From f86f08c7dc401e249e862ec24d599d5b47a3646d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 1 Oct 2026 19:41:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=A1=A5):=20read=5Finbox=20=E5=9C=A8?= =?UTF-8?q?=E9=9D=9E=E9=82=AE=E4=BB=B6=E9=A9=B1=E5=8A=A8=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E4=B8=8A=E4=B9=9F=E5=BE=97=E5=B8=A6=20workspace=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20homeagent=20=E4=B8=8E=20opencode=20=E4=B8=A4?= =?UTF-8?q?=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一个形状的缺陷在两个桥上,**根因都是架构差异,不是"忘了写"**: pi/opencode/dsh 的工具**只在邮件驱动回合里装配** ⇒ workspace 永远有值; homeagent 的 15 个工具是 `registerTool` **全局注册**(webui/a2a/对话都能调), opencode 是 `export default` 全局插件 ⇒ 非邮件驱动会话上查不到工作区 ⇒ 拼不出 `&workspace=` ⇒ 服务端 400。 实测:homeagent 2026-10-01 14:32:26 一次真实失败,近 24h **3 失败 / 0 成功**。 opencode 那次是**静默**失败(工具返回错误文本,模型照走)⇒ 线上 0 次报错 不代表没问题,是靠两桥的架构差异推出来的,不是靠日志。 修法不同(各自的可用信号不同): - homeagent: `effectiveWorkspace()` = 信封 → **回落到进程 cwd**。 cwd 是对的默认值:homed 按调用上下文以子进程拉起插件,实测对话中那个桥 cwd=/home/program/agentmail,而从插件目录拉起的那个是 plugins/...。 ⚠ 只是默认不是保证(库里 17 个工作区),收窄语义不变。 - opencode: handler 拿得到 `context.sessionID`,而 opencode `Session` **带 directory**(types.gen.d.ts 的 `export type Session` 可见), 且 `client.session.get` 本桥已在用 ⇒ 现场问权威值,不猜也不另存一份。 readInboxTool 是模块级常量,故新增 hostClient 在 init 时捕获。 部署:homeagent 首次走正规 hmap 路径(解包 + cp manifest + install -m 0755, skill §5 四步全绿)。判据:homeagent 新增 4 格 + 修正 3 条失效的既有断言 (补 cwd 回落使其旧前提失效:整串相等 vs 分片包含、"q[1:] 不能有 &" vs 合法分隔符、 "没工作区就不带"vs"带的是不是真值"——判据失败时先判断是判据错了还是行为错了)。 opencode 新增 5 格。全量:server 全绿 + race 干净;opencode 桥 359/359。 端到端:homeagent webui 一轮 `tool read_inbox result: {"content":[{"text":"收件箱为空。" (修复前是 400);opencode 两条非邮件驱动会话均 status: completed, "空"是正确的收窄结果(74 封全是 read/archived,unread=0),近 10 分钟 0 次 400。 --- .../inbox_workspace_test.go | 20 +++- plugins/homeagent-mail-bridge/plugin.go | 50 +++++++++- .../plugin_read_scope_test.go | 16 +++- .../plugin_read_thread_path_test.go | 18 +++- .../read_inbox_workspace_test.go | 91 +++++++++++++++++++ plugins/opencode-mail-bridge/index.js | 88 +++++++++++++++++- .../test/workspace-probe.test.mjs | 86 ++++++++++++++++++ 7 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 plugins/homeagent-mail-bridge/read_inbox_workspace_test.go create mode 100644 plugins/opencode-mail-bridge/test/workspace-probe.test.mjs diff --git a/plugins/homeagent-mail-bridge/inbox_workspace_test.go b/plugins/homeagent-mail-bridge/inbox_workspace_test.go index 6fa0a16..620be15 100644 --- a/plugins/homeagent-mail-bridge/inbox_workspace_test.go +++ b/plugins/homeagent-mail-bridge/inbox_workspace_test.go @@ -36,11 +36,27 @@ func TestInboxURLCarriesWorkspace(t *testing.T) { // 拿不到工作区时**不带** —— 服务端会 400,那是刻意的: // 错误可见,好过静默跨工作区拿到别处的信。 +// +// ★ 2026-10-01:本条断言**已失效**,改钉真正要保的东西。 +// +// 原断言:`strings.Contains(u, "workspace=")` ⇒ 失败。 +// 它写于「currentWorkspace 为空就拿不到 workspace」那个时代。 +// 而 2026-10-01 补了 `effectiveWorkspace()` 的 cwd 回落 ⇒ 拿得到 ⇒ 带上了。 +// +// 值得注意的是:**这条测试当年是对的**。它防的是「不知道工作区却假装知道」 +// (旧语义 = 跨工作区误读,正是 2026-09-26 那个缺陷本身)。 +// 现在的形状变了:要防的不再是「有没有带」,而是「**带的是不是真值**」。 func TestInboxURLOmitsWorkspaceWhenUnknown(t *testing.T) { p := &Plugin{gwURL: "http://gw", currentSessionID: "s1"} u := p.inboxURL("unread", 5) - if strings.Contains(u, "workspace=") { - t.Fatalf("没有工作区时不该带 workspace 参数: %s", u) + + // 现在必带(cwd 回落),但必须是**真值**而不是空串/占位 + if !strings.Contains(u, "workspace=") { + t.Fatalf("★ cwd 回落修好后必须带 workspace:%s", u) + } + // 尾部不能是 `workspace=`(空值)—— 那种拼法会被服务端当成不存在的工作区 + if strings.Contains(u, "workspace=&") || strings.HasSuffix(u, "&workspace=") { + t.Fatalf("workspace 不能是空值:%s", u) } } diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 875ee32..f0b8bf5 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -121,6 +121,25 @@ type Plugin struct { // 我今天先部署了服务端、只修了 pi/opencode/dsh 三个桥,漏了这里 —— // 线上随即出现 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`(07:42 起)。 // 这就是「服务端先改、四个桥后改」的那半天窗口。 + // + // ★★ 2026-10-01:**光有它不够**(本字段的第二次补洞)。 + // + // 现象:homeagent 的工具是**全局注册**的(webui / a2a / 对话都能调), + // 而本字段只在 InjectInputSync 期间有值 —— 于是**从对话里调 read_inbox + // 必然 400**。实测 2026-10-01 14:32:26 一次真实失败;近 24h **3 次失败、0 次成功**。 + // + // 为什么另外三桥没这个问题:**它们的工具只在邮件驱动回合里被装配** + // (pi 的 `mailTools` 在 `loadSession` 之前装,opencode/dsh 同构), + // 所以 `getWorkspace()` 永远有值。**架构差异,不是某个桥忘了写。** + // + // 修法:`effectiveWorkspace()` —— 本字段为空时回落到**桥进程的 cwd**。 + // 那个 cwd 就是**调用方的工作目录**(homed 以子进程方式按调用上下文拉起它), + // 实测:对话里那个桥进程 cwd=/home/program/agentmail, + // 而从插件目录拉起的那个 cwd=/home/newqqagent/plugins/homeagent-mail-bridge。 + // + // ⚠ 它是**默认**而不是**保证**:库里 17 个不同工作区,而进程 cwd 只反映 + // 当前这一个调用方。收窄语义不变(仍只列这个工作区的信), + // 拿不准时宁可少列 —— 跨工作区误读正是 2026-09-26 那个缺陷本身。 currentWorkspace string // 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。 @@ -1544,12 +1563,33 @@ func (p *Plugin) scopeQuery(sep string) string { return "" } q := sep + "session_id=" + url.QueryEscape(p.currentSessionID) - if p.currentWorkspace != "" { - q += "&workspace=" + url.QueryEscape(p.currentWorkspace) + if ws := p.effectiveWorkspace(); ws != "" { + q += "&workspace=" + url.QueryEscape(ws) } return q } +// effectiveWorkspace 返回本轮该用的工作区:**先信封,再进程 cwd**。 +// +// 为什么要回落:homeagent 的工具全局可调,而 currentWorkspace 只在 +// InjectInputSync 期间有值 —— 只用它 ⇒ 对话里调 read_inbox 必 400 +// (实测 2026-10-01:3 次失败 / 0 次成功)。 +// +// 为什么 cwd 是**正确**的默认值而不是随便猜:homed 按调用上下文以子进程方式 +// 拉起插件,那个进程的 cwd 就是**调用方的工作目录**(实测见字段注释)。 +// +// ⚠ 拿不到时(cwd 不可读)返回空串,**不编造**:服务端会回 400, +// 而那正是设计要的「错误可见,好过静默跨工作区拿到别处的信」。 +func (p *Plugin) effectiveWorkspace() string { + if ws := strings.TrimSpace(p.currentWorkspace); ws != "" { + return ws + } + if wd, err := os.Getwd(); err == nil { + return strings.TrimSpace(wd) + } + return "" +} + // inboxURL 拼收件箱地址。单独抽出来是为了能被单测直接断言 —— // 会话收窄这种"少了个参数就静默丢信"的改动,必须有判据盯着 URL 本身。 // @@ -1562,9 +1602,9 @@ func (p *Plugin) inboxURL(status string, limit int) string { } // ★ workspace 同样要带(见 currentWorkspace 字段的说明)。缺了服务端直接 400 // —— 那是刻意的:旧语义(不带 = 全部工作区)正是用户报的那个越界缺陷。 - // 拿不到时不带,让服务端报 400:错误可见,好过静默跨工作区拿到别处的信。 - if p.currentWorkspace != "" { - scope += "&workspace=" + url.QueryEscape(p.currentWorkspace) + // 取不到时仍不带,让服务端报 400:错误可见,好过静默跨工作区拿到别处的信。 + if ws := p.effectiveWorkspace(); ws != "" { + scope += "&workspace=" + url.QueryEscape(ws) } return fmt.Sprintf("%s/api/v1/mail/inbox?status=%s&limit=%d%s", p.gwURL, status, limit, scope) } diff --git a/plugins/homeagent-mail-bridge/plugin_read_scope_test.go b/plugins/homeagent-mail-bridge/plugin_read_scope_test.go index 1385a26..e31e247 100644 --- a/plugins/homeagent-mail-bridge/plugin_read_scope_test.go +++ b/plugins/homeagent-mail-bridge/plugin_read_scope_test.go @@ -91,8 +91,20 @@ func TestScopeQueryEscapesAndSeparates(t *testing.T) { if !strings.HasPrefix(q, "?session_id=") { t.Fatalf("分隔符要是调用方给的那个:%q", q) } - if strings.Contains(q[1:], "&") { - t.Fatalf("session_id 未转义(会把后面的参数吃掉):%q", q) + // ★ 2026-10-01:原断言是「`q[1:]` 里不能有 `&`」,现改为**只查 session_id 那一段**。 + // + // 它写于「scopeQuery 只产出一个参数」的时代。补了 cwd 回落之后, + // scopeQuery 会产出**两个**参数(session_id + workspace), + // 而 `&workspace=` 里那个 `&` 是**合法分隔符** —— 断言把正常输出当成了未转义。 + // + // 要防的真问题没变:**参数值里的 `&` 不能裸奔**(否则会把后面的参数吃掉)。 + // 所以这里改成只看 session_id 的值那一段。 + seg := strings.TrimPrefix(q, "?session_id=") + if i := strings.Index(seg, "&"); i >= 0 { + seg = seg[:i] // 截到下一个参数分隔符为止 + } + if strings.ContainsAny(seg, "& ") { + t.Fatalf("session_id 的值未转义(会把后面的参数吃掉):%q", seg) } if _, err := url.Parse("http://x/y" + q); err != nil { t.Fatalf("拼出的 URL 不合法:%v", err) diff --git a/plugins/homeagent-mail-bridge/plugin_read_thread_path_test.go b/plugins/homeagent-mail-bridge/plugin_read_thread_path_test.go index 1e265ea..f4354bf 100644 --- a/plugins/homeagent-mail-bridge/plugin_read_thread_path_test.go +++ b/plugins/homeagent-mail-bridge/plugin_read_thread_path_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" ) @@ -44,12 +45,17 @@ func TestReadThreadBothPathsRoutable(t *testing.T) { name string args map[string]interface{} wantPath string - wantQ string + // wantQ 只钉**这条判据要管的参数**(路径拼接 / offset 传递)。 + // ★ 2026-10-01:原断言用的是整串相等,而补上 cwd 回落之后 + // scopeQuery 会多带一个 `workspace=` 参数 ⇒ 整串比较必然不等。 + // 要防的东西没变(路径必须逐字匹配路由、offset 必须传下去), + // 所以改成「wantQ 的每个片段都必须在实际查询串里」。 + wantQ []string }{ {"offset 省略", map[string]interface{}{"mail_id": "m-1"}, - wantPath, "session_id=" + sid}, + wantPath, []string{"session_id=" + sid}}, {"offset>0", map[string]interface{}{"mail_id": "m-1", "offset": float64(5)}, - wantPath, "offset=5&session_id=" + sid}, + wantPath, []string{"offset=5", "session_id=" + sid}}, } for _, c := range cases { @@ -72,8 +78,10 @@ func TestReadThreadBothPathsRoutable(t *testing.T) { t.Fatalf("★ %s:路径匹配不上网关路由\n 实际: %s\n 期望: %s\n (完整请求 %s)", c.name, u.Path, c.wantPath, seen[0]) } - if u.RawQuery != c.wantQ { - t.Fatalf("★ %s:查询串不对\n 实际: %s\n 期望: %s", c.name, u.RawQuery, c.wantQ) + for _, frag := range c.wantQ { + if !strings.Contains(u.RawQuery, frag) { + t.Fatalf("★ %s:查询串缺 %q\n 实际: %s", c.name, frag, u.RawQuery) + } } } } diff --git a/plugins/homeagent-mail-bridge/read_inbox_workspace_test.go b/plugins/homeagent-mail-bridge/read_inbox_workspace_test.go new file mode 100644 index 0000000..6ca756d --- /dev/null +++ b/plugins/homeagent-mail-bridge/read_inbox_workspace_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ★ read_inbox 在**非邮件轮次**里必须仍能带上 workspace(2026-10-01) +// +// ## 缺陷 +// +// homeagent 的工具是**全局注册**的(webui / a2a / 对话都能调),而 +// `currentWorkspace` 只在 `InjectInputSync` 期间有值 ⇒ 从对话里调 +// `read_inbox` 拼不出 `&workspace=` ⇒ 服务端 400。 +// +// 实测(2026-10-01 14:32:26):近 24h **3 次失败、0 次成功**。 +// 另三桥没这个问题,因为它们的工具**只在邮件驱动回合里被装配** +// —— 那是架构差异,不是「某个桥忘了写」。 +// +// ## 为什么回落到进程 cwd 是对的取值 +// +// homed 按调用上下文以子进程方式拉起插件,那个进程的 cwd 就是 +// **调用方的工作目录**(实测:对话里那个桥 cwd=/home/program/agentmail)。 +// +// ⚠ 它是**默认**不是**保证**:库里 17 个工作区,cwd 只反映当前这一个调用方。 +// 收窄语义不变(仍只列这个工作区的信)—— 跨工作区误读正是 +// 2026-09-26 那个缺陷本身,拿不准时宁可少列。 + +func TestEffectiveWorkspaceFallsBackToProcessCwd(t *testing.T) { + p := &Plugin{} + + // 场景 A:邮件轮次内 —— currentWorkspace 有值,**必须优先用它** + p.currentWorkspace = "/home/program/agentmail" + if got := p.effectiveWorkspace(); got != "/home/program/agentmail" { + t.Fatalf("轮次内必须用信封上的 path 位,实际 %q", got) + } + + // 场景 B:对话里(currentWorkspace 为空)—— 回落到 cwd + p.currentWorkspace = "" + got := p.effectiveWorkspace() + if got == "" { + t.Fatal("★ 对话里必须有 workspace 可用,否则 read_inbox 必然 400") + } + cwd, _ := os.Getwd() + if got != cwd { + t.Fatalf("回落值应等于进程 cwd:得到 %q,cwd=%q", got, cwd) + } + if !filepath.IsAbs(got) { + t.Fatalf("回落值必须是绝对路径(服务端要求),实际 %q", got) + } +} + +func TestEffectiveWorkspaceTrimsBlanks(t *testing.T) { + // 空白串不是有效取值 —— 当作没有,回落到 cwd。 + // 否则会拼出 `workspace=%20%20`,服务端拿到一个不存在的目录。 + p := &Plugin{currentWorkspace: " "} + got := p.effectiveWorkspace() + cwd, _ := os.Getwd() + if strings.TrimSpace(got) == "" { + t.Fatal("空白 currentWorkspace 不得被当作有效取值") + } + if got != cwd { + t.Fatalf("空白应回落到 cwd,得到 %q", got) + } +} + +func TestInboxURLAlwaysCarriesWorkspace(t *testing.T) { + // 这条是**端到端形状**:拼出来的 URL 必须带 workspace。 + // 上面两格验的是取值,这一格验的是「真的拼进去了」—— + // 2026-09-26 那次就是取值对了、拼接处漏了,两者是不同的形状。 + p := &Plugin{} + u := p.inboxURL("unread", 5) + if !strings.Contains(u, "workspace=") { + t.Fatalf("★ inboxURL 必须带 workspace(缺了服务端直接 400):%s", u) + } +} + +func TestScopeQuerySkippedOutsideTurnButInboxNever(t *testing.T) { + // scopeQuery 在非轮次时**整体**返回空串(那是刻意的:没有会话可收窄), + // 但 inboxURL 仍必须带 workspace —— 两者不是同一个开关。 + // 混起来就会出现「非轮次时 workspace 也一起没了」,即 2026-10-01 那个缺陷。 + p := &Plugin{} + if q := p.scopeQuery("&"); q != "" { + t.Fatalf("非轮次时 scopeQuery 应为空,实际 %q", q) + } + if !strings.Contains(p.inboxURL("unread", 5), "workspace=") { + t.Fatal("★ 非轮次时 inboxURL 仍必须带 workspace") + } +} \ No newline at end of file diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 5745a7f..ee79629 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -122,6 +122,59 @@ function authHeaders() { // ─── HTTP ─── +/* +★ 2026-10-01:取出「这次调用所在的工作区」,信封优先、其次向 opencode 问。 + +# 为什么需要它 + +`sessionWorkspace` 只在**邮件驱动回合**建会话那一刻被填(见 resolveSessionForMail)。 +插件是全局注册的(`export default`),所以从 webui/界面里直接对话时调 `read_inbox`, +那次调用落在一条**不是邮件驱动**的会话上 ⇒ 查表为空 ⇒ 服务端 400。 + +# 为什么向 opencode 问是可靠的(而不是又一份暂存状态) + +`@opencode-ai/sdk` 的 `Session` 类型**带 directory 字段** +(node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts 里 `export type Session` +可见),而 `client.session.get({ path: { id } })` 是本桥**已在用**的调用 +(resolveSessionForMail 里校验被接管的会话就是它)。 +⇒ 这里问出来的是**权威值**,不是我们自己维护的第二份拷贝。 + +# 为什么要缓存(否则每个工具调用都多一次 HTTP) + +`client` 不是全局的(按插件实例持有),所以用一个模块级 Map 按 sessionID 缓存, +拿不到就不缓存(失败会重试,避免把一次抖动记成永久)。 + +⚠ 缓存只存**成功**拿到的值:宁可每次多问一次,也不要把错误记成事实。 +*/ +const workspaceProbeCache = new Map(); + +async function resolveWorkspace(context, client) { + const sessionID = String(context?.sessionID ?? ""); + if (!sessionID) return ""; + + const known = sessionWorkspace.get(sessionID); + if (known) return known; + + const cached = workspaceProbeCache.get(sessionID); + if (cached) return cached; + + if (!client?.session?.get) return ""; + try { + const got = await client.session.get({ path: { id: sessionID } }); + const dir = (got?.data ?? got)?.directory; + if (typeof dir === "string" && dir.trim()) { + workspaceProbeCache.set(sessionID, dir.trim()); + sessionWorkspace.set(sessionID, dir.trim()); + return dir.trim(); + } + } catch { + // 问不到就当没工作区:下面的调用会不带 workspace,服务端会报 400。 + // 那是刻意的(旧语义正是跨工作区误读那个缺陷本身), + // 比"猜一个目录然后读到别人的信"安全得多。 + } + return ""; +} + async function apiGet(path) { const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { headers: authHeaders() }); if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); @@ -274,14 +327,30 @@ const readInboxTool = { const filter = args.filter || DEFAULT_INBOX_STATUS; const limit = args.limit || DEFAULT_INBOX_LIMIT; const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || ""; - const workspace = sessionWorkspace.get(String(context?.sessionID ?? "")) || ""; /* ★ 两维收窄都要带: - · session_id —— 只列**这条线索**的信(防"A 会话标掉 B 会话的未读") + · session_id —— 只列**这条线索**的��(防"A 会话标掉 B 会话的未读") · workspace —— 只列**这个工作区**的信(防"在 mc 干活却读到 agentmail 的信") 服务端对缺 workspace 直接 400 —— 那是刻意的(旧语义正是缺陷本身)。 workspace 拿不到时**不带**,让服务端报 400:错误可见,好过静默跨工作区。 + + ★★ 2026-10-01:拿不到时的第二道取值(补上后者的同形状缺陷) + + `sessionWorkspace` 只在**邮件驱动回合**建会话那一刻被填(line 754)。 + 而插件是 `export default` 全局注册的 —— 从 webui/界面里直接对话时 + 调 `read_inbox`,那次调用落在一条**不是邮件驱动**的会话上 ⇒ 查表为空 + ⇒ workspace 空 ⇒ 服务端 400。 + + 与 homeagent 那次是**同一个形状**(那边实测 3 次失败 / 0 次成功), + 但成因不同:homeagent 的工具 handler 拿不到 exec 上下文、只能靠字段暂存; + 本桥的 handler **拿得到** `context.sessionID`,而 opencode 的 + `Session` 对象带 `directory` 字段(types.gen.d.ts 里已确认), + 所以可以现场问出来 —— 不必猜,也不必记。 + + 两次都失败是**静默**的(工具返回一句错误文本,模型照旧往下走), + 所以线上至今 0 次报错不代表没问题。 */ + const workspace = await resolveWorkspace(context, hostClient); const scope = (mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : "") + (workspace ? `&workspace=${encodeURIComponent(workspace)}` : ""); const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`); @@ -636,6 +705,19 @@ pi 的 worker 一个进程只服务一封邮件,信封就在手上(`data.to_ 的代码。三维地址 `name@path.session` 的 **path 位本来就该参与寻址**。 */ const sessionWorkspace = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 工作区绝对路径 + +/* +★ 2026-10-01:把插件实例的 `client` 存一份,供**模块级工具对象**用。 + +`readInboxTool` 是模块级常量(`tool: { read_inbox: readInboxTool }`), +拿不到 `mailBridge(input)` 的参数。而 `resolveWorkspace` 需要 `client.session.get` +去问权威的 `Session.directory`(见该函数注释)。 + +⇒ 在插件初始化时存下来。插件实例是**一个**(`export default` 被调用一次), +所以这份引用是稳定的;readInboxTool 是全局可调的,而它需要的不止是信件状态, +还有宿主句柄 —— 以前没这个需求,所以没有通道。 +*/ +let hostClient = null; const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏) // 权限询问的双向定位。 @@ -1173,6 +1255,8 @@ function settleFirstTurn(sessionID, outcome) { export default async function mailBridge(input) { const { client, directory } = input; + // 供模块级工具对象(read_inbox)问工作区用,见 hostClient 的注释。 + if (client) hostClient = client; // 注册 Agent。无密钥也无 secret 时先本地生成一把密钥, // 等管理员在后台登记后即可接入(无需重装插件)。 diff --git a/plugins/opencode-mail-bridge/test/workspace-probe.test.mjs b/plugins/opencode-mail-bridge/test/workspace-probe.test.mjs new file mode 100644 index 0000000..ee24ea6 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/workspace-probe.test.mjs @@ -0,0 +1,86 @@ +/** + * ★ 2026-10-01:read_inbox 在**非邮件驱动**的会话上也得能带出 workspace。 + * + * ## 缺陷 + * + * `sessionWorkspace` 只在**邮件驱动回合**建会话那一刻被填 + * (`resolveSessionForMail` 里 `sessionWorkspace.set(sessionID, wantDir)`)。 + * 而插件是 `export default` 全局注册的 —— 从 webui/界面里直接对话时调 + * `read_inbox`,那次调用落在一条**不是邮件驱动**的会话上 ⇒ 查表为空 + * ⇒ URL 拼不出 `&workspace=` ⇒ **服务端 400**。 + * + * 与 homeagent 那次是同一个形状(那边实测 3 次失败 / 0 次成功),但成因不同: + * homeagent 的 handler 拿不到 exec 上下文,只能靠字段暂存; + * 本桥的 handler 拿得到 `context.sessionID`,且 opencode 的 `Session` 带 + * `directory`(`types.gen.d.ts` 里 `export type Session` 可见)⇒ 可以现场问出来。 + * + * ⚠ 静默失败:工具返回一句错误文本,模型照旧往下走 + * ⇒ **线上至今 0 次报错不代表没问题**(这一点与 homeagent 那次不同: + * 那边至少有人工触发的失败日志)。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(HERE, '..', 'index.js'), 'utf8'); + +test('read_inbox 不再直接查 sessionWorkspace(那是非邮件驱动会话的空来源)', () => { + // 旧写法:查表为空 → workspace 空 → 400 + assert.ok( + !/const workspace = sessionWorkspace\.get\(String\(context\?\.sessionID/.test(src), + 'read_inbox 仍在直接查 sessionWorkspace —— 非邮件驱动会话上它必为空'); + assert.ok( + src.includes('await resolveWorkspace(context'), + 'read_inbox 应改走 resolveWorkspace()'); +}); + +test('resolveWorkspace 有信封之外的第二道取值', () => { + assert.ok(src.includes('async function resolveWorkspace('), + '缺少 resolveWorkspace()'); + // 第二道:向宿主问权威的 Session.directory + assert.ok(src.includes('client.session.get'), + 'resolveWorkspace 应向 opencode 问 Session.directory(本桥已在别处用过这个调用)'); + assert.ok(src.includes('.directory'), + '应读取 Session 的 directory 字段'); +}); + +test('★ 缓存只存成功值(失败的探测不得被记成事实)', () => { + // 缓存若把失败也记下来,一次抖动会变成永久的"这个会话没有工作区" + // ⇒ 而那正是本缺陷本身(静默地少带参数 → 400)。 + const m = src.match(/async function resolveWorkspace[\s\S]*?\n}/); + assert.ok(m, '未找到 resolveWorkspace 函数体'); + const body = m[0]; + assert.ok( + !/catch\s*\{[\s\S]{0,200}?\n\s*\}\s*\n?\s*workspaceProbeCache\.set/.test(body) + || /catch[\s\S]*?\n\s*return\s+""/.test(body), + 'catch 分支里不得写入缓存'); + // 有缓存写入的话,必须发生在成功判据之后 + const setIdx = body.indexOf('workspaceProbeCache.set'); + const dirIdx = body.indexOf('.directory'); + assert.ok(setIdx > dirIdx, + '缓存写入必须发生在确认 directory 之后(否则会把空值记成事实)'); +}); + +test('★ 拿不到时不猜目录(宁可让服务端 400)', () => { + const m = src.match(/async function resolveWorkspace[\s\S]*?\n}/); + const body = m[0]; + // 回落到某个"看起来像工作区"的默认值 = 跨工作区误读,正是 2026-09-26 那个缺陷 + assert.ok(!/__dirname|process\.cwd\(\)/.test(body), + '不得用 __dirname / process.cwd() 当工作区回落 —— 那会读到别处的信'); + assert.ok(body.includes('return "";'), + '拿不到时必须返回空串(让服务端报 400)'); +}); + +test('模块级工具对象拿得到宿主 client(否则第二道取值形同虚设)', () => { + assert.ok(src.includes('let hostClient = null;'), + '缺少 hostClient —— readInboxTool 是模块级常量,拿不到 mailBridge(input) 的参数'); + assert.ok( + /if \(client\) hostClient = client;/.test(src), + '插件初始化时必须把 client 存下来'); + assert.ok( + src.includes('await resolveWorkspace(context, hostClient)'), + 'read_inbox 应把 hostClient 传给 resolveWorkspace'); +}); \ No newline at end of file