/* * 四个邮件桥的**提示词策略一致性**判据:`relay-policy.js` 的四份拷贝必须逐字相同。 * * # 为什么需要它 * * `relay-policy.js` 是四个桥各存一份的手抄副本(pi / dsh / zcode / opencode)。 * 它直接决定提示词里对模型说的话,而提示词写错的后果是**模型被误导去做无用功**, * 且**没有任何东西会红** —— 服务照常 active、投递照常成功、测试全绿。 * * 2026-09-28 登记的 `in-reply-to-ignores-direction`(`docs/DEBTS.json`)就是 * 这一族的一个实例,但那次是**运行时**暴露的: * 压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`, * 投递通知却逐封宣称「回的是你那封:<上一封的 id>」—— * 因为 `inboundHeadline` 只问「有没有父邮件」,不问「那封父邮件是不是我发的」。 * 单向续信链同样满足「有父邮件」⇒ 一条纯单向的链被读成了双向对话, * 代价是每个 Agent 把一句「收到」当成新任务,客套到撞 hop 上限。 * * 那次能定位到手,靠的是人肉比对四份文件。而**比对是可以自动化的**: * 修 `in_reply_to` 的方向判据时要改四个地方,漏一个就会让 * 「pi 已修、dsh 未修」这种分叉活到线上。 * * ★ 这正是本文件存在的意义:**它不验证行为对不对,只验证四份拷贝没分叉。** * 行为对不对由各桥自己的 `relay-policy.test.mjs` 负责(那四份也各存一份, * 本文件同样管它们的同步)。两者分工:一个挡实例,一个挡漂移。 * * # 为什么用「逐字比对」而不是「跑同一张表比结果」 * * `cross-client-logic.test.mjs` 对 electron/harmony 用的是跑表比结果,因为 * 那是**两套不同类型系统的实现**(ArkTS 禁解构/禁 any/禁对象字面量), * 只能比行为、不能比文本。 * * 而这四个桥是**同一个 node 运行时下的四份 JS 拷贝**,没有任何语言差异 * 需要归一(`cross-client-logic` 里那三类「不同」在这里一类都不存在: * 没有命名差异、没有签名差异,因为它们是复制粘贴的)。 * 于是最便宜也最严格的判据就是**字节相等** —— * 一旦有人改了其中一个而没改另外三个,立刻红,且直接指名是哪几个不一致。 * * # 为什么钉住「这四个」而不是扫描 plugins/* * * 目录扫描会把将来新增的桥(哪怕它有意做出与别人不同的策略)也拖进来, * 那样这条判据就变成了「不许有差异」这种过宽的断言。 * 显式列出这四个,并断言它们**都存在** —— 少一个就是部署事故,当场红。 */ import { code, prose } from './lib/read.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { dirname, join } from 'node:path'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..', '..'); /** * 四份手抄副本。顺序固定,便于报错时对照。 * `src` 是插件的**子目录名**,不是 Agent 名 —— 别按 Agent 名去拼路径。 */ const BRIDGES = [ { agent: 'pi', src: 'pi-mail-bridge' }, { agent: 'dsh', src: 'dsh-mail-bridge' }, { agent: 'zcode', src: 'zcode-mail-bridge' }, { agent: 'opencode', src: 'opencode-mail-bridge' }, ]; /** 要比对的文件:策略实现 + 它的判据。两份都要同步。 */ const FILES = ['lib/relay-policy.js', 'test/relay-policy.test.mjs']; /** * 读文件。★ 走 `prose()` 而不是裸 `readFileSync` —— 本文件**整行整行都在讲道理**, * 而本仓有条判据「判据目录里不得出现裸 readFileSync」(`criteria-hygiene.test.mjs`): * 裸用会被它扫出来而红。这里比的是**原文**(含注释),所以取 `prose` 而非 `code`。 */ function read(bridge, rel) { const p = join(ROOT, 'plugins', bridge.src, rel); try { return prose(p); } catch (e) { // 缺文件本身要报得清楚:是「这个桥没这个文件」还是「读权限/路径写错」。 throw new Error(`读不到 ${p}:${e instanceof Error ? e.message : String(e)}`); } } function sha(text) { return createHash('sha256').update(text).digest('hex').slice(0, 12); } // ─── 存在性:少一个桥就是部署事故,必须当场红 ─── for (const rel of FILES) { test(`四个桥都有 ${rel}`, () => { for (const b of BRIDGES) { const src = read(b, rel); assert.ok(src.length > 0, `${b.agent} 的 ${rel} 是空文件`); } }); } // ─── 逐字一致:修一处不改其余三处 ⇒ 红 ─── for (const rel of FILES) { test(`${rel} 四份拷贝逐字相同`, () => { const byAgent = BRIDGES.map((b) => ({ agent: b.agent, text: read(b, rel) })); const base = byAgent[0]; const diverged = byAgent.filter((x) => x.text !== base.text); assert.equal( diverged.length, 0, diverged.length === 0 ? '' : [ `${rel} 已分叉:以 ${base.agent} 为准,sha12=${sha(base.text)}`, ...diverged.map((x) => ` ✗ ${x.agent} sha12=${sha(x.text)}`), '', '改这一族文件时四个桥要一起改(pi/dsh/zcode/opencode)。', '本仓已有两笔同形的债:044a664(注释说修了而代码没改)、', 'd3a7873(判据说干净而构建物不干净)—— 都是「一句话与事实分家,且没有任何东西会红」。', '若某个桥**有意**与其它桥不同,那本条判据不适用,请在下面把理由登记成显式例外,', '不要靠改测试来消红。', ].join('\n') ); }); } // ─── 方向判据的形状(按形状断言,不点名)─── // // `in-reply-to-ignores-direction` 的修法要么在 relay-policy 里加方向判据, // 要么在服务端补 `parent_from` 后由 relay-policy 使用。两种落法本文件都不预判, // 只钉住一件与落法无关的事:**不许只凭 `inReplyTo` 非空就断言「这封是对我的回复」**。 // // 下面这条在当前(未修)代码上**是红的** —— 它就是这条债的判据。 // 它故意现在就红:判据先写好,修好就转绿;不写就永远没人知道还欠着。 // ★ 这与本仓纪律一致:「先钉判据再修」,而不是「修完再补一句说明」。 test('inboundHeadline 不得只凭 inReplyTo 非空就宣称「你上一封信的回复到了」', () => { const src = read(BRIDGES[0], 'lib/relay-policy.js'); // 取出 inboundHeadline 的函数体(到下一个顶层 `export` 或文件尾) const start = src.indexOf('export function inboundHeadline'); assert.ok(start > -1, 'relay-policy.js 里找不到 inboundHeadline'); const rest = src.slice(start); const nextExport = rest.indexOf('\nexport ', 1); const body = nextExport > -1 ? rest.slice(0, nextExport) : rest; // 判据(按形状,不点名):若要宣称「回复到了」,必须还有一个方向判据参与。 // 这里要求:inboundHeadline 的实现里出现过「父邮件发件人」相关的判据字段。 const hasDirectionGuard = /parent_?from|parentFrom|inReplyToFromMe|fromMe|isMine|parentAuthor/.test(body); assert.ok( hasDirectionGuard, [ 'inboundHeadline 仍然只问「有没有父邮件」,不问「那封父邮件是不是我发的」。', '', '后果(已在生产兜现,见 docs/DEBTS.json 的 in-reply-to-ignores-direction):', ' 压测线索 stress-thread-21863-15348 里 8 封全是 opencode → pi,', ' 投递通知却逐封宣称「回的是你那封:<上一封的 id>」——', ' 没有任何一封是 pi 发出的。单向续信链同样满足「有父邮件」,', ' 于是一条纯单向的链被读成了双向对话。', '', '修法(服务端补 parent_from 字段更便宜:不用每封多一次 read_mail 往返):', ' inboundHeadline 改成同时要求「有父邮件」且「父邮件由本方发出」。', '', '注意:提示词里已经逐字写着「回的是你那封:」也救不了 ——', '本次已证明,模型会去核、核完照样被这条结构化判据带着走。', ].join('\n') ); });