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