Files
MailUI4Agents/plugins/homeagent-mail-bridge/plugin_read_thread_path_test.go
dsh 60012ede3d fix(homeagent桥): ★ read_thread 不传 offset 时 URL 拼成 /thread&session_id ⇒ 100% 404
症状:模型默认不传 offset ⇒ offset="" ⇒ scopeQuery("&") 拼出
  /agent/mail/{id}/thread&session_id=…
`&` 被当成路径的一部分,网关路由 /agent/mail/{id}/thread 匹配不上 ⇒ 404。

根因:2a5e3d7(09-14「四家桥的读端点也带上会话收窄」)只给 read_thread 拼错了
分隔符;其余四家桥走 `path.includes('?') ? '&' : '?'`,没踩到。

★ 网关日志里的单字符对照实验(09-30 10:14:47,同一 mail_id、同一 session_id 值、
相隔 0 秒的两条请求,只差分隔符):
  /thread?session_id=x  -> 401  86B  ← 已路由进 handler,只是鉴权没过
  /thread&session_id=x  -> 404  19B  ← 从未匹配到路由
同一 session_id、同样缺 workspace,唯一变量是 ? / &。09-29 的三次 404
(20:21:12 / 20:58:14 / 21:44:42)URL 形态一致,且**不伴随** [agent-scope]
「没声明 session_id」告警 ⇒ session_id 确实带上了,问题在分隔符。

为什么「同一二进制 09-29 全 404、09-30 全成功」不是矛盾:该缺陷只在
currentSessionID != ""(即正在处理某轮邮件)时触发。09-30 那三次读发生在回合外,
scopeQuery 返回空串、不追加分隔符 ⇒ 路径正确 ⇒ 200(网关日志有 [agent-scope]
「旧语义放行」告警为证)。所以它恰好只在**邮件回合内**发作 —— 即需要读线索
才能回信的那条路径。

修法:sep := "?" ; if offset != "" { sep = "&" }。
不能只把 "&" 改成 "?" —— offset 自带 "?offset=%d",一刀切会把分页那条从对的改错。
两条路径都在 plugin_read_thread_path_test.go 里逐字断言路径 == 网关路由。

自证边界(改完仍无法自证的部分):单测证明的是**拼出的 URL 落在网关路由上**,
不等于已在 homeagent 部署的那份 plugin.bin 上端到端复现过。原守卫测试
(plugin_read_scope_test.go)断言的是「URL 里有没有 session_id=」,缺陷 URL 里
恰恰有 ⇒ 绿着放行;新测试把判据落在 u.Path 上。
2026-09-30 10:40:44 +08:00

80 lines
2.8 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"
"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 string
}{
{"offset 省略", map[string]interface{}{"mail_id": "m-1"},
wantPath, "session_id=" + sid},
{"offset>0", map[string]interface{}{"mail_id": "m-1", "offset": float64(5)},
wantPath, "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])
}
if u.RawQuery != c.wantQ {
t.Fatalf("★ %s:查询串不对\n 实际: %s\n 期望: %s", c.name, u.RawQuery, c.wantQ)
}
}
}