Files
MailUI4Agents/server/internal/handler/session_tree_test.go
JianFeeeee cd894d5e4d feat(线索树)★★: 会话级父子 + 树视图 —— 跨会话分叉第一次可见
## 要解决的问题

用户 2026-10-04:「对话树实现得非常原始,根本没有形成/展示为树结构」。

★ 先纠正我自己的一个误判:我先前只看 `ContactPanel`(会话列表)就断言
「前端纯平铺、零层级」。那是不完整的检查 —— `ThreadView.tsx` 早就在用
`node.depth` 做缩进 + 连接线渲染**会话内**的邮件树,服务端 `DescendantsRaw`
也早就有 `WITH RECURSIVE … lvl`。所以**会话内**的树是有的。

真正缺的是**跨会话**:`sessions` 表根本没有 `parent_session_id`,
而生产库实测有 **6 封**邮件的 parent 指向**另一条会话**,
界面上它们是几行互不相干的东西。而「A 交给 B 之后 B 继续推」正是
协作里最常见的形状 —— 那 6 条里就有 `本机-agent-能力盘点 → 渲染自检`
这种纯分叉。

## 回填后的真实拓扑(生产数据)

    harmony-emu-unblock ─┐
    deploy-pi-bridge-…  ─┴→ 邮件驱动…项目概述 ─┬→ 核实-HomeAgent-mail-bridge
                                              └→ 邮件驱动…项目概述-2 → 时间显示自检
    本机-agent-能力盘点 → 渲染自检

## 可见性:逐节点过滤 + 剪断不可见祖先(安全边界)

树天然会把父节点带给子节点,而「我能看见 B」≠「我能看见 B 的父 A」
(A 可能是别人与别人的对话)。

所以按节点过滤之后**必须重算 parent**:不可见的祖先一律跳过、树在可见处
重新起根。只过滤不重算,输出里就带着不可见父的 session_id 与标题 ——
一条真实的泄露路径,而且它藏在「树视图」这个新功能里,没人会想到去查。

**admin 看全部**(用户 2026-10-04 定的分层)。

★ 与 Agent 侧那条边界无关:`AgentMayReadSession` 是 15e4fe9 / 095213b
修出来的越权防护(Agent 只能读自己参与过的会话);本端点属人类登录态,
admin 全看是显式授权的。两者语义不同,不要混谈。

## 回填只跑一次

回填改的是会话**拓扑**,不是派生数据。若每次 Migrate 都跑,
「某人手工把 parent 改对」会在每次重启时被悄悄改回去 —— 与既有
`backfillMailReads` 同族(其注释:每次跑会把「某抄送方读过」按主收件人
写成已读,正是那次要修的语义错误)。故用 `app_meta` marker 守住。

★ 判据分两包:`repo` 测回填口径与剪枝,`db` 测「二次 Migrate 不覆盖人工设置」。
只写在 repo 包就只测到「回填幂等」,测不到 Migrate 那一层。

## 实现中修掉的三个真 bug(都被判据抓到)

1. **参数顺序反了** —— `t.lvl < ?` 是 SQL 里第一个占位符,我放在 args 末尾
   ⇒ 根查询拿到一个整数、匹配不到任何行,树只返回递归分支那半。
2. **环下打满 55s 超时** —— 计数写成相关子查询,环下每个节点都重跑一次
   mails 计数。改成 `LEFT JOIN` 两个聚合后 0.01s。
3. **`IN (NULL)`** —— parentIDs 为空时该表达式恒不匹配任何行。

## 判据(8 格)

含防环(环下 0.01s + 耗时断言)、不覆盖人工父、不把同会话内邮件父子
当会话父子、不回填不可见祖先(两个变异分别去掉「过滤」「剪断」都转红)。

★ 判据自己错了一次:「隐藏不可见祖先」那格我写成「对每个可见节点都断言
depth==0」,但 B 重起根(0)、C 仍挂在 B 下(1),两者不同 —— 判据红而代码
是对的。(又是「判据比语义宽/窄」那一族。)

14 包全绿。
2026-10-04 12:03:58 +08:00

90 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
/*
会话树的**可见性剪断**判据(2026-10-04)。
# 为什么这一格最要紧
树天然会把**父节点**的信息带给子节点。而「我能看见 B」不等于「我能看见
B 的父A」—— A 可能是别人与别人的对话。
所以服务端必须**按节点过滤 + 剪断不可见祖先**。只做前者不做后者,输出里
就带着不可见父节点的 session_id 与标题 —— 那是一条真实的泄露路径,
而且它藏在「树视图」这个新功能里,不会有人想到去查。
★ 与 Agent 侧那条边界无关:AgentMayReadSession(Agent 只能读自己参与过的会话)
是 15e4fe9 / 095213b 修出来的越权防护;本端点属人类登录态,admin 全看是用户
2026-10-04 显式授权的。两者语义不同,不要混谈。
*/
import (
"testing"
"github.com/agentmail/gateway/internal/repo"
)
// A → B → C,用户只参与 B 与 C(A 是别人与别人的对话)。
func TestPruneTreeHidesInvisibleAncestor(t *testing.T) {
nodes := []repo.SessionNode{
{SessionID: "A", Alias: "别人的私密线索", Depth: 0},
{SessionID: "B", Alias: "我参与的", ParentSessionID: "A", Depth: 1},
{SessionID: "C", Alias: "我参与的续线", ParentSessionID: "B", Depth: 2},
}
visible := map[string]bool{"B": true, "C": true}
out := repo.PruneTree(nodes, visible)
if len(out) != 2 {
t.Fatalf("只应留下可见的 2 个节点,实际 %d:%+v", len(out), out)
}
// ★ 初版这里对**每个**输出节点都断言 Depth==0,那是判据自己写错了:
// B 重新起根(depth 0),但 C 仍挂在 B 之下(depth 1)—— 两者不同。
// 判据红着而代码是对的。(又一次「判据比语义宽/窄」的同族错误。)
for _, n := range out {
if n.SessionID == "A" {
t.Fatal("★ 不可见的 A 不该出现在输出里")
}
if n.ParentSessionID == "A" {
t.Errorf("★ B 的 parent 仍指向不可见的 A ⇒ 泄露了 A 的 session_id")
}
}
depth := map[string]int{}
parent := map[string]string{}
for _, n := range out {
depth[n.SessionID] = n.Depth
parent[n.SessionID] = n.ParentSessionID
}
if depth["B"] != 0 || parent["B"] != "" {
t.Errorf("B 的父被剪掉后应重新起根(depth=0, parent 空),实际 depth=%d parent=%q",
depth["B"], parent["B"])
}
// C 仍挂在 **B** 之下(B 可见),但绝不能经由不可见的 A
if depth["C"] != 1 || parent["C"] != "B" {
t.Errorf("C 应挂在 B 之下(depth=1, parent=B),实际 depth=%d parent=%q",
depth["C"], parent["C"])
}
}
// 全部可见时不得改动任何东西(admin 走的就是这条路)。
func TestPruneTreeKeepsFullyVisibleTree(t *testing.T) {
nodes := []repo.SessionNode{
{SessionID: "A", Alias: "a", Depth: 0},
{SessionID: "B", Alias: "b", ParentSessionID: "A", Depth: 1},
{SessionID: "C", Alias: "c", ParentSessionID: "B", Depth: 2},
}
visible := map[string]bool{"A": true, "B": true, "C": true}
out := repo.PruneTree(nodes, visible)
if len(out) != 3 {
t.Fatalf("全部可见时应保留 3 个节点,实际 %d", len(out))
}
for _, n := range out {
want := map[string]int{"A": 0, "B": 1, "C": 2}[n.SessionID]
if n.Depth != want {
t.Errorf("%s depth 应为 %d,实际 %d", n.SessionID, want, n.Depth)
}
}
if out[1].ParentSessionID != "A" {
t.Errorf("全部可见时 B 的 parent 不该被清空,实际 %q", out[1].ParentSessionID)
}
}