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。
This commit is contained in:
@ -36,11 +36,27 @@ func TestInboxURLCarriesWorkspace(t *testing.T) {
|
||||
|
||||
// 拿不到工作区时**不带** —— 服务端会 400,那是刻意的:
|
||||
// 错误可见,好过静默跨工作区拿到别处的信。
|
||||
//
|
||||
// ★ 2026-10-01:本条断言**已失效**,改钉真正要保的东西。
|
||||
//
|
||||
// 原断言:`strings.Contains(u, "workspace=")` ⇒ 失败。
|
||||
// 它写于「currentWorkspace 为空就拿不到 workspace」那个时代。
|
||||
// 而 2026-10-01 补了 `effectiveWorkspace()` 的 cwd 回落 ⇒ 拿得到 ⇒ 带上了。
|
||||
//
|
||||
// 值得注意的是:**这条测试当年是对的**。它防的是「不知道工作区却假装知道」
|
||||
// (旧语义 = 跨工作区误读,正是 2026-09-26 那个缺陷本身)。
|
||||
// 现在的形状变了:要防的不再是「有没有带」,而是「**带的是不是真值**」。
|
||||
func TestInboxURLOmitsWorkspaceWhenUnknown(t *testing.T) {
|
||||
p := &Plugin{gwURL: "http://gw", currentSessionID: "s1"}
|
||||
u := p.inboxURL("unread", 5)
|
||||
if strings.Contains(u, "workspace=") {
|
||||
t.Fatalf("没有工作区时不该带 workspace 参数: %s", u)
|
||||
|
||||
// 现在必带(cwd 回落),但必须是**真值**而不是空串/占位
|
||||
if !strings.Contains(u, "workspace=") {
|
||||
t.Fatalf("★ cwd 回落修好后必须带 workspace:%s", u)
|
||||
}
|
||||
// 尾部不能是 `workspace=`(空值)—— 那种拼法会被服务端当成不存在的工作区
|
||||
if strings.Contains(u, "workspace=&") || strings.HasSuffix(u, "&workspace=") {
|
||||
t.Fatalf("workspace 不能是空值:%s", u)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -121,6 +121,25 @@ type Plugin struct {
|
||||
// 我今天先部署了服务端、只修了 pi/opencode/dsh 三个桥,漏了这里 ——
|
||||
// 线上随即出现 `read_inbox 工具执行失败: HTTP 400 缺少 workspace`(07:42 起)。
|
||||
// 这就是「服务端先改、四个桥后改」的那半天窗口。
|
||||
//
|
||||
// ★★ 2026-10-01:**光有它不够**(本字段的第二次补洞)。
|
||||
//
|
||||
// 现象:homeagent 的工具是**全局注册**的(webui / a2a / 对话都能调),
|
||||
// 而本字段只在 InjectInputSync 期间有值 —— 于是**从对话里调 read_inbox
|
||||
// 必然 400**。实测 2026-10-01 14:32:26 一次真实失败;近 24h **3 次失败、0 次成功**。
|
||||
//
|
||||
// 为什么另外三桥没这个问题:**它们的工具只在邮件驱动回合里被装配**
|
||||
// (pi 的 `mailTools` 在 `loadSession` 之前装,opencode/dsh 同构),
|
||||
// 所以 `getWorkspace()` 永远有值。**架构差异,不是某个桥忘了写。**
|
||||
//
|
||||
// 修法:`effectiveWorkspace()` —— 本字段为空时回落到**桥进程的 cwd**。
|
||||
// 那个 cwd 就是**调用方的工作目录**(homed 以子进程方式按调用上下文拉起它),
|
||||
// 实测:对话里那个桥进程 cwd=/home/program/agentmail,
|
||||
// 而从插件目录拉起的那个 cwd=/home/newqqagent/plugins/homeagent-mail-bridge。
|
||||
//
|
||||
// ⚠ 它是**默认**而不是**保证**:库里 17 个不同工作区,而进程 cwd 只反映
|
||||
// 当前这一个调用方。收窄语义不变(仍只列这个工作区的信),
|
||||
// 拿不准时宁可少列 —— 跨工作区误读正是 2026-09-26 那个缺陷本身。
|
||||
currentWorkspace string
|
||||
|
||||
// 单调递增的 last-seen-ID:被重放的旧事件不会让它回退。
|
||||
@ -1544,12 +1563,33 @@ func (p *Plugin) scopeQuery(sep string) string {
|
||||
return ""
|
||||
}
|
||||
q := sep + "session_id=" + url.QueryEscape(p.currentSessionID)
|
||||
if p.currentWorkspace != "" {
|
||||
q += "&workspace=" + url.QueryEscape(p.currentWorkspace)
|
||||
if ws := p.effectiveWorkspace(); ws != "" {
|
||||
q += "&workspace=" + url.QueryEscape(ws)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// effectiveWorkspace 返回本轮该用的工作区:**先信封,再进程 cwd**。
|
||||
//
|
||||
// 为什么要回落:homeagent 的工具全局可调,而 currentWorkspace 只在
|
||||
// InjectInputSync 期间有值 —— 只用它 ⇒ 对话里调 read_inbox 必 400
|
||||
// (实测 2026-10-01:3 次失败 / 0 次成功)。
|
||||
//
|
||||
// 为什么 cwd 是**正确**的默认值而不是随便猜:homed 按调用上下文以子进程方式
|
||||
// 拉起插件,那个进程的 cwd 就是**调用方的工作目录**(实测见字段注释)。
|
||||
//
|
||||
// ⚠ 拿不到时(cwd 不可读)返回空串,**不编造**:服务端会回 400,
|
||||
// 而那正是设计要的「错误可见,好过静默跨工作区拿到别处的信」。
|
||||
func (p *Plugin) effectiveWorkspace() string {
|
||||
if ws := strings.TrimSpace(p.currentWorkspace); ws != "" {
|
||||
return ws
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
return strings.TrimSpace(wd)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inboxURL 拼收件箱地址。单独抽出来是为了能被单测直接断言 ——
|
||||
// 会话收窄这种"少了个参数就静默丢信"的改动,必须有判据盯着 URL 本身。
|
||||
//
|
||||
@ -1562,9 +1602,9 @@ func (p *Plugin) inboxURL(status string, limit int) string {
|
||||
}
|
||||
// ★ workspace 同样要带(见 currentWorkspace 字段的说明)。缺了服务端直接 400
|
||||
// —— 那是刻意的:旧语义(不带 = 全部工作区)正是用户报的那个越界缺陷。
|
||||
// 拿不到时不带,让服务端报 400:错误可见,好过静默跨工作区拿到别处的信。
|
||||
if p.currentWorkspace != "" {
|
||||
scope += "&workspace=" + url.QueryEscape(p.currentWorkspace)
|
||||
// 取不到时仍不带,让服务端报 400:错误可见,好过静默跨工作区拿到别处的信。
|
||||
if ws := p.effectiveWorkspace(); ws != "" {
|
||||
scope += "&workspace=" + url.QueryEscape(ws)
|
||||
}
|
||||
return fmt.Sprintf("%s/api/v1/mail/inbox?status=%s&limit=%d%s", p.gwURL, status, limit, scope)
|
||||
}
|
||||
|
||||
@ -91,8 +91,20 @@ func TestScopeQueryEscapesAndSeparates(t *testing.T) {
|
||||
if !strings.HasPrefix(q, "?session_id=") {
|
||||
t.Fatalf("分隔符要是调用方给的那个:%q", q)
|
||||
}
|
||||
if strings.Contains(q[1:], "&") {
|
||||
t.Fatalf("session_id 未转义(会把后面的参数吃掉):%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)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -44,12 +45,17 @@ func TestReadThreadBothPathsRoutable(t *testing.T) {
|
||||
name string
|
||||
args map[string]interface{}
|
||||
wantPath string
|
||||
wantQ string
|
||||
// wantQ 只钉**这条判据要管的参数**(路径拼接 / offset 传递)。
|
||||
// ★ 2026-10-01:原断言用的是整串相等,而补上 cwd 回落之后
|
||||
// scopeQuery 会多带一个 `workspace=` 参数 ⇒ 整串比较必然不等。
|
||||
// 要防的东西没变(路径必须逐字匹配路由、offset 必须传下去),
|
||||
// 所以改成「wantQ 的每个片段都必须在实际查询串里」。
|
||||
wantQ []string
|
||||
}{
|
||||
{"offset 省略", map[string]interface{}{"mail_id": "m-1"},
|
||||
wantPath, "session_id=" + sid},
|
||||
wantPath, []string{"session_id=" + sid}},
|
||||
{"offset>0", map[string]interface{}{"mail_id": "m-1", "offset": float64(5)},
|
||||
wantPath, "offset=5&session_id=" + sid},
|
||||
wantPath, []string{"offset=5", "session_id=" + sid}},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@ -72,8 +78,10 @@ func TestReadThreadBothPathsRoutable(t *testing.T) {
|
||||
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)
|
||||
for _, frag := range c.wantQ {
|
||||
if !strings.Contains(u.RawQuery, frag) {
|
||||
t.Fatalf("★ %s:查询串缺 %q\n 实际: %s", c.name, frag, u.RawQuery)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
91
plugins/homeagent-mail-bridge/read_inbox_workspace_test.go
Normal file
91
plugins/homeagent-mail-bridge/read_inbox_workspace_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ read_inbox 在**非邮件轮次**里必须仍能带上 workspace(2026-10-01)
|
||||
//
|
||||
// ## 缺陷
|
||||
//
|
||||
// homeagent 的工具是**全局注册**的(webui / a2a / 对话都能调),而
|
||||
// `currentWorkspace` 只在 `InjectInputSync` 期间有值 ⇒ 从对话里调
|
||||
// `read_inbox` 拼不出 `&workspace=` ⇒ 服务端 400。
|
||||
//
|
||||
// 实测(2026-10-01 14:32:26):近 24h **3 次失败、0 次成功**。
|
||||
// 另三桥没这个问题,因为它们的工具**只在邮件驱动回合里被装配**
|
||||
// —— 那是架构差异,不是「某个桥忘了写」。
|
||||
//
|
||||
// ## 为什么回落到进程 cwd 是对的取值
|
||||
//
|
||||
// homed 按调用上下文以子进程方式拉起插件,那个进程的 cwd 就是
|
||||
// **调用方的工作目录**(实测:对话里那个桥 cwd=/home/program/agentmail)。
|
||||
//
|
||||
// ⚠ 它是**默认**不是**保证**:库里 17 个工作区,cwd 只反映当前这一个调用方。
|
||||
// 收窄语义不变(仍只列这个工作区的信)—— 跨工作区误读正是
|
||||
// 2026-09-26 那个缺陷本身,拿不准时宁可少列。
|
||||
|
||||
func TestEffectiveWorkspaceFallsBackToProcessCwd(t *testing.T) {
|
||||
p := &Plugin{}
|
||||
|
||||
// 场景 A:邮件轮次内 —— currentWorkspace 有值,**必须优先用它**
|
||||
p.currentWorkspace = "/home/program/agentmail"
|
||||
if got := p.effectiveWorkspace(); got != "/home/program/agentmail" {
|
||||
t.Fatalf("轮次内必须用信封上的 path 位,实际 %q", got)
|
||||
}
|
||||
|
||||
// 场景 B:对话里(currentWorkspace 为空)—— 回落到 cwd
|
||||
p.currentWorkspace = ""
|
||||
got := p.effectiveWorkspace()
|
||||
if got == "" {
|
||||
t.Fatal("★ 对话里必须有 workspace 可用,否则 read_inbox 必然 400")
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
if got != cwd {
|
||||
t.Fatalf("回落值应等于进程 cwd:得到 %q,cwd=%q", got, cwd)
|
||||
}
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Fatalf("回落值必须是绝对路径(服务端要求),实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveWorkspaceTrimsBlanks(t *testing.T) {
|
||||
// 空白串不是有效取值 —— 当作没有,回落到 cwd。
|
||||
// 否则会拼出 `workspace=%20%20`,服务端拿到一个不存在的目录。
|
||||
p := &Plugin{currentWorkspace: " "}
|
||||
got := p.effectiveWorkspace()
|
||||
cwd, _ := os.Getwd()
|
||||
if strings.TrimSpace(got) == "" {
|
||||
t.Fatal("空白 currentWorkspace 不得被当作有效取值")
|
||||
}
|
||||
if got != cwd {
|
||||
t.Fatalf("空白应回落到 cwd,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxURLAlwaysCarriesWorkspace(t *testing.T) {
|
||||
// 这条是**端到端形状**:拼出来的 URL 必须带 workspace。
|
||||
// 上面两格验的是取值,这一格验的是「真的拼进去了」——
|
||||
// 2026-09-26 那次就是取值对了、拼接处漏了,两者是不同的形状。
|
||||
p := &Plugin{}
|
||||
u := p.inboxURL("unread", 5)
|
||||
if !strings.Contains(u, "workspace=") {
|
||||
t.Fatalf("★ inboxURL 必须带 workspace(缺了服务端直接 400):%s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeQuerySkippedOutsideTurnButInboxNever(t *testing.T) {
|
||||
// scopeQuery 在非轮次时**整体**返回空串(那是刻意的:没有会话可收窄),
|
||||
// 但 inboxURL 仍必须带 workspace —— 两者不是同一个开关。
|
||||
// 混起来就会出现「非轮次时 workspace 也一起没了」,即 2026-10-01 那个缺陷。
|
||||
p := &Plugin{}
|
||||
if q := p.scopeQuery("&"); q != "" {
|
||||
t.Fatalf("非轮次时 scopeQuery 应为空,实际 %q", q)
|
||||
}
|
||||
if !strings.Contains(p.inboxURL("unread", 5), "workspace=") {
|
||||
t.Fatal("★ 非轮次时 inboxURL 仍必须带 workspace")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user