第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。
## --mode 是必传的(不传等于关掉授权系统)
ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。
档位映射(依据是 CLI 产物里的规则表,不是猜):
plan → --mode plan (mode.plan.nonReadOnly:非只读一律拒)
workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
full → --mode yolo (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。
## 一轮怎么跑
node <zcode.cjs> --prompt <提示词> --output-format stream-json \
--cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N
用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。
## 回信策略(与另三桥同源)
- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
收件箱里两封说同一件事的邮件,311 与 342 字节)。
## 两处健壮性(都是实现时自己发现的真问题)
- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
(unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
而既没有驱动看着它、也没有本地界面看着它。
## 自报强制力只声明得出来的事
驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。
## 验证
- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
反向验证:让 workspace.js 分叉会被抓住)
## 我自己写错并被测试抓出来的三处(值得记)
1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
→ 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
208 lines
8.5 KiB
JavaScript
208 lines
8.5 KiB
JavaScript
/**
|
||
* 跑一轮的测试:参数拼装 + stream-json 解析 + 假进程的整轮行为。
|
||
*
|
||
* 不需要真的 ZCode、也不需要模型 —— 用可注入的 spawn 造一个说同样协议的假进程。
|
||
* 这样「--mode 忘了传」「结果行没解析对」「超时不杀进程树」这些问题
|
||
* 都能在本地断言,而不是等到线上某封信没人回。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { EventEmitter } from 'node:events';
|
||
import { buildRunArgs, parseStreamLine, runTurn } from '../src/zcode-run.mjs';
|
||
|
||
/** 造一个假子进程:按脚本吐 stdout/stderr,然后以指定退出码关闭。 */
|
||
function fakeSpawn({ stdout = '', stderr = '', exitCode = 0, onStart, neverExit = false } = {}) {
|
||
const calls = [];
|
||
const fn = (file, args, opts) => {
|
||
calls.push({ file, args, opts });
|
||
const child = new EventEmitter();
|
||
child.stdout = new EventEmitter();
|
||
child.stderr = new EventEmitter();
|
||
child.pid = 4242;
|
||
child.kill = () => true;
|
||
onStart?.(child, { file, args, opts });
|
||
setImmediate(() => {
|
||
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
|
||
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
|
||
if (!neverExit) {
|
||
child.exitCode = exitCode;
|
||
child.emit('close', exitCode);
|
||
}
|
||
});
|
||
return child;
|
||
};
|
||
fn.calls = calls;
|
||
return fn;
|
||
}
|
||
|
||
// ─── 参数拼装 ─────────────────────────────────────────────────────
|
||
test('★ 参数里必须带 --mode(不传等于默认 yolo,会绕过授权)', () => {
|
||
const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
|
||
assert.ok(args.includes('--mode'));
|
||
assert.equal(args[args.indexOf('--mode') + 1], 'build');
|
||
});
|
||
|
||
test('★ 漏传 mode 直接抛错,而不是悄悄退回默认', () => {
|
||
// 这是刻意的:静默退回意味着「授权系统消失但没人发现」。
|
||
assert.throws(() => buildRunArgs({ prompt: 'p', cwd: '/tmp' }), /mode/);
|
||
});
|
||
|
||
test('默认用 stream-json,便于把过程写进日志', () => {
|
||
const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
|
||
assert.equal(args[args.indexOf('--output-format') + 1], 'stream-json');
|
||
});
|
||
|
||
test('resume 只在有时才带(首轮不该带空 --resume)', () => {
|
||
const first = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
|
||
assert.equal(first.includes('--resume'), false);
|
||
const next = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build', resumeSessionId: 'sess_1' });
|
||
assert.equal(next[next.indexOf('--resume') + 1], 'sess_1');
|
||
});
|
||
|
||
test('maxTurns 与工具黑白名单按需传递', () => {
|
||
const args = buildRunArgs({
|
||
prompt: 'p',
|
||
cwd: '/tmp',
|
||
mode: 'plan',
|
||
maxTurns: 6,
|
||
allowedTools: ['Read', 'Grep'],
|
||
disallowedTools: ['Bash']
|
||
});
|
||
assert.equal(args[args.indexOf('--max-turns') + 1], '6');
|
||
assert.equal(args[args.indexOf('--allowed-tools') + 1], 'Read,Grep');
|
||
assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash');
|
||
});
|
||
|
||
// ─── 解析 ─────────────────────────────────────────────────────────
|
||
test('result 行取出 sessionId 与 response', () => {
|
||
const r = parseStreamLine(
|
||
JSON.stringify({ type: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3 })
|
||
);
|
||
assert.deepEqual(r, { kind: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3, projection: undefined });
|
||
});
|
||
|
||
test('普通事件行归为 event', () => {
|
||
const e = parseStreamLine(JSON.stringify({ type: 'tool.call.started', toolName: 'Bash' }));
|
||
assert.equal(e.kind, 'event');
|
||
assert.equal(e.event.toolName, 'Bash');
|
||
});
|
||
|
||
test('非 JSON / 空行返回 null(不抛)', () => {
|
||
for (const line of ['', ' ', 'not json', '[1,2]', 'null', '42']) {
|
||
assert.equal(parseStreamLine(line), null, JSON.stringify(line));
|
||
}
|
||
});
|
||
|
||
test('★ 输出格式变了会被计数,而不是静默当成没输出', async () => {
|
||
// 若 CLI 换掉了输出格式,所有的行都会变成不可解析 —— 那时必须能看见
|
||
// 「解析不了的行有 N 条」,否则现象是「回合跑完了但什么都没回」。
|
||
const spawn = fakeSpawn({ stdout: 'human readable output\nmore text\n' });
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.unparsable, 2);
|
||
assert.equal(r.response, '');
|
||
});
|
||
|
||
// ─── 整轮行为 ─────────────────────────────────────────────────────
|
||
test('整轮:解析事件、取出最终回复与会话 id', async () => {
|
||
const spawn = fakeSpawn({
|
||
stdout:
|
||
JSON.stringify({ type: 'tool.call.started', toolName: 'Read' }) +
|
||
'\n' +
|
||
JSON.stringify({ type: 'result', sessionId: 'sess_9', response: '结论:可以' }) +
|
||
'\n'
|
||
});
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.sessionId, 'sess_9');
|
||
assert.equal(r.response, '结论:可以');
|
||
assert.equal(r.events.length, 1);
|
||
assert.equal(r.exitCode, 0);
|
||
});
|
||
|
||
test('最后一行没有换行也能收到', async () => {
|
||
const spawn = fakeSpawn({ stdout: JSON.stringify({ type: 'result', sessionId: 's', response: 'ok' }) });
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.response, 'ok');
|
||
});
|
||
|
||
test('分块到达(一条 JSON 被切成两半)也能拼回来', async () => {
|
||
const payload = JSON.stringify({ type: 'result', sessionId: 'sess_split', response: '完整' });
|
||
const half = Math.floor(payload.length / 2);
|
||
const spawn = fakeSpawn({
|
||
onStart: child => {
|
||
setImmediate(() => {
|
||
child.stdout.emit('data', Buffer.from(payload.slice(0, half)));
|
||
child.stdout.emit('data', Buffer.from(`${payload.slice(half)}\n`));
|
||
child.emit('close', 0);
|
||
});
|
||
},
|
||
neverExit: true
|
||
});
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.response, '完整');
|
||
});
|
||
|
||
test('非零退出码原样带出(不吞成成功)', async () => {
|
||
const spawn = fakeSpawn({ stdout: '', stderr: 'boom\n', exitCode: 7 });
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.exitCode, 7);
|
||
assert.match(r.stderrTail, /boom/);
|
||
});
|
||
|
||
test('spawn 本身失败时给可读结果,而不是抛出去', async () => {
|
||
const spawn = () => {
|
||
throw new Error('ENOENT');
|
||
};
|
||
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
assert.equal(r.exitCode, -1);
|
||
assert.match(r.stderrTail, /ENOENT/);
|
||
});
|
||
|
||
test('★ 超时会标记 timedOut 并杀进程树', async () => {
|
||
const signals = [];
|
||
const spawn = fakeSpawn({
|
||
neverExit: true,
|
||
onStart: child => {
|
||
child.kill = sig => {
|
||
signals.push(sig);
|
||
};
|
||
}
|
||
});
|
||
// 孩子永不退出:这是最坏情况 —— 既不退也不报错。
|
||
const r = await runTurn(
|
||
{ prompt: 'p', cwd: '/tmp', mode: 'build', turnTimeoutMs: 60, killGraceMs: 60, settleGraceMs: 60 },
|
||
{ spawn }
|
||
);
|
||
assert.equal(r.timedOut, true, '必须报告超时');
|
||
assert.equal(r.exitCode, -1);
|
||
// ★ 更关键的是**它一定会结束**:不结束的话驱动会对这封信永远挂住,
|
||
// 而队列是串行的,后面的信全都不再被处理。
|
||
// 升级阶梯:先礼后兵。只断言「杀过」会漏掉「SIGTERM 之后没升级」——
|
||
// 那种情况下要等满宽限期才能收尾,而工具子进程可能已经跑完了坏事。
|
||
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
|
||
});
|
||
|
||
test('进程组隔离:非 win32 平台用 detached 起', async () => {
|
||
const spawn = fakeSpawn({ stdout: '' });
|
||
await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
|
||
const opts = spawn.calls[0].opts;
|
||
if (process.platform !== 'win32') assert.equal(opts.detached, true);
|
||
assert.equal(opts.cwd, '/tmp');
|
||
});
|
||
|
||
test('注入的环境变量会传给子进程(钩子靠它判断档位与有无本地界面)', async () => {
|
||
const spawn = fakeSpawn({ stdout: '' });
|
||
await runTurn(
|
||
{
|
||
prompt: 'p',
|
||
cwd: '/tmp',
|
||
mode: 'build',
|
||
env: { AGENTMAIL_SESSION_ID: 'sess-x', AGENTMAIL_PERMISSION_MODE: 'workspace' }
|
||
},
|
||
{ spawn }
|
||
);
|
||
const env = spawn.calls[0].opts.env;
|
||
assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-x');
|
||
assert.equal(env.AGENTMAIL_PERMISSION_MODE, 'workspace');
|
||
});
|