Files
MailUI4Agents/gateway/internal/repo/platform_sessions_test.go
JianFeeeee ca64d12057 feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。

## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」)

插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` ——
每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有
邮件会话既不属于任何项目、彼此也不同组。

而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload
只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在
SSE 事件里,插件即使想用也拿不到。

- SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**,
  不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区
- 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到
  兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活)
- 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`

## 2. 会话别名列不出工作区下的历史会话(无法选择)

workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须
JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace
填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p`
在只剩 Agent 回信可匹配时两边都对不上。

- `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入
- `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace
  为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失
- `FindOrCreateDefaultSession` 同步改用会话的 workspace

## 3. 平台侧会话在补全里根本不存在

人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。

新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。
**上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway,
Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。

- 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有
  本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断
  这条到底是不是真的本侧会话
- **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是
  三态语义,指向不存在的会话直接 404
- **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像),
  而不是传空数组把镜像抹掉
- **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是
  派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名;
  它们是父 agent 内部的工作单元,人往里发邮件毫无意义
- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里
  出现几个点哪个都不确定的候选
- DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着

补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与
第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是
「缓存选型」而不是 brisk-harbor 这种随机短名。

## 4. 对话树看不见抄送与转发产生的分支

旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是
它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到
另一个;挂在原件上的转发分支同理。

改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向,
因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点
为什么并列,唯一的解释就是父邮件抄送给了两个人。

## 5. DSH 插件(Phase 7.7)

卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是
`followup()` 的参数形状:DSH 要完整的 UserMessage(content + source),
而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。

- `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的
  session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道
- `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的
  permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall,
  可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回
- 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 ——
  留在别名里会让它自己被解析器切开)
- 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处
  炸一个无关错误」

## 其他

- `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup`
  而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的
  foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰
  systemd(演练时误停过一次生产服务)
- 插件 dist/ 不进版本库,install.sh 负责构建
- `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话
2026-09-02 20:05:51 +08:00

355 lines
13 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
import (
"context"
"testing"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// seedPlatformAgent 注册一个 Agent平台会话镜像与它绑定。
// 与 quota_test.go 的 seedAgent 区分开:那个要指定 default_rounds这里不关心。
func seedPlatformAgent(t *testing.T, name string) {
t.Helper()
if _, err := db.DB.ExecContext(context.Background(),
`INSERT INTO agents (agent_name, secret, platform, status) VALUES ($1, 'x', $1, 'online')`,
name); err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
}
// seedSessionWS 建一个带 workspace 与别名的会话。
func seedSessionWS(t *testing.T, alias, workspace, subject string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := db.DB.QueryRowContext(context.Background(), `
INSERT INTO sessions (session_alias, workspace, from_agent, subject, alias_source)
VALUES ($1, $2, 'admin', $3, 'platform')
RETURNING session_id
`, alias, workspace, subject).Scan(&id)
if err != nil {
t.Fatalf("seed session %s: %v", alias, err)
}
return id
}
// seedMailWS 插一封带明确 to_workspace 的邮件。
func seedMailWS(t *testing.T, sessionID uuid.UUID, from, to, toWS, subject string) uuid.UUID {
t.Helper()
var id uuid.UUID
err := db.DB.QueryRowContext(context.Background(), `
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
subject, body, cc_list, created_at)
VALUES ($1, $2, '', $3, $4, $5, 'body', '[]', $6)
RETURNING mail_id
`, sessionID, from, to, toWS, subject, nextSeedTime()).Scan(&id)
if err != nil {
t.Fatalf("seed mail: %v", err)
}
return id
}
// 这个测试是「会话别名没有正确显示曾经发生在工作区下的会话」那次故障的回归。
//
// 旧实现按 mails 反推工作区,条件是
// `to_workspace = $path OR from_workspace = $path`。
// 而 Agent 回信时 from_workspace 存的是 **Agent 名**(如 "dsh")而不是路径,
// 于是一旦会话里只剩 Agent 的回信可匹配,反推就落空、别名列不出来。
// 现在 workspace 存在会话自己身上,与邮件里那些脏数据无关。
func TestSuggestSessionCandidatesUsesSessionWorkspace(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "dsh")
sid := seedSessionWS(t, "brisk-harbor", "/home/program/agentmail", "缓存选型")
// 只有 Agent 的回信from_workspace 是脏的Agent 名to_workspace 是人类(空)
if _, err := db.DB.ExecContext(context.Background(), `
INSERT INTO mails (session_id, from_name, from_workspace, to_name, to_workspace,
subject, body, cc_list, created_at)
VALUES ($1, 'dsh', 'dsh', 'admin', '', 'Re: 缓存选型', 'body', '[]', $2)
`, sid, nextSeedTime()); err != nil {
t.Fatalf("seed agent reply: %v", err)
}
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/program/agentmail")
if err != nil {
t.Fatalf("SuggestSessionCandidates: %v", err)
}
if len(got) != 1 {
t.Fatalf("应有 1 个候选,实际 %d —— 会话的 workspace 列没被用上", len(got))
}
if got[0].Alias != "brisk-harbor" {
t.Errorf("别名错误:%q", got[0].Alias)
}
if got[0].Source != "mail" {
t.Errorf("来源应为 mail实际 %q", got[0].Source)
}
if got[0].Title != "缓存选型" {
t.Errorf("标题应带出来:%q", got[0].Title)
}
}
// 历史会话的 workspace 列是空的(新加的列),必须回退到 mails.to_workspace 反推,
// 否则升级后所有老会话一夜之间从候选列表里消失。
func TestSuggestSessionCandidatesFallsBackToMails(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "opencode")
// workspace 留空,模拟升级前建立的会话
sid := seedSessionWS(t, "legacy-thread", "", "老线索")
seedMailWS(t, sid, "admin", "opencode", "/home/legacy", "老线索")
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/legacy")
if err != nil {
t.Fatalf("SuggestSessionCandidates: %v", err)
}
if len(got) != 1 || got[0].Alias != "legacy-thread" {
t.Fatalf("老会话应能靠 mails 反推出来,实际 %+v", got)
}
}
// 工作区不匹配的会话不能出现:候选项点下去就会被填进 session 位,
// 而 session 位是三态语义 —— 指向别处的会话会直接 404「无法送达」。
func TestSuggestSessionCandidatesFiltersByWorkspace(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "dsh")
mine := seedSessionWS(t, "here-thread", "/home/a", "本区")
seedMailWS(t, mine, "admin", "dsh", "/home/a", "本区")
other := seedSessionWS(t, "there-thread", "/home/b", "别区")
seedMailWS(t, other, "admin", "dsh", "/home/b", "别区")
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "/home/a")
if err != nil {
t.Fatalf("SuggestSessionCandidates: %v", err)
}
if len(got) != 1 || got[0].Alias != "here-thread" {
t.Fatalf("只应给出本工作区的会话,实际 %+v", got)
}
}
// path 为空(地址写成 `dsh` 而不带 @/path时不按工作区过滤
// 用户还没写到 path 段就该看到全部可续的会话。
func TestSuggestSessionCandidatesEmptyPathReturnsAll(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "dsh")
a := seedSessionWS(t, "ws-a", "/home/a", "A")
seedMailWS(t, a, "admin", "dsh", "/home/a", "A")
b := seedSessionWS(t, "ws-b", "/home/b", "B")
seedMailWS(t, b, "admin", "dsh", "/home/b", "B")
got, err := SuggestSessionCandidates(context.Background(), "admin", "dsh", "")
if err != nil {
t.Fatalf("SuggestSessionCandidates: %v", err)
}
if len(got) != 2 {
t.Fatalf("path 为空应给出全部 2 条,实际 %d", len(got))
}
}
// 平台侧会话(人直接在 opencode/DSH 界面上开的)经心跳上报后也要能被选中 ——
// 这正是「定期从 agent 平台同步会话」要解决的问题。
func TestSuggestSessionCandidatesIncludesPlatformMirror(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "opencode")
now := time.Now()
err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{
{PlatformID: "ses_1", Workspace: "/home/program/agentmail", Slug: "witty-planet",
Title: "重构导入路径", MailDriven: false, UpdatedAt: &now},
{PlatformID: "ses_2", Workspace: "/home/other", Slug: "brave-comet",
Title: "别的工作区", MailDriven: false, UpdatedAt: &now},
})
if err != nil {
t.Fatalf("ReplacePlatformSessions: %v", err)
}
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/program/agentmail")
if err != nil {
t.Fatalf("SuggestSessionCandidates: %v", err)
}
if len(got) != 1 {
t.Fatalf("应有 1 个平台候选,实际 %d%+v", len(got), got)
}
if got[0].Alias != "witty-planet" || got[0].Source != "platform" {
t.Errorf("平台候选错误:%+v", got[0])
}
if got[0].Title != "重构导入路径" {
t.Errorf("标题应带出来:%q", got[0].Title)
}
}
// 同一别名两边都有时保留 mail 来源:它是「一定送得到」的保证,
// 镜像只是平台的说法。但镜像的标题该补上去 —— 平台标题通常比会话主题更贴切。
func TestSuggestSessionCandidatesMailWinsOverMirror(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "opencode")
// 本侧线索:有别名但主题为空
sid := seedSessionWS(t, "witty-planet", "/home/x", "")
seedMailWS(t, sid, "admin", "opencode", "/home/x", "某事")
now := time.Now()
if err := ReplacePlatformSessions(context.Background(), "opencode", []PlatformSession{
{PlatformID: "ses_1", Workspace: "/home/x", Slug: "witty-planet",
Title: "平台侧的标题", UpdatedAt: &now},
}); err != nil {
t.Fatalf("ReplacePlatformSessions: %v", err)
}
got, err := SuggestSessionCandidates(context.Background(), "admin", "opencode", "/home/x")
if err != nil {
t.Fatalf("SuggestSessionCandidates: %v", err)
}
if len(got) != 1 {
t.Fatalf("同名应合并成 1 条,实际 %d%+v", len(got), got)
}
if got[0].Source != "mail" {
t.Errorf("应保留 mail 来源(它保证送得到),实际 %q", got[0].Source)
}
if got[0].Title != "平台侧的标题" {
t.Errorf("本侧标题为空时应补上镜像的:%q", got[0].Title)
}
}
// 上报是整表替换:平台侧删掉的会话必须从候选列表里消失。
// 增量合并会让它永远留着,而 session 位指向一条不存在的会话会直接 404。
func TestReplacePlatformSessionsIsFullReplace(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "dsh")
ctx := context.Background()
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
{PlatformID: "s1", Workspace: "/w", Slug: "one"},
{PlatformID: "s2", Workspace: "/w", Slug: "two"},
}); err != nil {
t.Fatalf("首次上报: %v", err)
}
if got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w"); len(got) != 2 {
t.Fatalf("首次上报应有 2 条,实际 %d", len(got))
}
// 第二次只报一条:另一条在平台侧已被删除
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
{PlatformID: "s1", Workspace: "/w", Slug: "one"},
}); err != nil {
t.Fatalf("二次上报: %v", err)
}
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
if len(got) != 1 || got[0].Alias != "one" {
t.Fatalf("整表替换失效,实际 %+v", got)
}
}
// 无 slug 的平台会话不进候选slug 是填进 session 位的值,
// 没有它这一项点下去只能得到一个空的 session 段。
func TestPlatformSessionsWithoutSlugAreSkipped(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "dsh")
ctx := context.Background()
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
{PlatformID: "s1", Workspace: "/w", Slug: ""},
{PlatformID: "s2", Workspace: "/w", Slug: "named"},
}); err != nil {
t.Fatalf("上报: %v", err)
}
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
if len(got) != 1 || got[0].Alias != "named" {
t.Fatalf("无 slug 的应被跳过,实际 %+v", got)
}
}
// 上报里的重复 platform_id 不该让整次事务失败(主键冲突)。
func TestReplacePlatformSessionsDedupes(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "dsh")
ctx := context.Background()
if err := ReplacePlatformSessions(ctx, "dsh", []PlatformSession{
{PlatformID: "dup", Workspace: "/w", Slug: "first"},
{PlatformID: "dup", Workspace: "/w", Slug: "second"},
}); err != nil {
t.Fatalf("重复 id 不该报错: %v", err)
}
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/w")
if len(got) != 1 || got[0].Alias != "first" {
t.Fatalf("应保留第一条,实际 %+v", got)
}
}
// SetSessionWorkspace 只在为空时写入:会话的工作区在建立时就定下了,
// 之后不该被一封发往别处的邮件改掉 —— 那会让它在候选列表里凭空换工作区。
func TestSetSessionWorkspaceDoesNotOverwrite(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid := seedSessionWS(t, "fixed-ws", "/home/original", "某事")
if err := SetSessionWorkspace(ctx, sid, "/home/hijacked"); err != nil {
t.Fatalf("SetSessionWorkspace: %v", err)
}
var ws string
if err := db.DB.QueryRowContext(ctx,
`SELECT workspace FROM sessions WHERE session_id = $1`, sid).Scan(&ws); err != nil {
t.Fatalf("read back: %v", err)
}
if ws != "/home/original" {
t.Errorf("已有 workspace 被覆盖成 %q", ws)
}
// 空的那种要能补上(历史会话回填)
empty := seedSessionWS(t, "empty-ws", "", "某事")
if err := SetSessionWorkspace(ctx, empty, "/home/filled"); err != nil {
t.Fatalf("SetSessionWorkspace(empty): %v", err)
}
if err := db.DB.QueryRowContext(ctx,
`SELECT workspace FROM sessions WHERE session_id = $1`, empty).Scan(&ws); err != nil {
t.Fatalf("read back: %v", err)
}
if ws != "/home/filled" {
t.Errorf("空 workspace 未被补上,实际 %q", ws)
}
}
// CreateSession 要把 workspace 存下来 —— 这是整条链的起点,
// 漏在这里的话后面所有查询都只能靠 mails 反推。
func TestCreateSessionStoresWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
id, err := CreateSession(ctx, nil, "admin", "带工作区", "/home/program/agentmail")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
var ws string
if err := db.DB.QueryRowContext(ctx,
`SELECT workspace FROM sessions WHERE session_id = $1`, id).Scan(&ws); err != nil {
t.Fatalf("read back: %v", err)
}
if ws != "/home/program/agentmail" {
t.Errorf("workspace 未落库:%q", ws)
}
}
// 归档的会话不进候选:归档就是「这条线索结束了」,
// 还出现在补全里等于邀请用户往一条已关闭的线索里发信。
func TestSuggestSessionCandidatesExcludesArchived(t *testing.T) {
setupTestDB(t)
seedPlatformAgent(t, "dsh")
ctx := context.Background()
sid := seedSessionWS(t, "done-thread", "/home/a", "已完成")
seedMailWS(t, sid, "admin", "dsh", "/home/a", "已完成")
if _, err := db.DB.ExecContext(ctx,
`UPDATE sessions SET status = 'archived' WHERE session_id = $1`, sid); err != nil {
t.Fatalf("archive: %v", err)
}
got, _ := SuggestSessionCandidates(ctx, "admin", "dsh", "/home/a")
if len(got) != 0 {
t.Fatalf("归档会话不该出现,实际 %+v", got)
}
}