Files
MailUI4Agents/plugins/homeagent-mail-bridge/inbox_workspace_test.go
JianFeeeee f86f08c7dc fix(桥): read_inbox 在非邮件驱动会话上也得带 workspace —— homeagent 与 opencode 两处
同一个形状的缺陷在两个桥上,**根因都是架构差异,不是"忘了写"**:
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。
2026-10-01 19:41:16 +08:00

113 lines
4.4 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 main
import (
"net/url"
"strings"
"testing"
)
// ★ 收件箱按工作区收窄(2026-09-26)
//
// 我今天先部署了服务端(缺 workspace 直接 400),只修了 pi/opencode/dsh 三个桥,
// 漏了这里 —— 线上随即出现:
//
// 07:42:28 tool read_inbox result: 工具 read_inbox 执行失败:
// proc: homeagent-mail-bridge.tool.invoke: HTTP 400:
// {"error":"缺少 workspace:收件箱按工作区收窄..."}
//
// 这组判据钉住"读类端点必须带 workspace",以及补投必须逐工作区。
func TestInboxURLCarriesWorkspace(t *testing.T) {
p := &Plugin{gwURL: "http://gw", currentSessionID: "s1", currentWorkspace: "/home/program/agentmail"}
u := p.inboxURL("unread", 5)
if !strings.Contains(u, "workspace=%2Fhome%2Fprogram%2Fagentmail") {
t.Fatalf("inboxURL 必须带 workspace,实际: %s", u)
}
// 两维都要在:session 防"A 会话标掉 B 会话",workspace 防跨工作区越界
if !strings.Contains(u, "session_id=s1") {
t.Fatalf("inboxURL 必须带 session_id,实际: %s", u)
}
// 构造出的 URL 必须可解析(不是靠字符串拼凑蒙对的)
if _, err := url.Parse(u); err != nil {
t.Fatalf("URL 不可解析: %v", err)
}
}
// 拿不到工作区时**不带** —— 服务端会 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)
// 现在必带(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)
}
}
func TestScopeQueryCarriesWorkspace(t *testing.T) {
p := &Plugin{currentSessionID: "s1", currentWorkspace: "/w"}
q := p.scopeQuery("&")
if !strings.Contains(q, "workspace=%2Fw") {
t.Fatalf("scopeQuery 必须带 workspace,实际: %q", q)
}
}
// ★ 补投不再读一次全局收件箱
func TestCatchUpIsPerWorkspace(t *testing.T) {
src := readSource(t, "plugin.go")
// catchUp 必须有 workspaces 参数并逐个分发
if !strings.Contains(src, "func (p *Plugin) catchUp(pending int, workspaces []string)") {
t.Fatal("catchUp 必须接 workspaces 清单(心跳给的 pending_workspaces)")
}
if !strings.Contains(src, "p.catchUpWorkspace(ws, limit, pending)") {
t.Fatal("catchUp 必须逐工作区分发")
}
// 真正的拉取必须带 workspace 参数
if !strings.Contains(src, "status=unread&limit=%d&workspace=%s") {
t.Fatal("补投的收件箱请求必须带 workspace")
}
// 反向对照:旧的全局读法不能残留
if strings.Contains(src, `"/api/v1/mail/inbox?status=unread&limit=%d"`) {
t.Fatal("仍残留不带 workspace 的全局收件箱读法")
}
// 拿不到清单就不补投(带空 workspace 去拉必然 400)
if !strings.Contains(src, "len(workspaces) == 0") {
t.Fatal("没有 pending_workspaces 时必须跳过补投")
}
}
// 两处投递路径都要在回合期间设上工作区 —— 模型那轮会调 read_inbox。
func TestDeliverySetsWorkspaceDuringTurn(t *testing.T) {
src := readSource(t, "plugin.go")
if strings.Count(src, "p.currentWorkspace = evt.Workspace") < 1 {
t.Fatal("SSE 投递路径必须设 currentWorkspace(信封的 to_workspace)")
}
if strings.Count(src, "p.currentWorkspace = workspace") < 1 {
t.Fatal("补投路径必须设 currentWorkspace")
}
// 两处都要清空 —— 残留会让下一轮读到上一封的工作区
if strings.Count(src, `p.currentWorkspace = ""`) < 2 {
t.Fatal("两处都必须清空 currentWorkspace(残留会串到下一轮)")
}
}