第三步(补齐一等 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。
370 lines
14 KiB
JavaScript
370 lines
14 KiB
JavaScript
/**
|
||
* 驱动的整条流水线测试(不需要模型、不需要 ZCode)。
|
||
*
|
||
* 判据集中在几件**错了就会静默出错**的事上:
|
||
*
|
||
* - 说错「会不会替你回信」→ 要么发件人等一封永远不来的信,要么收到两封重复邮件
|
||
* - 忘了带 `--mode` → 授权询问全部消失(见 turn-mode 的测试)
|
||
* - 一轮跑不起来却不回信 → 发件人只看到「信发出去了,然后再无音讯」
|
||
* - 去重失效 → 同一封信被处理两遍
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtemp, rm, readFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { createDriver } from '../src/index.mjs';
|
||
import { explicitSendsFile, noteExplicitSendFile } from '../lib/explicit-sends.mjs';
|
||
|
||
/** 假网关客户端:记下发出去的每一封信。 */
|
||
function fakeClient() {
|
||
const sent = [];
|
||
return {
|
||
sent,
|
||
baseURL: 'http://fake',
|
||
agentName: 'zcode',
|
||
authHeaders: () => ({ 'X-Agent-Name': 'zcode' }),
|
||
checkConfig: () => [],
|
||
async post(path, body) {
|
||
if (path === '/mail/send') {
|
||
sent.push(body);
|
||
return { mail_id: `out-${sent.length}` };
|
||
}
|
||
return {};
|
||
},
|
||
async get() {
|
||
return {};
|
||
}
|
||
};
|
||
}
|
||
|
||
/** 一封来自人的来信。 */
|
||
const humanMail = (over = {}) => ({
|
||
mail_id: 'in-1',
|
||
session_id: 'sess-1',
|
||
role: 'to',
|
||
from_human: true,
|
||
from_name: 'gui-lab',
|
||
subject: '帮我看看日志',
|
||
permission_mode: 'workspace',
|
||
to_workspace: '',
|
||
reply_address: 'gui-lab@/work.sess-1',
|
||
...over
|
||
});
|
||
|
||
async function harness({ turn = { sessionId: 'sess_z1', response: '结论:是磁盘满了', exitCode: 0 }, env } = {}) {
|
||
const dir = await mkdtemp(join(tmpdir(), 'zc-driver-'));
|
||
const client = fakeClient();
|
||
const logs = [];
|
||
const calls = [];
|
||
const driver = createDriver({
|
||
client,
|
||
logFn: (...a) => logs.push(a.join(' ')),
|
||
env: env || {},
|
||
config: { workspaceRoot: dir, cliPath: '/fake/zcode.cjs', turnTimeoutMs: 60_000 },
|
||
runTurnFn: async (opts, deps) => {
|
||
calls.push({ opts, deps });
|
||
// 第三个参数是调用序号:测试里常要「第一次失败、第二次成功」
|
||
return typeof turn === 'function' ? turn(opts, deps, calls.length) : turn;
|
||
}
|
||
});
|
||
return { driver, client, logs, calls, dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
||
}
|
||
|
||
test('★ 人来信 + 模型没自己发 → 自动回信', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 1, '必须回一封信');
|
||
const mail = h.client.sent[0];
|
||
assert.equal(mail.to, 'gui-lab');
|
||
assert.equal(mail.body, '结论:是磁盘满了');
|
||
assert.equal(mail.reply_to, 'in-1');
|
||
assert.equal(mail.subject, 'Re: 帮我看看日志');
|
||
// 走免配额通道:模型已经把话说完了,驱动只是搬运
|
||
assert.equal(mail.relay, 'summary');
|
||
assert.ok(mail.relay_key, '要有幂等键');
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ Agent 来信 → 不自动转发(Agent 间必须自己 send_mail)', async () => {
|
||
// 反向对照:同一封邮件只翻转 from_human,回信行为必须跟着翻转。
|
||
// 不这么做的话,两个 Agent 会互相把对方的「已收到」当成待办,无限客套下去。
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' }));
|
||
assert.equal(h.client.sent.length, 0, 'Agent 来信不该被自动回信');
|
||
assert.ok(
|
||
h.logs.some(l => /不自动转发/.test(l)),
|
||
`日志里应说明原因:${h.logs.join(' | ')}`
|
||
);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 模型这一轮自己发过信 → 让位,不重复转发', async () => {
|
||
// 线上实测过后果:收件箱里两封说同一件事的邮件(311 与 342 字节)。
|
||
const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(await mkdtemp(join(tmpdir(), 'zc-sends-')), 'sends.jsonl') };
|
||
noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, {
|
||
sessionId: 'sess-1',
|
||
to: 'gui-lab',
|
||
replyTo: 'in-1'
|
||
});
|
||
const h = await harness({ env });
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 0, '模型已亲手回过,驱动不该再发一封');
|
||
assert.ok(h.logs.some(l => /跳过自动转发/.test(l)));
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 反向对照:另一条会话的主动发信不该让本会话沉默', async () => {
|
||
// 去重不能按「有人发过信」一刀切,必须按会话配对。
|
||
const dir = await mkdtemp(join(tmpdir(), 'zc-sends-'));
|
||
const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(dir, 'sends.jsonl') };
|
||
noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, {
|
||
sessionId: 'sess-OTHER',
|
||
to: 'gui-lab',
|
||
replyTo: 'in-1'
|
||
});
|
||
const h = await harness({ env });
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 1, '别的会话发过信不该影响这一封');
|
||
} finally {
|
||
await h.cleanup();
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('★★ 一轮跑不起来 → 必须回一封失败信', async () => {
|
||
// 邮件驱动的会话没有本地界面:什么都不发等于「信发出去了,然后再无音讯」。
|
||
const h = await harness({
|
||
turn: { sessionId: '', response: '', exitCode: 1, stderrTail: 'Model config is missing.' }
|
||
});
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 1, '失败也必须回信');
|
||
const mail = h.client.sent[0];
|
||
assert.match(mail.subject, /处理失败/);
|
||
assert.match(mail.body, /Model config is missing/);
|
||
// 失败信的正文要给出**这个平台**的成因,而不是别处的建议
|
||
assert.match(mail.body, /没有登录/);
|
||
assert.match(mail.body, /AGENTMAIL_ZCODE_CLI/);
|
||
assert.equal(h.driver.stats.failures, 1);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('超时也算失败,且原因写明超时', async () => {
|
||
const h = await harness({
|
||
turn: { sessionId: '', response: '', exitCode: -1, timedOut: true }
|
||
});
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.match(h.client.sent[0].body, /超时/);
|
||
assert.doesNotMatch(h.client.sent[0].body, /CLI 失败/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('退出码 0 但没有最终文本 → 不冒充回信', async () => {
|
||
const h = await harness({ turn: { sessionId: 's', response: ' ', exitCode: 0 } });
|
||
try {
|
||
const r = await h.driver.processMail(humanMail());
|
||
assert.equal(h.client.sent.length, 0);
|
||
assert.equal(r.relayed, false);
|
||
assert.ok(h.logs.some(l => /没有产出最终文本/.test(l)));
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
// ─── 提示词与档位怎么传下去 ─────────────────────────────────────────
|
||
test('提示词里带上回信地址与邮件 id(模型自己发信时要拼对地址)', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
const prompt = h.calls[0].opts.prompt;
|
||
assert.match(prompt, /gui-lab@\/work\.sess-1/);
|
||
assert.match(prompt, /in-1/);
|
||
// 人来信:告诉模型插件会替它回信
|
||
assert.match(prompt, /回信不用你自己发/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('Agent 来信的提示词必须说清「插件不会替你回信」', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' }));
|
||
const prompt = h.calls[0].opts.prompt;
|
||
assert.match(prompt, /不会替你回信/);
|
||
assert.doesNotMatch(prompt, /回信不用你自己发/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 档位随邮件传下去,并作为 --mode 与钩子环境变量注入', async () => {
|
||
for (const [tier, mode] of [
|
||
['plan', 'plan'],
|
||
['workspace', 'build'],
|
||
['full', 'yolo']
|
||
]) {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail({ permission_mode: tier }));
|
||
assert.equal(h.calls[0].opts.mode, mode, `档位 ${tier} 应映射到 ${mode}`);
|
||
const env = h.calls[0].opts.env;
|
||
// 钩子靠这两个变量决定档位与「有没有本地界面」
|
||
assert.equal(env.AGENTMAIL_PERMISSION_MODE, tier);
|
||
assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-1');
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
}
|
||
});
|
||
|
||
test('工作目录取 to_workspace;没有就用兜底目录', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.ok(h.calls[0].opts.cwd.startsWith(h.dir), `兜底目录应在 ${h.dir} 下,实际 ${h.calls[0].opts.cwd}`);
|
||
|
||
const real = await mkdtemp(join(tmpdir(), 'zc-ws-'));
|
||
await h.driver.processMail(humanMail({ mail_id: 'in-2', to_workspace: real }));
|
||
assert.equal(h.calls[1].opts.cwd, real);
|
||
await rm(real, { recursive: true, force: true });
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 同一会话的第二封信带上 --resume(否则模型每封信都从零开始)', async () => {
|
||
const h = await harness();
|
||
try {
|
||
await h.driver.processMail(humanMail());
|
||
assert.equal(h.calls[0].opts.resumeSessionId, undefined, '首轮不该带 resume');
|
||
await h.driver.processMail(humanMail({ mail_id: 'in-2' }));
|
||
assert.equal(h.calls[1].opts.resumeSessionId, 'sess_z1', '第二轮要续上同一个 ZCode 会话');
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
// ─── 事件入口 ───────────────────────────────────────────────────────
|
||
test('SSE 事件:重复的 mail_id 只处理一次', async () => {
|
||
const h = await harness();
|
||
try {
|
||
h.driver.handleEvent('new_mail', humanMail());
|
||
h.driver.handleEvent('new_mail', humanMail());
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(h.calls.length, 1, '同一封信被处理了两遍');
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('抄送给自己也处理(role=cc),其它角色忽略', async () => {
|
||
const h = await harness();
|
||
try {
|
||
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'cc-1', role: 'cc' }));
|
||
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'x-1', role: 'from' }));
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(h.calls.length, 1);
|
||
assert.equal(h.calls[0].opts.prompt.includes('cc-1'), true);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('非 new_mail 事件被忽略(permission_decision 由钩子自己处理)', async () => {
|
||
const h = await harness();
|
||
try {
|
||
h.driver.handleEvent('permission_decision', { relay_key: 'k' });
|
||
h.driver.handleEvent('session_archived', { session_id: 's' });
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(h.calls.length, 0);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('一封邮件处理崩了不会带走驱动(后面的信照常处理)', async () => {
|
||
const h = await harness({
|
||
turn: (opts, deps, n) => {
|
||
if (n === 1) throw new Error('boom');
|
||
return { sessionId: 's', response: '第二封处理好了', exitCode: 0 };
|
||
}
|
||
});
|
||
try {
|
||
h.driver.handleEvent('new_mail', humanMail());
|
||
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'in-2' }));
|
||
await new Promise(r => setTimeout(r, 50));
|
||
assert.equal(h.client.sent.length, 1);
|
||
assert.match(h.client.sent[0].body, /第二封处理好了/);
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
test('★ 关停时终止在途回合(不留下跑工具的孤儿)', async () => {
|
||
// systemd 杀掉驱动后,那个 ZCode 进程还在跑工具,而既没有驱动看着它、
|
||
// 也没有本地界面看着它 —— 宁可丢掉这一轮的工作。
|
||
const signals = [];
|
||
let release;
|
||
const h = await harness({
|
||
turn: async (opts, deps) => {
|
||
// 真实现会把「怎么杀」通过 deps.onChild 交出来(见 zcode-run.mjs)
|
||
deps.onChild(sig => signals.push(sig));
|
||
await new Promise(r => (release = r));
|
||
return { sessionId: 's', response: 'x', exitCode: 0 };
|
||
}
|
||
});
|
||
try {
|
||
const p = h.driver.processMail(humanMail());
|
||
await new Promise(r => setTimeout(r, 20));
|
||
assert.equal(typeof release, 'function', '回合应已开始');
|
||
|
||
h.driver.abort();
|
||
assert.deepEqual(signals, ['SIGTERM'], 'abort 必须终止在途回合');
|
||
// 幂等:重复关停不该再杀一次
|
||
h.driver.abort();
|
||
assert.deepEqual(signals, ['SIGTERM']);
|
||
|
||
release();
|
||
await p;
|
||
} finally {
|
||
await h.cleanup();
|
||
}
|
||
});
|
||
|
||
// ─── 主动发信记录的落盘 ─────────────────────────────────────────────
|
||
test('★ explicit-sends 文件位置两端一致(驱动与 MCP 服务器必须解析出同一路径)', async () => {
|
||
const env = { AGENTMAIL_CONFIG_DIR: '/tmp/agentmail-cfg' };
|
||
assert.equal(explicitSendsFile(env), '/tmp/agentmail-cfg/explicit-sends.jsonl');
|
||
assert.equal(explicitSendsFile({ ZCODE_PLUGIN_DATA: '/d' }), '/d/explicit-sends.jsonl');
|
||
assert.match(explicitSendsFile({}), /explicit-sends\.jsonl$/);
|
||
});
|
||
|
||
test('读回的记录形状可直接交给共用去重判据', async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), 'zc-sends-'));
|
||
const f = join(dir, 'sends.jsonl');
|
||
noteExplicitSendFile(f, { sessionId: 's1', to: 'gui-lab@/p', replyTo: 'm1' });
|
||
const { readExplicitSends } = await import('../lib/explicit-sends.mjs');
|
||
const rec = readExplicitSends(f, { sessionId: 's1' });
|
||
assert.ok(rec.names.has('gui-lab'));
|
||
assert.ok(rec.replyTos.has('m1'));
|
||
await rm(dir, { recursive: true, force: true });
|
||
});
|