Files
MailUI4Agents/plugins/opencode-mail-bridge/test/workspace-probe.test.mjs
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

86 lines
4.2 KiB
JavaScript
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.

/**
* ★ 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');
});