Files
MailUI4Agents/plugins/pi-mail-bridge/test/turn.test.mjs
JianFeeeee 630b5bfdd7 fix(bridges): 续谈失败静默 + duplicate_relay 静默挂死(两个都是「人那边什么都收不到」)
同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。

## 1)pi 的续谈失败不回失败信(实测缺口)

模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。

修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。

**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。

顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。

## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策

网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。

- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
  `DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
  那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
  (各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。

## 3)新增接线断言(带判据自检)

`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。

三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
  「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
  改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
  第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。

而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。

## 4)部署脚本:zcode 分支现在会重启驱动

`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。

## 复查

四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。

另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
2026-09-12 23:13:48 +08:00

324 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。
*
* 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的
* 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
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() rejectNo 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, /未知错误/);
});