Files
MailUI4Agents/plugins/pi-mail-bridge/test/turn.test.mjs
JianFeeeee 1ea92098d1 fix(四桥)★★: parent_from 方向判据在 dsh/opencode 上是死代码 —— 接线补齐
## 起因:修部署漂移时撞出来的

b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName`,
用来分辨「回的是你那封」与「多方线索里的他人续谈」——单向续信链(8 封全是
对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」,
模型把它当新任务处理。**但只有 pi 桥接了**:

    pi        parentFrom: data?.parent_from    ✓(turn.mjs:148)
    dsh       从不传                        ✗ 3 处
    opencode  从不传                        ✗ 1 处

判据函数是三个桥**同一份** relay-policy.js 的拷贝,看起来测过了 ——
但「函数支持」≠「调用方传了」。三个桥各自都有 relay-policy 单元测试且全绿,
因为它们只测那个**纯函数**,没有一个测「桥真的把参数传进去了」。

dsh 还多一层:它有一份**手写**的 `lib/relay-policy.d.ts`,缺这两个字段
⇒ tsc 报 TS2353 ⇒ 调用方即使想传也过不了类型检查。那行判据是被类型系统
**主动拦住**的死代码。只补 .js 不补 .d.ts 等于没补。

## 改动

dsh 3 处 + opencode 1 处调用点补 `parentFrom`/`selfName`,对齐 pi 的样板;
dsh 的 `.d.ts` 补声明(注释写明「光有声明不够,调用点也得传」)。

## 新增判据 deploy/check-parent-from.mjs(7 格)

不只是查「有没有传」,还查**三处曾经不一致的接缝**:

  ① 每个 inboundHeadline 调用点都传了 parentFrom(次数写死,新增调用点
     忘了传要能被发现,而不是被 `>0` 静默接受)
  ② 传了 parentFrom 就必须传 selfName —— 只传前者时
     `parentFrom === selfName` 永不成立,判据形同虚设
  ③ 三份 relay-policy.js 都真的有 `parentFrom === selfName`
  ④ dsh 的 .d.ts 声明与实现一致(否则 tsc 拦住,判据等于没有)

**变异验证**:撤一处传参 → 红 2;只撤 selfName → 红 1;撤 .d.ts 声明 → 红 1。

## ★ 判据自身也踩了两次坑(都已修)

1. 初版 `.d.ts` 那格用 `\bparentFrom\b` 全文件搜 ⇒ 撤掉字段声明后,
   **注释文字里还留着这个词** ⇒ 照样匹配 ⇒ 变异不红(假绿)。
   改为只认字段声明 `^\s*parentFrom\??\s*:\s*string\s*;`。
2. 初版 `.mjs` 写了 `require` ⇒ ReferenceError(ESM)。已改 import。

## 顺带修掉 6 个陈旧判据(都在 HEAD 上、此前静默红着)

`turn.test.mjs` 的「回信到达时明说不是新任务」:只造 `in_reply_to` 却断言
`/m-0/`,而 `b0c8719` 新增的「我的回复」分支文案里压根不含父邮件 ID ——
判据没跟上那次改动。已改成覆盖三种情形(parent_from 是我 / 是别人 / 缺席)。

★ 我第一版改完是**假绿**:只断言 `/续谈/` 时,把 `mine` 分支改坏也会走
「续谈」分支而照样通过。加反向断言(mine 不含「续谈」、theirs 不含
「不是新任务」)后才抓住。变异 A/B 各 35 pass / 1 fail。

`cross-bridge-permission-routing.test.mjs`:zcode 的路径与目录还指向
`d09ef39` 已删的 `hooks/`、`mcp/` ⇒ ENOENT。zcode 确实**没有** 409 分支了
(移除执行类工具所致,非缺陷)⇒ `permanent: null` 表示不适用,并加反向
守卫:若 `src/` 里出现 `isPermanentFailure` 就红(防止前提变了却继续空转)。
`isPermanentFailure` 是 `lib/relay-key.js` 的通用网关辅助,与权限放行无关 ——
踩过一次:把 `lib` 加进扫描范围后对着共享辅助函数误报。

全量:pi 533 / opencode 364 / dsh 433,零红(此前 4 红)。

## 部署与端到端

pi / opencode / dsh 三个宿主均已部署;dsh 已真实收发验证:

    [dsh-mail-bridge] 本轮不自动转发:来信方 pi 是 Agent…
    [dsh-mail-bridge] new_mail -> 新会话 mail-8e8bf319…
2026-10-03 00:25:28 +08:00

400 lines
17 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 { 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('不变量:回信到达时明说「不是新任务」', () => {
// 把回复当新任务处理正是互相客套的起点。
//
// ★ 2026-10-02 修判据(不是修代码):这格原本只造 in_reply_to,于是走进
// `parentFrom === selfName` 的 **mine** 分支,而那行文案压根不含父邮件
// ID ⇒ 断言 /m-0/ 必然失败。它是 b0c8719 加方向判据时漏改的陈旧判据
// (实测:HEAD 上就是红的,且 pi 当时一个字节都没动 ⇒ 不是回归)。
//
// 三种情形都要覆盖,因为它们走的是**不同分支、文案不同**:
// · parent_from === 我自己 ⇒ 我的回复到了(只说不是新任务)
// · parent_from === 别人 ⇒ 多方线索里的续谈(要说回的是谁的)
// · 无 parent_from ⇒ 服务端未升级,退回旧行为
const mine = buildMailPrompt({
agentName: 'pi',
data: { ...mailData, from_human: false, in_reply_to: 'm-0', parent_from: 'pi' },
kind: 'mail',
reused: true,
});
assert.match(mine, /回复/);
assert.match(mine, /不是新任务/);
// ★ 反向对照:这格原来是假绿 —— 只断言 /续谈/ 时,
// 把 mine 分支改坏(mine=false)也会走「续谈」分支而照样通过。
// 所以必须反向断言 mine **不是**续谈文案,否则这格没有区分力。
assert.doesNotMatch(mine, /续谈/, '方向是我的就不该说成续谈');
const theirs = buildMailPrompt({
agentName: 'pi',
data: { ...mailData, from_human: false, in_reply_to: 'm-0', parent_from: 'opencode' },
kind: 'mail',
reused: true,
});
assert.match(theirs, /续谈/);
assert.match(theirs, /opencode/, '★ 方向不对时必须说清回的是谁的信');
// ★ 同理:他人续谈**不是**我的回复,不该说成「不是新任务」
// (那会把别人的话当成我上一封信的回音,正是要避免的误判)。
assert.doesNotMatch(theirs, /不是新任务/, '他人续谈不是我的回信');
const legacy = buildMailPrompt({
agentName: 'pi',
data: { ...mailData, from_human: false, in_reply_to: 'm-0' },
kind: 'mail',
reused: true,
});
assert.match(legacy, /不是新任务/, '服务端未升级时仍要明说不是新任务');
});
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, /平台未指定模型/, '应当在日志里显式说明「平台未指定」');
});