Files
MailUI4Agents/plugins/homeagent-mail-bridge/plugin_read_thread_path_test.go
JianFeeeee f86f08c7dc 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。
2026-10-01 19:41:16 +08:00

88 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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