四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。 ## 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:插件重启丢了待决映射时要靠它定位会话
287 lines
9.1 KiB
Go
287 lines
9.1 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
func TestPreviewTruncatesOnUTF8Boundary(t *testing.T) {
|
||
// 「巡」是 3 字节;在 max=4 处切会切进第 2 个字符中间
|
||
s := "巡检报告"
|
||
got := preview(s, 4)
|
||
if got != "巡..." {
|
||
t.Fatalf("按 UTF-8 边界截断失败:%q", got)
|
||
}
|
||
for i, r := range got {
|
||
if r == 0xFFFD {
|
||
t.Fatalf("位置 %d 出现替换符,说明切在了字符中间", i)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPreviewKeepsShortBodyIntact(t *testing.T) {
|
||
if got := preview("短正文", 240); got != "短正文" {
|
||
t.Fatalf("未超长却被改动:%q", got)
|
||
}
|
||
}
|
||
|
||
// seedReply 插一封回复:parent 指向来信。
|
||
func seedReply(t *testing.T, sessionID uuid.UUID, parent uuid.UUID, from, to, subject string) uuid.UUID {
|
||
t.Helper()
|
||
var id uuid.UUID
|
||
err := db.DB.QueryRowContext(context.Background(), `
|
||
INSERT INTO mails (session_id, parent_mail_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, parent, from, to, subject, nextSeedTime()).Scan(&id)
|
||
if err != nil {
|
||
t.Fatalf("seed reply: %v", err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
// 线索根定位:整棵树从根展开,所以这一步错了后面全错。
|
||
func TestThreadRootOf(t *testing.T) {
|
||
setupTestDB(t)
|
||
sid := seedSessionRow(t, "root-of")
|
||
|
||
root := seedMailIn(t, sid, "admin", "dsh", "原件")
|
||
mid := seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
|
||
leaf := seedReply(t, sid, mid, "admin", "dsh", "Re: Re: 原件")
|
||
|
||
cases := []struct {
|
||
name string
|
||
from uuid.UUID
|
||
depth int
|
||
}{
|
||
{"从根本身出发", root, 0},
|
||
{"从中间一封出发", mid, 1},
|
||
{"从叶子出发", leaf, 2},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
gotRoot, gotDepth, err := ThreadRootOf(context.Background(), c.from)
|
||
if err != nil {
|
||
t.Fatalf("ThreadRootOf: %v", err)
|
||
}
|
||
if gotRoot != root {
|
||
t.Errorf("根定位错误:得到 %s,期望 %s", gotRoot, root)
|
||
}
|
||
if gotDepth != c.depth {
|
||
t.Errorf("层数错误:得到 %d,期望 %d", gotDepth, c.depth)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 这个测试是对话树那次故障的回归:
|
||
// 一封抄送给两个 Agent 的邮件收到两个回复,它们互为**兄弟**。
|
||
// 旧实现从锚点分「祖先方向 + 子孙方向」两路展开,兄弟既不是锚点的祖先
|
||
// 也不是它的子孙,于是整条分支在树里根本不出现。
|
||
// 从线索根 BFS 之后,兄弟都是根的子孙,必须一次全出来。
|
||
func TestDescendantsFromRootIncludesSiblings(t *testing.T) {
|
||
setupTestDB(t)
|
||
sid := seedSessionRow(t, "siblings")
|
||
|
||
root := seedMailIn(t, sid, "admin", "dsh", "测试抄送")
|
||
replyA := seedReply(t, sid, root, "dsh", "admin", "Re: 测试抄送")
|
||
replyB := seedReply(t, sid, root, "opencode", "admin", "Re: 测试抄送")
|
||
|
||
// 从 replyA 出发定位根,再从根整树展开
|
||
gotRoot, anchorDepth, err := ThreadRootOf(context.Background(), replyA)
|
||
if err != nil {
|
||
t.Fatalf("ThreadRootOf: %v", err)
|
||
}
|
||
if gotRoot != root || anchorDepth != 1 {
|
||
t.Fatalf("根定位错误:root=%s depth=%d", gotRoot, anchorDepth)
|
||
}
|
||
|
||
nodes, hasMore, err := DescendantsRaw(context.Background(), gotRoot, 0, 50)
|
||
if err != nil {
|
||
t.Fatalf("DescendantsRaw: %v", err)
|
||
}
|
||
if hasMore {
|
||
t.Error("三封邮件不该报 hasMore")
|
||
}
|
||
|
||
byID := map[uuid.UUID]TreeMail{}
|
||
for _, n := range nodes {
|
||
byID[n.ID] = n
|
||
}
|
||
for name, id := range map[string]uuid.UUID{"根": root, "回复A": replyA, "回复B": replyB} {
|
||
if _, ok := byID[id]; !ok {
|
||
t.Errorf("%s 不在树里 —— 兄弟分支又丢了", name)
|
||
}
|
||
}
|
||
if byID[root].Depth != 0 {
|
||
t.Errorf("根的深度应为 0,实际 %d", byID[root].Depth)
|
||
}
|
||
if byID[replyA].Depth != 1 || byID[replyB].Depth != 1 {
|
||
t.Errorf("两个回复都应在深度 1:A=%d B=%d", byID[replyA].Depth, byID[replyB].Depth)
|
||
}
|
||
}
|
||
|
||
// 转发落在**另一个会话**里,但 parent 仍指向原件。
|
||
// 树必须跨会话展开,否则「这条线索转发给谁了」就看不见了。
|
||
func TestDescendantsCrossSession(t *testing.T) {
|
||
setupTestDB(t)
|
||
srcSession := seedSessionRow(t, "fwd-src")
|
||
dstSession := seedSessionRow(t, "fwd-dst")
|
||
|
||
root := seedMailIn(t, srcSession, "admin", "dsh", "原件")
|
||
// 转发:新会话,parent 仍指原件
|
||
fwd := seedReply(t, dstSession, root, "admin", "opencode", "Fwd: 原件")
|
||
// 转发的下游回复,还在新会话里
|
||
fwdReply := seedReply(t, dstSession, fwd, "opencode", "admin", "Re: Fwd: 原件")
|
||
|
||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 50)
|
||
if err != nil {
|
||
t.Fatalf("DescendantsRaw: %v", err)
|
||
}
|
||
found := map[uuid.UUID]int{}
|
||
for _, n := range nodes {
|
||
found[n.ID] = n.Depth
|
||
}
|
||
if _, ok := found[fwd]; !ok {
|
||
t.Error("转发不在树里 —— 跨会话展开失效")
|
||
}
|
||
if _, ok := found[fwdReply]; !ok {
|
||
t.Error("转发的下游回复不在树里")
|
||
}
|
||
if found[fwd] != 1 || found[fwdReply] != 2 {
|
||
t.Errorf("跨会话深度错误:fwd=%d fwdReply=%d(期望 1/2)", found[fwd], found[fwdReply])
|
||
}
|
||
// 会话不同 → session_id 必须如实反映,否则前端无法标出「线索去了别的会话」
|
||
for _, n := range nodes {
|
||
if n.ID == fwd && n.SessionID != dstSession {
|
||
t.Errorf("转发的 session_id 错误:%s,期望 %s", n.SessionID, dstSession)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 分页:BFS 顺序稳定,两页拼起来等于一次全取。
|
||
func TestDescendantsPaginationStable(t *testing.T) {
|
||
setupTestDB(t)
|
||
sid := seedSessionRow(t, "paging")
|
||
|
||
root := seedMailIn(t, sid, "admin", "dsh", "原件")
|
||
for i := 0; i < 5; i++ {
|
||
seedReply(t, sid, root, "dsh", "admin", "Re: 原件")
|
||
}
|
||
|
||
full, hasMoreFull, err := DescendantsRaw(context.Background(), root, 0, 50)
|
||
if err != nil {
|
||
t.Fatalf("全取: %v", err)
|
||
}
|
||
if hasMoreFull {
|
||
t.Error("6 封邮件一次取完不该报 hasMore")
|
||
}
|
||
if len(full) != 6 {
|
||
t.Fatalf("应有 6 个节点,实际 %d", len(full))
|
||
}
|
||
|
||
page1, hasMore1, err := DescendantsRaw(context.Background(), root, 0, 4)
|
||
if err != nil {
|
||
t.Fatalf("第一页: %v", err)
|
||
}
|
||
if !hasMore1 {
|
||
t.Error("还有 2 封没取,hasMore 应为真")
|
||
}
|
||
page2, hasMore2, err := DescendantsRaw(context.Background(), root, 4, 4)
|
||
if err != nil {
|
||
t.Fatalf("第二页: %v", err)
|
||
}
|
||
if hasMore2 {
|
||
t.Error("第二页已取完,hasMore 应为假")
|
||
}
|
||
|
||
joined := append(append([]TreeMail{}, page1...), page2...)
|
||
if len(joined) != len(full) {
|
||
t.Fatalf("两页拼接 %d 个,全取 %d 个", len(joined), len(full))
|
||
}
|
||
for i := range full {
|
||
if joined[i].ID != full[i].ID {
|
||
t.Fatalf("第 %d 个节点顺序不一致:分页 %s,全取 %s —— BFS 顺序不稳定,"+
|
||
"分页加载会重复或漏掉节点", i, joined[i].ID, full[i].ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TreeMailByID 是「锚点没落进 BFS 首页」时的回填手段,深度由调用方给。
|
||
func TestTreeMailByIDUsesGivenDepth(t *testing.T) {
|
||
setupTestDB(t)
|
||
sid := seedSessionRow(t, "by-id")
|
||
id := seedMailIn(t, sid, "admin", "dsh", "某封")
|
||
|
||
got, err := TreeMailByID(context.Background(), id, 7)
|
||
if err != nil {
|
||
t.Fatalf("TreeMailByID: %v", err)
|
||
}
|
||
if got.ID != id {
|
||
t.Errorf("取错了邮件:%s", got.ID)
|
||
}
|
||
if got.Depth != 7 {
|
||
t.Errorf("深度应取调用方给的 7,实际 %d", got.Depth)
|
||
}
|
||
// 树视图只要预览,全文必须被清空 —— 否则整条线索会把几百 KB 正文塞给前端
|
||
if got.Body != "" {
|
||
t.Errorf("Body 应清空,实际 %q", got.Body)
|
||
}
|
||
if got.BodyPreview == "" {
|
||
t.Error("BodyPreview 应有内容")
|
||
}
|
||
}
|
||
|
||
// 抄送列表必须原样带出来:树上两个兄弟节点为什么并列,
|
||
// 唯一的解释就是父邮件抄送给了两个人。丢了 cc_list 前端就没法说明。
|
||
func TestTreeCarriesCCList(t *testing.T) {
|
||
setupTestDB(t)
|
||
sid := seedSessionRow(t, "cc-carry")
|
||
|
||
var root 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, 'admin', '', 'dsh', '', '抄送两人', 'body',
|
||
'[{"name":"opencode","path":"/home","session":"new","raw":"opencode@/home.new"}]', $2)
|
||
RETURNING mail_id
|
||
`, sid, nextSeedTime()).Scan(&root)
|
||
if err != nil {
|
||
t.Fatalf("seed: %v", err)
|
||
}
|
||
|
||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
|
||
if err != nil {
|
||
t.Fatalf("DescendantsRaw: %v", err)
|
||
}
|
||
if len(nodes) != 1 {
|
||
t.Fatalf("应有 1 个节点,实际 %d", len(nodes))
|
||
}
|
||
if len(nodes[0].CCList) != 1 {
|
||
t.Fatalf("抄送应有 1 人,实际 %d —— cc_list 没带出来", len(nodes[0].CCList))
|
||
}
|
||
if nodes[0].CCList[0].Raw != "opencode@/home.new" {
|
||
t.Errorf("抄送 raw 错误:%q", nodes[0].CCList[0].Raw)
|
||
}
|
||
}
|
||
|
||
// 无抄送时 cc_list 必须是空数组而不是 null:
|
||
// Go 的 nil slice 会序列化成 null,前端 node.cc_list.length 直接抛异常。
|
||
func TestTreeEmptyCCIsArrayNotNull(t *testing.T) {
|
||
setupTestDB(t)
|
||
sid := seedSessionRow(t, "cc-empty")
|
||
root := seedMailIn(t, sid, "admin", "dsh", "无抄送")
|
||
|
||
nodes, _, err := DescendantsRaw(context.Background(), root, 0, 10)
|
||
if err != nil {
|
||
t.Fatalf("DescendantsRaw: %v", err)
|
||
}
|
||
if nodes[0].CCList == nil {
|
||
t.Error("cc_list 为 nil,会序列化成 null")
|
||
}
|
||
}
|