/** * 驱动的整条流水线测试(不需要模型、不需要 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 }); });