同一个形状的缺陷在两个桥上,**根因都是架构差异,不是"忘了写"**:
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。
113 lines
4.4 KiB
Go
113 lines
4.4 KiB
Go
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(残留会串到下一轮)")
|
||
}
|
||
}
|