同一个形状的缺陷在两个桥上,**根因都是架构差异,不是"忘了写"**:
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。
91 lines
3.5 KiB
Go
91 lines
3.5 KiB
Go
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")
|
||
}
|
||
} |