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(残留会串到下一轮)") } }