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