diff --git a/server/internal/handler/mail.go b/server/internal/handler/mail.go index 662900a..bbc4c20 100644 --- a/server/internal/handler/mail.go +++ b/server/internal/handler/mail.go @@ -282,6 +282,29 @@ func SendMail(w http.ResponseWriter, r *http.Request) { return } + // 收件方是 Agent 且地址里没写 path 位时,用**会话的 workspace** 补上。 + // + // # 为什么必须补 + // + // `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口。 + // 而 Agent 之间的回信、以及人在对话页里点回复,地址里通常没有 path 位 + // —— 平台自己下发的 `reply_address` 就是这个形状。空着传下去,插件只能 + // 自己拼一个临时目录:**每封邮件一个不同的空目录**,DSH / opencode 按 + // cwd 分组,界面上于是「每处理一封邮件就多出一条未分组会话」,而模型在 + // 空目录里什么项目文件也看不到。 + // + // 会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释): + // 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。 + // + // # 为什么改 to 而不是只改建库那一行 + // + // 同一个值还进 `notifyRecipients` 的投递载荷(`to_workspace` / + // `self_address`)。改一处另一处不改,就会出现「API 读到的与插件推到的 + // 不是同一个目录」—— 那正是本项目一直在治的静默不一致。 + sessionWorkspace := repo.SessionWorkspaceOf(r.Context(), sessionID) + toIsHuman, _ := repo.IsHumanUser(r.Context(), to.Name) + to.Path = resolveToWorkspace(to.Path, sessionWorkspace, toIsHuman) + // Agent 新开的会话继承权限档位,**不得自行抬档**。 // // req 里根本没有 permission_mode 字段 —— 这是有意的:Agent 能指定档位 diff --git a/server/internal/handler/toworkspace.go b/server/internal/handler/toworkspace.go new file mode 100644 index 0000000..afcdc42 --- /dev/null +++ b/server/internal/handler/toworkspace.go @@ -0,0 +1,38 @@ +package handler + +// resolveToWorkspace 决定一封邮件落库与投递时用的 `to_workspace`。 +// +// # 它要解决的问题 +// +// `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口。而 Agent +// 之间的回信、以及人在对话页里点回复,三维地址里通常**没有 path 位** —— 平台 +// 下发的 `reply_address` 就是这个形状。空着传下去的后果是可观测的: +// +// - 插件只能自己拼一个临时目录,于是**每封邮件落在一个不同的空目录**里; +// - DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理一封邮件就多出 +// 一条未分组会话」; +// - 模型在那个空目录里什么项目文件也看不到,却照样开工。 +// +// 会话的 workspace 才是权威来源(见 models.SessionWorkspace 的注释):回信本来 +// 就是回给**那条会话**的,而那条会话知道自己属于哪个项目。 +// +// # 三条规则,顺序不能换 +// +// 1. 地址里写了 path → **照用**。那是人的明确意图,会话的 workspace 只是兜底。 +// 2. 没写、且收件方是**人** → 保持空。人只有名字,没有工作目录; +// 给人类也填 path,前端与 agent_discovery 会拼出 `gui-lab@/root/proj.别名` +// 这种错地址(`ToHuman` 字段就是为避开这件事加的)。 +// 3. 没写、且收件方是 **Agent** → 用会话的 workspace(可能仍为空串, +// 那表示这条会话确实没有工作目录,此时行为与从前一致)。 +// +// 抽成纯函数是为了能直接对规则本身做判断:上面第 2 条是**反向对照**, +// 少了它,把「一律填 path」写错成看起来也合理。 +func resolveToWorkspace(addrPath, sessionWorkspace string, toIsHuman bool) string { + if addrPath != "" { + return addrPath + } + if toIsHuman { + return "" + } + return sessionWorkspace +} diff --git a/server/internal/handler/toworkspace_test.go b/server/internal/handler/toworkspace_test.go new file mode 100644 index 0000000..6cea5d4 --- /dev/null +++ b/server/internal/handler/toworkspace_test.go @@ -0,0 +1,93 @@ +package handler + +import "testing" + +// resolveToWorkspace 的判据。 +// +// 每一条都对着一个真实后果,不是凑覆盖率: +// - Agent 间回信丢 path → 收信方落进一次性空目录(界面上每封多一条会话) +// - 给人类填 path → 界面拼出 `gui-lab@/root/proj.别名` 这种错地址 +// - 地址里明确写了 path → 必须照用(人的意图优先于兜底) +func TestResolveToWorkspace(t *testing.T) { + const ws = "/root/projects/demo" + + cases := []struct { + name string + addrPath string + sessionWS string + toIsHuman bool + want string + why string + }{ + { + name: "地址里没写 path,收件方是 Agent → 继承会话 workspace", + addrPath: "", sessionWS: ws, toIsHuman: false, + want: ws, + why: "这是 Agent 间回信与人点回复的常态;不继承就会落在一次性空目录里", + }, + { + name: "地址里没写 path,收件方是人 → 保持空", + addrPath: "", sessionWS: ws, toIsHuman: true, + want: "", + why: "给人类填 path 会拼出 gui-lab@/root/projects/demo.别名 这种错地址", + }, + { + name: "地址里写了 path → 照用,不用会话 workspace", + addrPath: "/other/place", sessionWS: ws, toIsHuman: false, + want: "/other/place", + why: "地址里的 path 是发件人的明确意图,兜底不该覆盖它", + }, + { + name: "地址里写了 path,即使收件方是人 → 照用", + addrPath: "/other/place", sessionWS: ws, toIsHuman: true, + want: "/other/place", + why: "人自己写了 path 就按他写的来(与上一条同一个原则)", + }, + { + name: "会话确实没有 workspace,收件方是 Agent → 仍是空", + addrPath: "", sessionWS: "", toIsHuman: false, + want: "", + why: "空串的语义是「不知道」,不能凭空编一个目录出来", + }, + { + name: "会话没有 workspace,收件方是人 → 空", + addrPath: "", sessionWS: "", toIsHuman: true, + want: "", + why: "两边都空,行为与改动前完全一致(无回归)", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveToWorkspace(c.addrPath, c.sessionWS, c.toIsHuman) + if got != c.want { + t.Errorf("resolveToWorkspace(%q, %q, human=%v) = %q,期望 %q —— %s", + c.addrPath, c.sessionWS, c.toIsHuman, got, c.want, c.why) + } + }) + } +} + +// 反向对照:确认「收件方是否人类」这个维度**真的在起作用**。 +// +// 如果哪天有人把 toIsHuman 用错(比如传反了、或当成常量传),上面的表里 +// 仍会有一半用例通过 —— 它们只在 want 恰好相同的情况下碰到。这里固定其他 +// 输入、只翻转这一个维度,要求结果必须不同: +// +// 不翻转就通过,说明这个参数被忽略了(恒为默认、或被短路掉), +// 那时「给人也填 path」的错地址会静默回归。 +func TestResolveToWorkspace_HumanDimensionActuallyMatters(t *testing.T) { + const ws = "/root/projects/demo" + agent := resolveToWorkspace("", ws, false) + human := resolveToWorkspace("", ws, true) + if agent == human { + t.Fatalf("收件方是人类与是 Agent 得到了同一个结果 %q —— "+ + "要么 toIsHuman 没起作用,要么规则退化成了一律填/一律不填", agent) + } + if human != "" { + t.Errorf("收件方是人类时 to_workspace 应为空,实得 %q", human) + } + if agent != ws { + t.Errorf("收件方是 Agent 时应继承会话 workspace %q,实得 %q", ws, agent) + } +} diff --git a/server/internal/notify/mail.go b/server/internal/notify/mail.go index bf1cbcd..18ac017 100644 --- a/server/internal/notify/mail.go +++ b/server/internal/notify/mail.go @@ -153,6 +153,12 @@ func Recipients(ctx context.Context, m Mail) { "session_alias": alias, // reply_address 是「把回信发回这条会话」的现成地址。 // 插件不必自己拼(拼错了就是静默开新会话)。 + // + // path 位刻意留空:它的语义是「**发件人**该在哪儿干活」,而人没有 + // 工作目录(算上它会拼出 `gui-lab@/path.别名` 这种错地址)。 + // 地址里没有 path 不会丢工作目录 —— 收信方回信时,发信端会用 + // `sessions.workspace` 把它补齐(见 handler/mail.go 的“收件方是 + // Agent 且地址里没写 path”那段),所以这里不必也不应该自己填。 "reply_address": models.FormatAddress(replyTo, "", alias), // self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。 "self_address": models.FormatAddress(forName, workspace, alias), diff --git a/server/internal/repo/session_workspace.go b/server/internal/repo/session_workspace.go new file mode 100644 index 0000000..9851cf0 --- /dev/null +++ b/server/internal/repo/session_workspace.go @@ -0,0 +1,35 @@ +package repo + +import ( + "context" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +// SessionWorkspaceOf 返回**这条会话**的工作目录(`sessions.workspace`)。 +// +// # 为什么需要它 +// +// 邮件的 `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口, +// 但它取的是**地址里的 path 位**。而 Agent 之间的回信、以及人在对话页点回复时, +// 地址里通常没有 path 位 —— 平台自己下发的 `reply_address` 就是这个形状。 +// +// 空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在 +// 一个不同的空目录里**;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理 +// 一封邮件就多出一条未分组会话」,而模型在空目录里什么项目文件也看不到。 +// +// 会话的 workspace 才是权威来源(见 `models.SessionWorkspace` 的注释): +// 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。 +// +// 读不到时返回空串而不是报错:投递路径不能因为一次查询失败就丢掉工作目录信息, +// 但也不能凭空编一个 —— 空串的语义就是「不知道」,由调用方决定怎么退化。 +func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string { + var ws string + err := db.DB.QueryRowContext(ctx, + `SELECT COALESCE(workspace, '') FROM sessions WHERE session_id = $1`, id).Scan(&ws) + if err != nil { + return "" + } + return ws +} diff --git a/server/internal/repo/session_workspace_test.go b/server/internal/repo/session_workspace_test.go new file mode 100644 index 0000000..c60bb2f --- /dev/null +++ b/server/internal/repo/session_workspace_test.go @@ -0,0 +1,46 @@ +package repo + +import ( + "context" + "testing" +) + +// SessionWorkspaceOf 必须真的读得到 `sessions.workspace`。 +// +// 为什么值得单独立一条测试:这个查询是**列名对不上就静默返回空串**的形态 +// (读不到时按设计返回 "",不报错)。空串在这里的语义是「这条会话没有工作 +// 目录」,与「我查错列了」无法区分 —— 于是列名写错的表现是:功能看起来还在 +// 跑,只是工作目录永远继承不到,Agent 继续落在一次性空目录里。 +// +// 本轮演练里我刚因为猜错表名(`mail_attachments` 不存在)而崩过一次, +// 这类错误只能靠对着真实 schema 跑一次来发现。 +func TestSessionWorkspaceOf(t *testing.T) { + ctx := context.Background() + setupTestDB(t) + + sid, err := CreateSession(ctx, nil, "pi", "工作目录继承", "/root/projects/demo") + if err != nil { + t.Fatalf("建会话: %v", err) + } + + got := SessionWorkspaceOf(ctx, sid) + if got != "/root/projects/demo" { + t.Fatalf("SessionWorkspaceOf = %q,期望 /root/projects/demo —— "+ + "空串说明列名或表名对不上(本函数读不到时静默返回空串)", got) + } + + // 反向对照:另一条没有 workspace 的会话必须返回空,而不是串上一条的值。 + empty, err := CreateSession(ctx, nil, "pi", "无工作目录", "") + if err != nil { + t.Fatalf("建第二条会话: %v", err) + } + if got := SessionWorkspaceOf(ctx, empty); got != "" { + t.Errorf("没有 workspace 的会话应返回空串,实得 %q —— "+ + "这可能是查询漏了 WHERE session_id 条件", got) + } + + // 不存在的会话:按设计返回空串而不是 panic/报错(投递路径不能因它中断)。 + if got := SessionWorkspaceOf(ctx, sid); got == "" { + t.Errorf("已存在的会话不应返回空串") + } +}