Files
MailUI4Agents/server/internal/repo/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

254 lines
8.6 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 repo
/*
会话级线索树的判据(2026-10-04)。
# 这一格最要紧的是**环**
`parent_session_id` 是自引用字段。加不加 FK 约束都可能被人填出环(A→B→A),
而读取走递归 CTE —— 环会让它**无限膨胀直到拖垮进程**。所以:
· 写入侧:回填只认「直接父邮件所在的会话」,且不回填已有值的会话
· 读取侧:**必须**带 depth 上限(maxTreeDepth)
第 2 条是兜底,不是装饰:环是**数据问题**,而数据问题必须在读取侧活下来,
否则一次误操作会变成「整个端点挂死」。
# 为什么不做「沿链上溯到根」
那样会把 A→B→C 三跳压成「A 是 C 的祖先」,而中间那条 B 线索恰恰是独立的
协作对象(参与方可能不同)。压平会让拓扑失真 —— 比没有树更糟。
所以只认直接跨会话分叉。
*/
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
)
func setupTreeDB(t *testing.T) {
t.Helper()
db.Close()
if err := db.Connect(context.Background(), "sqlite://"+t.TempDir()+"/tree.db"); err != nil {
t.Fatalf("连接测试库: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("迁移: %v", err)
}
t.Cleanup(db.Close)
}
func mkSession(t *testing.T, alias, subject string) string {
t.Helper()
var id string
if err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO sessions (session_alias, subject, status, workspace, from_agent)
VALUES ($1,$2,'active','/tmp','pi') RETURNING session_id`, alias, subject).Scan(&id); err != nil {
t.Fatalf("建会话 %s: %v", alias, err)
}
return id
}
func mkMail(t *testing.T, sid, subject, parentID string) string {
t.Helper()
var p any
if parentID != "" {
p = parentID
}
var id string
if err := db.DB.QueryRowContext(context.Background(),
`INSERT INTO mails (mail_id, session_id, from_name, to_workspace, to_name, subject, body, status, parent_mail_id)
VALUES (gen_random_uuid(),$1,'pi','/tmp','dsh',$2,'x','unread',$3) RETURNING mail_id`,
sid, subject, p).Scan(&id); err != nil {
t.Fatalf("建邮件 %s: %v", subject, err)
}
return id
}
// ★ 核心:跨会话分叉要连成树。
func TestBackfillLinksCrossSessionForks(t *testing.T) {
setupTreeDB(t)
parent := mkSession(t, "parent-line", "原始线索")
child := mkSession(t, "child-line", "接手继续")
pid := mkMail(t, parent, "交给 B", "")
mkMail(t, child, "B 接手", pid)
n, err := BackfillSessionParents(context.Background())
if err != nil {
t.Fatalf("回填失败: %v", err)
}
if n != 1 {
t.Errorf("应回填 1 条 parent_session_id,实际 %d", n)
}
nodes, err := ListSessionTree(context.Background(), nil)
if err != nil {
t.Fatalf("列树失败: %v", err)
}
if len(nodes) != 2 {
t.Fatalf("树应有 2 个节点,实际 %d:%+v", len(nodes), nodes)
}
// 父 depth=0 子 depth=1,且父的 child_count=1
var parentNode, childNode *SessionNode
for i := range nodes {
if nodes[i].SessionID == parent {
parentNode = &nodes[i]
} else if nodes[i].SessionID == child {
childNode = &nodes[i]
}
}
if parentNode == nil || childNode == nil {
t.Fatalf("两个节点都该在树里:%+v", nodes)
}
if childNode.Depth != 1 {
t.Errorf("子节点 depth 应为 1(缩进一级),实际 %d", childNode.Depth)
}
if parentNode.ChildCnt != 1 {
t.Errorf("父节点 child_count 应为 1,实际 %d", parentNode.ChildCnt)
}
}
// ★ 环不能挂死请求:库里已有环时,读取必须仍能返回。
func TestListSessionTreeSurvivesCycle(t *testing.T) {
setupTreeDB(t)
a := mkSession(t, "a", "A")
b := mkSession(t, "b", "B")
// 人为造环:A→B→A
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, b, a); err != nil {
t.Fatalf("设 A 的父: %v", err)
}
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, a, b); err != nil {
t.Fatalf("设 B 的父: %v", err)
}
// 有 depth 上限 ⇒ 这条递归会在 maxTreeDepth 处停下并返回,不会无限增长。
//
// ★ 这格真正要防的是「挂死」:初版把计数写成相关子查询,环下每个节点
// 都重跑一次 mails 计数 —— 实测 2 节点环打满 **55s 超时**。
// 改成 LEFT JOIN 聚合后是 0.01s 级。
//
// 注意环节点**不会**出现在结果里(根条件要求 parent 指向已不存在的会话,
// 而环上的 parent 真实存在 ⇒ 起点那条被排除,返回 0 个节点)。
// 那是**符合预期的**:环是坏数据,不该出现在树里。
// 但「被排除」不等于「不会打挂」—— 递归仍会跑,所以这格守的是后者。
st := time.Now()
nodes, err := ListSessionTree(context.Background(), []string{a})
elapsed := time.Since(st)
if err != nil {
t.Fatalf("★ 环不应让读取失败/挂死,实际报错: %v", err)
}
if elapsed > 5*time.Second {
t.Errorf("★ 环下查询耗时 %v(>5s)⇒ 防环/性能兜底失效", elapsed)
}
for _, n := range nodes {
if n.Depth > maxTreeDepth {
t.Fatalf("★ depth 超过上限 %d ⇒ 防环兜底失效", maxTreeDepth)
}
}
t.Logf("环下返回 %d 个节点(最大 depth=%d)", len(nodes), maxDepthOf(nodes))
}
func maxDepthOf(ns []SessionNode) int {
m := 0
for _, n := range ns {
if n.Depth > m {
m = n.Depth
}
}
return m
}
// ★ 回填不得覆盖人工设置的父。
func TestBackfillDoesNotOverwriteManual(t *testing.T) {
setupTreeDB(t)
parent := mkSession(t, "p", "父")
child := mkSession(t, "c", "子")
manual := mkSession(t, "manual-parent", "人工指定的父")
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, manual, child); err != nil {
t.Fatalf("设人工父: %v", err)
}
// 再造一封真实分叉邮件(回填会想写它)
pid := mkMail(t, parent, "真分叉", "")
mkMail(t, child, "子里的回信", pid)
if _, err := BackfillSessionParents(context.Background()); err != nil {
t.Fatalf("回填失败: %v", err)
}
var got string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
t.Fatalf("读父: %v", err)
}
if got != manual {
t.Errorf("★ 回填覆盖了人工设置的父:%s ⇒ 应为 %s", got, manual)
}
}
// ★ 同会话内的邮件父子**不该**产生会话父子(那会让会话自己指向自己)。
func TestBackfillIgnoresIntraSessionMail(t *testing.T) {
setupTreeDB(t)
sid := mkSession(t, "same", "同会话内续谈")
a := mkMail(t, sid, "第一封", "")
mkMail(t, sid, "第二封", a)
n, err := BackfillSessionParents(context.Background())
if err != nil {
t.Fatalf("回填失败: %v", err)
}
if n != 0 {
t.Errorf("同会话内的邮件父子不该建会话父子,实际回填 %d 条", n)
}
}
// ★ 一次性守卫:回填**只能跑一次**。
//
// 为什么这格要紧:回填改的是会话**拓扑**(parent_session_id),不是派生数据。
// 若每次 Migrate 都跑,那么「某人手工把 parent 改对/改空」这个意图会在每次
// 重启时被悄悄改回去 —— 与既有 backfillMailReads 踩过的坑同族(它的注释写着:
// 每次跑会把「某抄送方读过」按主收件人写成已读,正是那次要修的语义错误)。
//
// 判据形状:回填后再手工改一个 parent,**再次 Migrate**,那个改动必须还在。
func TestBackfillSessionParentsRunsOnlyOnce(t *testing.T) {
setupTreeDB(t)
parent := mkSession(t, "p", "父")
child := mkSession(t, "c", "子")
pid := mkMail(t, parent, "分叉", "")
mkMail(t, child, "子里的回信", pid)
if _, err := BackfillSessionParents(context.Background()); err != nil {
t.Fatalf("首次回填: %v", err)
}
// 手工改一个**不同于自动推断**的值(自动会写 parent,这里写成手工的别的会话)
manual := mkSession(t, "manual", "人工指定的父")
if _, err := db.DB.ExecContext(context.Background(),
`UPDATE sessions SET parent_session_id = ? WHERE session_id = ?`, manual, child); err != nil {
t.Fatalf("设人工父: %v", err)
}
// 第二次回填(模拟重启后 Migrate 又跑)
if _, err := BackfillSessionParents(context.Background()); err != nil {
t.Fatalf("二次回填: %v", err)
}
var got string
if err := db.DB.QueryRowContext(context.Background(),
`SELECT parent_session_id FROM sessions WHERE session_id = ?`, child).Scan(&got); err != nil {
t.Fatalf("读父: %v", err)
}
if got != manual {
t.Errorf("★ 二次回填覆盖了人工设置:%s ⇒ 应为 %s", got, manual)
}
}