Files
MailUI4Agents/plugins/homeagent-mail-bridge/plugin_read_scope_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

116 lines
4.7 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"
)
/*
读类端点必须带上**自己那条邮件会话** —— 这次多出来的作用不是"少看几封",而是
**工作区隔离**。
# 缺陷(用户报的)
「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了,还需要我亲自去解释。」
根因:隔离单位是 Agent,而一个 Agent 同时服务所有工作区(注册时 workspaces 为空、
cwd 由每封邮件的 to_workspace 决定)。服务端原先的判据只是「这个 Agent 参与过这条
会话」—— 于是同一个 agent `pi` 在 TrueAgent 里干活的 worker 眼里,对 agentmail 的
会话也成立。服务端现在补上了第二道闸门(两条会话的 workspace 相同),而闸门的输入
就是这里要拼的 `session_id`。
# 判据是行为,不是正则
起一个 httptest 服务当网关,把**每个读工具真调一遍**,断言它请求的 URL 里带了
`session_id`。对照侧:不在回合里(currentSessionID 为空)时**不许**带 ——
否则"永远拼一个空 session_id"也能骗过上半段。
*/
func TestReadToolsCarrySessionScope(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"
cases := []struct {
tool string
args map[string]interface{}
call func(*Plugin, map[string]interface{}) (interface{}, error)
}{
{"read_mail", map[string]interface{}{"mail_id": "m-1"}, (*Plugin).handleReadMail},
{"read_thread", map[string]interface{}{"mail_id": "m-1"}, (*Plugin).handleReadThread},
{"suggest_address", map[string]interface{}{"name": "dsh", "path": "/home/program/x"}, (*Plugin).handleSuggestAddress},
{"list_contacts", map[string]interface{}{}, (*Plugin).handleListContacts},
{"session_participants", map[string]interface{}{"session_id": "s-1"}, (*Plugin).handleSessionParticipants},
// 转发也读原文(把原文引出到别的会话)⇒ 与 read_mail 同一条命。
{"forward", map[string]interface{}{"mail_id": "m-1", "to": "x@/home/program/y"}, (*Plugin).handleForwardMail},
}
for _, c := range cases {
// ── 回合中:必须带上当前会话 ──
p := &Plugin{gwURL: srv.URL, client: srv.Client(), currentSessionID: sid}
seen = nil
if _, err := c.call(p, c.args); err != nil {
t.Fatalf("%s:调用失败 %v", c.tool, err)
}
if len(seen) != 1 {
t.Fatalf("%s:应当只请求一次(实际 %d 次:%v)", c.tool, len(seen), seen)
}
if !strings.Contains(seen[0], "session_id="+url.QueryEscape(sid)) {
t.Fatalf("★ %s 的请求没带会话收窄:%s", c.tool, seen[0])
}
// ── 不在回合里(桥启动自检那种):不许拼出空的 session_id ──
p2 := &Plugin{gwURL: srv.URL, client: srv.Client()}
seen = nil
if _, err := c.call(p2, c.args); err != nil {
t.Fatalf("%s(无会话):调用失败 %v", c.tool, err)
}
if len(seen) == 1 && strings.Contains(seen[0], "session_id=") {
t.Fatalf("%s:拿不到会话时不该拼 session_id:%s", c.tool, seen[0])
}
}
}
// scopeQuery 的转义与分隔符:会话 id 里出现 `&` 时不能把后面的参数吃掉。
func TestScopeQueryEscapesAndSeparates(t *testing.T) {
p := &Plugin{}
if got := p.scopeQuery("?"); got != "" {
t.Fatalf("不在回合里应当返回空串(实际 %q)", got)
}
p.currentSessionID = "a b&c"
q := p.scopeQuery("?")
if !strings.HasPrefix(q, "?session_id=") {
t.Fatalf("分隔符要是调用方给的那个:%q", q)
}
// ★ 2026-10-01:原断言是「`q[1:]` 里不能有 `&`」,现改为**只查 session_id 那一段**。
//
// 它写于「scopeQuery 只产出一个参数」的时代。补了 cwd 回落之后,
// scopeQuery 会产出**两个**参数(session_id + workspace),
// 而 `&workspace=` 里那个 `&` 是**合法分隔符** —— 断言把正常输出当成了未转义。
//
// 要防的真问题没变:**参数值里的 `&` 不能裸奔**(否则会把后面的参数吃掉)。
// 所以这里改成只看 session_id 的值那一段。
seg := strings.TrimPrefix(q, "?session_id=")
if i := strings.Index(seg, "&"); i >= 0 {
seg = seg[:i] // 截到下一个参数分隔符为止
}
if strings.ContainsAny(seg, "& ") {
t.Fatalf("session_id 的值未转义(会把后面的参数吃掉):%q", seg)
}
if _, err := url.Parse("http://x/y" + q); err != nil {
t.Fatalf("拼出的 URL 不合法:%v", err)
}
if got := p.scopeQuery("&"); !strings.HasPrefix(got, "&session_id=") {
t.Fatalf("已有查询串时要用 & 分隔:%q", got)
}
}