同一个形状的缺陷在两个桥上,**根因都是架构差异,不是"忘了写"**:
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。
88 lines
3.3 KiB
Go
88 lines
3.3 KiB
Go
package main
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
/*
|
||
read_thread 的两条路径都必须真的落在网关路由上。
|
||
|
||
# 缺陷(dsh 2026-09-29 报,已在本机复现)
|
||
|
||
`tools.go` 里曾写死 `scopeQuery("&")`。模型默认**不传 offset** ⇒ offset 为空串
|
||
⇒ 拼出 `/agent/mail/{id}/thread&session_id=…`。`&` 不是 `?`,所以它被当成
|
||
**路径的一部分**:网关路由 `/agent/mail/{id}/thread`({id} 只吃一个路径段)
|
||
匹配不上 ⇒ **404**。生产日志里同一封邮件相隔 19 秒两条请求,
|
||
只带 session_id 的那条 200、带 workspace 的那条 404 —— 后者就是本桥。
|
||
|
||
# 为什么原来的守卫测试没拦住
|
||
|
||
`plugin_read_scope_test.go` 断言的是「URL 里**有没有** session_id=」——
|
||
缺陷 URL 里**有**,所以绿着。判据必须落在**路径**上,而不是查询串的存在性。
|
||
|
||
# 陷阱:不能只把 & 改成 ?
|
||
|
||
`offset>0` 那条的 offset 自带 `?`,所以它原本是**对的**。把 `"&"` 一刀切成 `"?"`
|
||
反而会把这条改坏。两条路径必须都验 —— 所以这个测试跑两组。
|
||
*/
|
||
func TestReadThreadBothPathsRoutable(t *testing.T) {
|
||
var seen []string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
seen = append(seen, r.URL.RequestURI())
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
const sid = "593988da-0000-0000-0000-0000000000aa"
|
||
const wantPath = "/api/v1/agent/mail/m-1/thread"
|
||
|
||
cases := []struct {
|
||
name string
|
||
args map[string]interface{}
|
||
wantPath string
|
||
// wantQ 只钉**这条判据要管的参数**(路径拼接 / offset 传递)。
|
||
// ★ 2026-10-01:原断言用的是整串相等,而补上 cwd 回落之后
|
||
// scopeQuery 会多带一个 `workspace=` 参数 ⇒ 整串比较必然不等。
|
||
// 要防的东西没变(路径必须逐字匹配路由、offset 必须传下去),
|
||
// 所以改成「wantQ 的每个片段都必须在实际查询串里」。
|
||
wantQ []string
|
||
}{
|
||
{"offset 省略", map[string]interface{}{"mail_id": "m-1"},
|
||
wantPath, []string{"session_id=" + sid}},
|
||
{"offset>0", map[string]interface{}{"mail_id": "m-1", "offset": float64(5)},
|
||
wantPath, []string{"offset=5", "session_id=" + sid}},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
p := &Plugin{gwURL: srv.URL, client: srv.Client(), currentSessionID: sid}
|
||
seen = nil
|
||
if _, err := p.handleReadThread(c.args); err != nil {
|
||
t.Fatalf("%s:调用失败 %v", c.name, err)
|
||
}
|
||
if len(seen) != 1 {
|
||
t.Fatalf("%s:应当只请求一次(实际 %d 次:%v)", c.name, len(seen), seen)
|
||
}
|
||
|
||
u, err := url.Parse(seen[0])
|
||
if err != nil {
|
||
t.Fatalf("%s:拼出的 URL 不合法 %q:%v", c.name, seen[0], err)
|
||
}
|
||
// ★ 关键判据:路径必须与网关路由逐字相等。缺陷版本这里是
|
||
// "/api/v1/agent/mail/m-1/thread&session_id=…" ⇒ 一定不相等。
|
||
if u.Path != c.wantPath {
|
||
t.Fatalf("★ %s:路径匹配不上网关路由\n 实际: %s\n 期望: %s\n (完整请求 %s)",
|
||
c.name, u.Path, c.wantPath, seen[0])
|
||
}
|
||
for _, frag := range c.wantQ {
|
||
if !strings.Contains(u.RawQuery, frag) {
|
||
t.Fatalf("★ %s:查询串缺 %q\n 实际: %s", c.name, frag, u.RawQuery)
|
||
}
|
||
}
|
||
}
|
||
}
|