## 要解决的问题
用户 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 包全绿。
90 lines
3.4 KiB
Go
90 lines
3.4 KiB
Go
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)
|
||
}
|
||
}
|