/** * pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。 * * 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的 * 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。 * * node --test 'test/*.test.mjs' */ 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)); import { stripRe, replySubject, lastAssistantText, classifyTurnOutcome, describeError, buildMailPrompt, relayKeyFor, renderResumeFailure, } from '../src/turn.mjs'; // ─── 主题 ─── test('stripRe 去掉叠加的 Re: 前缀', () => { assert.equal(stripRe('Re: Re: Re: 缓存选型'), '缓存选型'); assert.equal(stripRe('缓存选型'), '缓存选型'); assert.equal(stripRe('RE: RE: x'), 'x'); }); test('replySubject 只加一层 Re:', () => { assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型'); assert.equal(replySubject('缓存选型'), 'Re: 缓存选型'); }); test('无主题时回信有兜底主题', () => { // 空主题会被 Gateway 拒(400 Missing subject),不兜底就发不出去 assert.equal(replySubject(''), '本轮工作总结'); assert.equal(replySubject(undefined), '本轮工作总结'); }); // ─── 消息文本提取 ─── const asst = (blocks, over = {}) => ({ role: 'assistant', content: blocks, stopReason: 'stop', ...over }); test('只取 text 块,丢掉 thinking', () => { // 思考过程不该出现在邮件里(B-5.1 / N-6):它对收件人没有意义, // 而且经常包含「我先假设…」这类会被误读为结论的话。 const got = lastAssistantText([ asst([ { type: 'thinking', thinking: '先看看有没有缓存层' }, { type: 'text', text: '已定位到问题:连接池没有复用。' }, ]), ]); assert.equal(got, '已定位到问题:连接池没有复用。'); }); test('不变量:跳过纯工具调用的收尾消息,往前找有文本的那条', () => { // 一轮的最后一条 assistant 消息常常只有 toolCall。取到它会得到空串, // 于是 B-5.4 判成「无话可说」而漏掉真正的结论 —— 发件人再无音讯。 const got = lastAssistantText([ asst([{ type: 'text', text: '结论在这里。' }]), asst([{ type: 'toolCall', toolName: 'bash', input: {} }]), ]); assert.equal(got, '结论在这里。'); }); test('多个 text 块按顺序拼接', () => { const got = lastAssistantText([asst([ { type: 'text', text: '第一段' }, { type: 'text', text: '第二段' }, ])]); assert.equal(got, '第一段\n第二段'); }); test('忽略 user 消息里的文本', () => { const got = lastAssistantText([ asst([{ type: 'text', text: 'assistant 说的' }]), { role: 'user', content: [{ type: 'text', text: 'user 说的' }] }, ]); assert.equal(got, 'assistant 说的'); }); test('没有 assistant 消息时返回空串', () => { assert.equal(lastAssistantText([{ role: 'user', content: [{ type: 'text', text: 'x' }] }]), ''); assert.equal(lastAssistantText([]), ''); assert.equal(lastAssistantText(undefined), ''); }); // ─── 轮次结论(D-3,两次适配都踩过)─── test('不变量:prompt 抛错判为失败', () => { // 实测:无凭证的 provider 让 prompt() reject(No API key found for // amazon-bedrock.),**一个事件都不发**。只看事件的话这轮会被当成没跑完。 const got = classifyTurnOutcome({ error: new Error('No API key found for amazon-bedrock.') }); assert.equal(got.ok, false); assert.match(got.error, /No API key/); }); test('不变量:一条 assistant 消息都没有判为失败', () => { // 判成功会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件, // 而不是错误说明。这是契约里 C-4「必须能区分成功与出错」的核心。 const got = classifyTurnOutcome({ messages: [{ role: 'user', content: [] }] }); assert.equal(got.ok, false); assert.match(got.error, /没有产出/); }); test('不变量:stopReason=error 判为失败并带出 errorMessage', () => { const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '半句' }], { stopReason: 'error', errorMessage: 'upstream 503 rate limited', })], }); assert.equal(got.ok, false); assert.equal(got.error, 'upstream 503 rate limited'); }); test('stopReason=error 但没给原因也要有话可说', () => { const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'error' })] }); assert.equal(got.ok, false); assert.ok(got.error, '失败原因不能是空串:renderFailureReport 会把它填进邮件'); }); test('正常收尾判为成功', () => { const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '好了' }])] }); assert.deepEqual(got, { ok: true, error: '', aborted: false }); }); test('不变量:length(被 max tokens 截断)判为成功', () => { // 内容不完整,但**是模型的产出**。判失败会让一封「说了一半」的回信 // 变成「换个模型重试」,那更糟 —— 用户什么都收不到。 const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '说了一半' }], { stopReason: 'length' })] }); assert.equal(got.ok, true); }); test('aborted 判为失败但标记 aborted', () => { // 有人主动打断(Esc / dispose),不是模型故障 —— 不该触发换模型重试 const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'aborted' })] }); assert.equal(got.ok, false); assert.equal(got.aborted, true); }); test('取最后一条 assistant 消息判定,不是第一条', () => { const got = classifyTurnOutcome({ messages: [ asst([{ type: 'text', text: '第一轮好的' }], { stopReason: 'stop' }), asst([], { stopReason: 'error', errorMessage: '第二轮炸了' }), ], }); assert.equal(got.ok, false); assert.equal(got.error, '第二轮炸了'); }); // ─── describeError ─── test('describeError 只取首行', () => { // 报错原文会被填进故障邮件的正文,多行堆栈会把那封信淹掉 assert.equal(describeError(new Error('炸了\n at foo (bar.js:1)')), '炸了'); assert.equal(describeError('单行错误'), '单行错误'); }); test('describeError 带上 code', () => { const e = new Error('connect failed'); e.code = 'ECONNREFUSED'; assert.equal(describeError(e), 'ECONNREFUSED: connect failed'); }); test('describeError 容错', () => { assert.equal(describeError(null), ''); assert.equal(describeError(undefined), ''); }); // ─── 提示词(B-3.4 / B-3.5)─── const mailData = { mail_id: 'm-1', from_name: 'admin', subject: '排查连接泄漏', to_workspace: '/home/program/agentmail', // 人类来信。**这一项不能省**:缺失时保守当作 Agent 来信,而两者的 // 提示词完全不同(人类才有自动转发)。 from_human: true, }; test('不变量:人类来信的提示词写明回信由桥自动发', () => { // 不说的话模型会自己调 send_mail,而桥在轮次结束时也会转发一次 —— // 同一件事两封邮件(生产里真实发生过)。 const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); assert.match(p, /回信不用你自己发/); }); test('不变量:Agent 来信的提示词必须改口(插件不代它回信)', () => { // Agent 之间两边都自动回信 = 无休止互相唤醒(实测 pi 与 dsh 客套 6 轮)。 const p = buildMailPrompt({ agentName: 'pi', data: { ...mailData, from_name: 'dsh', from_human: false }, kind: 'mail', reused: false, }); assert.doesNotMatch(p, /回信不用你自己发/, '那句话在这里是假的'); assert.match(p, /不会替你回信/); assert.match(p, /send_mail/); }); test('不变量:from_human 缺失时按 Agent 处理(不能承诺做不到的事)', () => { const { from_human, ...noFlag } = mailData; const p = buildMailPrompt({ agentName: 'pi', data: noFlag, kind: 'mail', reused: false }); assert.doesNotMatch(p, /回信不用你自己发/, '宁可让它多调一次 send_mail,也不能让发件方白等一个不会发生的自动回信'); }); test('不变量:回信到达时明说「不是新任务」', () => { // 把回复当新任务处理正是互相客套的起点。 const p = buildMailPrompt({ agentName: 'pi', data: { ...mailData, from_human: false, in_reply_to: 'm-0' }, kind: 'mail', reused: true, }); assert.match(p, /回复/); assert.match(p, /不是新任务/); assert.match(p, /m-0/, '要说出回的是哪封'); }); test('不变量:提示词带 mail_id 与 read_inbox 指引', () => { // 事件里只有主题,正文和附件清单都在收件箱里;不给 mail_id 模型无法定位这一封 const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); assert.match(p, /m-1/); assert.match(p, /read_inbox/); }); test('首封带身份,续谈不重复带', () => { const first = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); const again = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: true }); assert.match(first, /你是 pi/); assert.doesNotMatch(again, /你是 pi/); assert.match(again, /本会话/, '续谈用「本会话」而不是「你收到」'); }); test('补投的邮件在提示词里说明来源', () => { // 不说明的话模型会以为这是刚到的、按「立即响应」的语气回 const p = buildMailPrompt({ agentName: 'pi', data: { ...mailData, catchup: true }, kind: 'mail', reused: false, }); assert.match(p, /积压/); }); test('不变量:带上服务端算好的 reply_address', () => { // 模型确实会自己发信(要抄送第三方、或分多封交代不同的事)。 // 让它自己拼三维地址的话,`.new` 会被拼进去 —— 回信静默开出一条新会话, // 原来的线索里再无下文。服务端在 new_mail 里已经算好了这个地址。 const p = buildMailPrompt({ agentName: 'pi', data: { ...mailData, reply_address: 'admin@.排查连接泄漏' }, kind: 'mail', reused: false, }); assert.match(p, /admin@\.排查连接泄漏/); }); test('没有 reply_address 时不留空行占位', () => { const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false }); assert.doesNotMatch(p, /回信地址/); }); test('权限决策的提示词带决策与决策人', () => { const p = buildMailPrompt({ agentName: 'pi', data: { decision: '同意', decided_by: 'zhang' }, kind: 'permission', reused: true, }); assert.match(p, /同意/); assert.match(p, /zhang/); }); // ─── 幂等键 ─── test('relayKey 由会话 id 与叶子 id 组成', () => { assert.equal(relayKeyFor('sess-1', 'leaf-9'), 'sess-1:leaf-9'); }); test('不变量:叶子 id 缺失时仍产出稳定键', () => { // 返回空串会让服务端把 relay_key 当作「没给」,于是幂等失效、同一轮转两次 assert.equal(relayKeyFor('sess-1', null), 'sess-1:noleaf'); assert.equal(relayKeyFor('sess-1', undefined), 'sess-1:noleaf'); }); // ─── 续谈失败的回报正文 ───────────────────────────────────────────────── // // 这组来自一个真实缺口(2026-09-12):模型侧 402 时,**新会话**那条路会回 // 「处理失败」,而**续谈**那条路只写日志就 throw —— 发件人什么都收不到。 // 邮件驱动的会话没有本地界面,没有这封信就等于「信发出去了,然后再无音讯」。 // // 另有一个「文案不能撒谎」的点:共用库的 renderFailureReport 说 // 「划定范围内的模型全部调用失败」并建议「调整可用模型范围」—— // 那是新会话那条路的事实;续谈**故意不降级**,照抄会让人去调一个无效的旋钮。 test('★ 续谈失败回报:说清是续谈失败,并带上上游错误原文', () => { const body = renderResumeFailure('四桥冒烟 SMOKE4-x-pi', '402: Insufficient Balance'); assert.match(body, /续谈/); assert.match(body, /SMOKE4-x-pi/); assert.match(body, /402: Insufficient Balance/); }); test('★ 续谈失败回报不能说「范围内的模型都试过了」(那是另一条路的事实)', () => { const body = renderResumeFailure('主题', '上游错误'); assert.doesNotMatch(body, /划定范围内的模型全部调用失败/); assert.doesNotMatch(body, /调整可用模型范围/); // 反向对照:必须给出**这条路真正可行**的建议,而不是让人去改一个无效的旋钮 assert.match(body, /新建/); assert.match(body, /不会\*\*换用其它模型|不会\*\*换/); }); test('续谈失败回报在主题/错误缺失时也不崩', () => { const body = renderResumeFailure(undefined, undefined); assert.match(body, /\(无主题\)/); assert.match(body, /未知错误/); }); // ─── 「平台没指定模型」不能是静默的 ───────────────────────────────────── // // 这一组来自一次真实误判:pi.env 里 AGENTMAIL_REPLY_PROVIDER/MODEL 都是空的 // (opencode 那边钉了 llmsproxy/AUTO),于是 modelAttemptOrder 返回 [undefined] // —— 语义是「交给宿主 SDK 用它自己的默认模型」,而宿主的默认是 // /root/.pi/agent/settings.json 里的 deepseek/deepseek-v4-flash(**直连云、 // 余额为零**)。每封来信都 402,而日志里那句「模型 (平台默认) 失败」 // 让它读起来像「平台选的模型没钱了」。 // // 所以:判据要说出语义(宿主默认 ≠ 平台默认),且这种情况下必须有一条显式告警。 test('★ 平台未指定模型时走 [undefined](= 宿主默认),这是既有语义', async () => { const { modelAttemptOrder } = await import('../lib/model-scope.js'); // 范围空 + env 空 → [undefined] assert.deepEqual(modelAttemptOrder([], { provider: '', model: '' }), [undefined]); // 反向对照:任一侧有值就不该落到 undefined assert.deepEqual(modelAttemptOrder([{ provider: 'llmsproxy', model: 'AUTO' }], {}), [ { provider: 'llmsproxy', model: 'AUTO' } ]); assert.deepEqual(modelAttemptOrder([], { provider: 'llmsproxy', model: 'AUTO' }), [ { provider: 'llmsproxy', model: 'AUTO' } ]); }); test('★ 源码里不能把「宿主默认」写成「平台默认」', () => { // 这条是**形态断言**:文案错了不会报错,只会让下一个人误判半小时。 const src = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8'); // 只看**字符串字面量**:注释里为了解释「不要这么写」也会出现这几个字, // 一并禁掉会让这条断言变成一个必须靠改注释才能过的枷锁。 assert.doesNotMatch( src, /['"`]\(平台默认\)['"`]/, '标签不能写「平台默认」——那个模型不是平台选的' ); assert.match(src, /宿主默认/, '应当明确写出这是宿主侧的默认'); assert.match(src, /平台未指定模型/, '应当在日志里显式说明「平台未指定」'); });