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")
|
||||
}
|
||||
}
|
||||
@ -122,6 +122,59 @@ function authHeaders() {
|
||||
|
||||
// ─── HTTP ───
|
||||
|
||||
/*
|
||||
★ 2026-10-01:取出「这次调用所在的工作区」,信封优先、其次向 opencode 问。
|
||||
|
||||
# 为什么需要它
|
||||
|
||||
`sessionWorkspace` 只在**邮件驱动回合**建会话那一刻被填(见 resolveSessionForMail)。
|
||||
插件是全局注册的(`export default`),所以从 webui/界面里直接对话时调 `read_inbox`,
|
||||
那次调用落在一条**不是邮件驱动**的会话上 ⇒ 查表为空 ⇒ 服务端 400。
|
||||
|
||||
# 为什么向 opencode 问是可靠的(而不是又一份暂存状态)
|
||||
|
||||
`@opencode-ai/sdk` 的 `Session` 类型**带 directory 字段**
|
||||
(node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts 里 `export type Session`
|
||||
可见),而 `client.session.get({ path: { id } })` 是本桥**已在用**的调用
|
||||
(resolveSessionForMail 里校验被接管的会话就是它)。
|
||||
⇒ 这里问出来的是**权威值**,不是我们自己维护的第二份拷贝。
|
||||
|
||||
# 为什么要缓存(否则每个工具调用都多一次 HTTP)
|
||||
|
||||
`client` 不是全局的(按插件实例持有),所以用一个模块级 Map 按 sessionID 缓存,
|
||||
拿不到就不缓存(失败会重试,避免把一次抖动记成永久)。
|
||||
|
||||
⚠ 缓存只存**成功**拿到的值:宁可每次多问一次,也不要把错误记成事实。
|
||||
*/
|
||||
const workspaceProbeCache = new Map();
|
||||
|
||||
async function resolveWorkspace(context, client) {
|
||||
const sessionID = String(context?.sessionID ?? "");
|
||||
if (!sessionID) return "";
|
||||
|
||||
const known = sessionWorkspace.get(sessionID);
|
||||
if (known) return known;
|
||||
|
||||
const cached = workspaceProbeCache.get(sessionID);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!client?.session?.get) return "";
|
||||
try {
|
||||
const got = await client.session.get({ path: { id: sessionID } });
|
||||
const dir = (got?.data ?? got)?.directory;
|
||||
if (typeof dir === "string" && dir.trim()) {
|
||||
workspaceProbeCache.set(sessionID, dir.trim());
|
||||
sessionWorkspace.set(sessionID, dir.trim());
|
||||
return dir.trim();
|
||||
}
|
||||
} catch {
|
||||
// 问不到就当没工作区:下面的调用会不带 workspace,服务端会报 400。
|
||||
// 那是刻意的(旧语义正是跨工作区误读那个缺陷本身),
|
||||
// 比"猜一个目录然后读到别人的信"安全得多。
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(`${GATEWAY_URL}/api/v1${path}`, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
|
||||
@ -274,14 +327,30 @@ const readInboxTool = {
|
||||
const filter = args.filter || DEFAULT_INBOX_STATUS;
|
||||
const limit = args.limit || DEFAULT_INBOX_LIMIT;
|
||||
const mailSessionID = reverseMap.get(String(context?.sessionID ?? "")) || "";
|
||||
const workspace = sessionWorkspace.get(String(context?.sessionID ?? "")) || "";
|
||||
/*
|
||||
★ 两维收窄都要带:
|
||||
· session_id —— 只列**这条线索**的信(防"A 会话标掉 B 会话的未读")
|
||||
· session_id —— 只列**这条线索**的<EFBFBD><EFBFBD>(防"A 会话标掉 B 会话的未读")
|
||||
· workspace —— 只列**这个工作区**的信(防"在 mc 干活却读到 agentmail 的信")
|
||||
服务端对缺 workspace 直接 400 —— 那是刻意的(旧语义正是缺陷本身)。
|
||||
workspace 拿不到时**不带**,让服务端报 400:错误可见,好过静默跨工作区。
|
||||
|
||||
★★ 2026-10-01:拿不到时的第二道取值(补上后者的同形状缺陷)
|
||||
|
||||
`sessionWorkspace` 只在**邮件驱动回合**建会话那一刻被填(line 754)。
|
||||
而插件是 `export default` 全局注册的 —— 从 webui/界面里直接对话时
|
||||
调 `read_inbox`,那次调用落在一条**不是邮件驱动**的会话上 ⇒ 查表为空
|
||||
⇒ workspace 空 ⇒ 服务端 400。
|
||||
|
||||
与 homeagent 那次是**同一个形状**(那边实测 3 次失败 / 0 次成功),
|
||||
但成因不同:homeagent 的工具 handler 拿不到 exec 上下文、只能靠字段暂存;
|
||||
本桥的 handler **拿得到** `context.sessionID`,而 opencode 的
|
||||
`Session` 对象带 `directory` 字段(types.gen.d.ts 里已确认),
|
||||
所以可以现场问出来 —— 不必猜,也不必记。
|
||||
|
||||
两次都失败是**静默**的(工具返回一句错误文本,模型照旧往下走),
|
||||
所以线上至今 0 次报错不代表没问题。
|
||||
*/
|
||||
const workspace = await resolveWorkspace(context, hostClient);
|
||||
const scope = (mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : "")
|
||||
+ (workspace ? `&workspace=${encodeURIComponent(workspace)}` : "");
|
||||
const data = await apiGet(`/mail/inbox?status=${filter}&limit=${limit}${scope}`);
|
||||
@ -636,6 +705,19 @@ pi 的 worker 一个进程只服务一封邮件,信封就在手上(`data.to_
|
||||
的代码。三维地址 `name@path.session` 的 **path 位本来就该参与寻址**。
|
||||
*/
|
||||
const sessionWorkspace = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 工作区绝对路径
|
||||
|
||||
/*
|
||||
★ 2026-10-01:把插件实例的 `client` 存一份,供**模块级工具对象**用。
|
||||
|
||||
`readInboxTool` 是模块级常量(`tool: { read_inbox: readInboxTool }`),
|
||||
拿不到 `mailBridge(input)` 的参数。而 `resolveWorkspace` 需要 `client.session.get`
|
||||
去问权威的 `Session.directory`(见该函数注释)。
|
||||
|
||||
⇒ 在插件初始化时存下来。插件实例是**一个**(`export default` 被调用一次),
|
||||
所以这份引用是稳定的;readInboxTool 是全局可调的,而它需要的不止是信件状态,
|
||||
还有宿主句柄 —— 以前没这个需求,所以没有通道。
|
||||
*/
|
||||
let hostClient = null;
|
||||
const syncedTitles = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> 已回写过的标题(去重,避免 session.updated 刷屏)
|
||||
|
||||
// 权限询问的双向定位。
|
||||
@ -1173,6 +1255,8 @@ function settleFirstTurn(sessionID, outcome) {
|
||||
|
||||
export default async function mailBridge(input) {
|
||||
const { client, directory } = input;
|
||||
// 供模块级工具对象(read_inbox)问工作区用,见 hostClient 的注释。
|
||||
if (client) hostClient = client;
|
||||
|
||||
// 注册 Agent。无密钥也无 secret 时先本地生成一把密钥,
|
||||
// 等管理员在后台登记后即可接入(无需重装插件)。
|
||||
|
||||
86
plugins/opencode-mail-bridge/test/workspace-probe.test.mjs
Normal file
86
plugins/opencode-mail-bridge/test/workspace-probe.test.mjs
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* ★ 2026-10-01:read_inbox 在**非邮件驱动**的会话上也得能带出 workspace。
|
||||
*
|
||||
* ## 缺陷
|
||||
*
|
||||
* `sessionWorkspace` 只在**邮件驱动回合**建会话那一刻被填
|
||||
* (`resolveSessionForMail` 里 `sessionWorkspace.set(sessionID, wantDir)`)。
|
||||
* 而插件是 `export default` 全局注册的 —— 从 webui/界面里直接对话时调
|
||||
* `read_inbox`,那次调用落在一条**不是邮件驱动**的会话上 ⇒ 查表为空
|
||||
* ⇒ URL 拼不出 `&workspace=` ⇒ **服务端 400**。
|
||||
*
|
||||
* 与 homeagent 那次是同一个形状(那边实测 3 次失败 / 0 次成功),但成因不同:
|
||||
* homeagent 的 handler 拿不到 exec 上下文,只能靠字段暂存;
|
||||
* 本桥的 handler 拿得到 `context.sessionID`,且 opencode 的 `Session` 带
|
||||
* `directory`(`types.gen.d.ts` 里 `export type Session` 可见)⇒ 可以现场问出来。
|
||||
*
|
||||
* ⚠ 静默失败:工具返回一句错误文本,模型照旧往下走
|
||||
* ⇒ **线上至今 0 次报错不代表没问题**(这一点与 homeagent 那次不同:
|
||||
* 那边至少有人工触发的失败日志)。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(HERE, '..', 'index.js'), 'utf8');
|
||||
|
||||
test('read_inbox 不再直接查 sessionWorkspace(那是非邮件驱动会话的空来源)', () => {
|
||||
// 旧写法:查表为空 → workspace 空 → 400
|
||||
assert.ok(
|
||||
!/const workspace = sessionWorkspace\.get\(String\(context\?\.sessionID/.test(src),
|
||||
'read_inbox 仍在直接查 sessionWorkspace —— 非邮件驱动会话上它必为空');
|
||||
assert.ok(
|
||||
src.includes('await resolveWorkspace(context'),
|
||||
'read_inbox 应改走 resolveWorkspace()');
|
||||
});
|
||||
|
||||
test('resolveWorkspace 有信封之外的第二道取值', () => {
|
||||
assert.ok(src.includes('async function resolveWorkspace('),
|
||||
'缺少 resolveWorkspace()');
|
||||
// 第二道:向宿主问权威的 Session.directory
|
||||
assert.ok(src.includes('client.session.get'),
|
||||
'resolveWorkspace 应向 opencode 问 Session.directory(本桥已在别处用过这个调用)');
|
||||
assert.ok(src.includes('.directory'),
|
||||
'应读取 Session 的 directory 字段');
|
||||
});
|
||||
|
||||
test('★ 缓存只存成功值(失败的探测不得被记成事实)', () => {
|
||||
// 缓存若把失败也记下来,一次抖动会变成永久的"这个会话没有工作区"
|
||||
// ⇒ 而那正是本缺陷本身(静默地少带参数 → 400)。
|
||||
const m = src.match(/async function resolveWorkspace[\s\S]*?\n}/);
|
||||
assert.ok(m, '未找到 resolveWorkspace 函数体');
|
||||
const body = m[0];
|
||||
assert.ok(
|
||||
!/catch\s*\{[\s\S]{0,200}?\n\s*\}\s*\n?\s*workspaceProbeCache\.set/.test(body)
|
||||
|| /catch[\s\S]*?\n\s*return\s+""/.test(body),
|
||||
'catch 分支里不得写入缓存');
|
||||
// 有缓存写入的话,必须发生在成功判据之后
|
||||
const setIdx = body.indexOf('workspaceProbeCache.set');
|
||||
const dirIdx = body.indexOf('.directory');
|
||||
assert.ok(setIdx > dirIdx,
|
||||
'缓存写入必须发生在确认 directory 之后(否则会把空值记成事实)');
|
||||
});
|
||||
|
||||
test('★ 拿不到时不猜目录(宁可让服务端 400)', () => {
|
||||
const m = src.match(/async function resolveWorkspace[\s\S]*?\n}/);
|
||||
const body = m[0];
|
||||
// 回落到某个"看起来像工作区"的默认值 = 跨工作区误读,正是 2026-09-26 那个缺陷
|
||||
assert.ok(!/__dirname|process\.cwd\(\)/.test(body),
|
||||
'不得用 __dirname / process.cwd() 当工作区回落 —— 那会读到别处的信');
|
||||
assert.ok(body.includes('return "";'),
|
||||
'拿不到时必须返回空串(让服务端报 400)');
|
||||
});
|
||||
|
||||
test('模块级工具对象拿得到宿主 client(否则第二道取值形同虚设)', () => {
|
||||
assert.ok(src.includes('let hostClient = null;'),
|
||||
'缺少 hostClient —— readInboxTool 是模块级常量,拿不到 mailBridge(input) 的参数');
|
||||
assert.ok(
|
||||
/if \(client\) hostClient = client;/.test(src),
|
||||
'插件初始化时必须把 client 存下来');
|
||||
assert.ok(
|
||||
src.includes('await resolveWorkspace(context, hostClient)'),
|
||||
'read_inbox 应把 hostClient 传给 resolveWorkspace');
|
||||
});
|
||||
Reference in New Issue
Block a user