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。
This commit is contained in:
2026-10-01 19:41:16 +08:00
parent 4d8165fde9
commit f86f08c7dc
7 changed files with 353 additions and 16 deletions

View File

@ -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)
}