## 新增 client/electron/test/cross-bridge-prompt.test.mjs(5 条,登记进 SUITE)
`relay-policy.js` 是 pi/dsh/zcode/opencode **各存一份的手抄副本**(当前
四份 md5 相同),它直接决定提示词里对模型说的话。修 `in_reply_to` 那一族
要改四个地方,**漏一个就会让分叉活到线上**。本判据就是防那个。
与 `cross-client-logic` 的分工:那边比**行为**(electron/harmony 两套类型
系统,只能跑同一张表比结果);这边比**字节**(同一个 node 运行时下的四份
JS 拷贝,没有任何语言差异要归一 ⇒ 字节相等是最便宜也最严格的判据)。
枚举挡实例、行为判据挡漂移,两者配对。
## 5 条的形状
· 4 条绿:四份 `relay-policy.js` + 四份 `relay-policy.test.mjs` 逐字相同,
且四个桥都存在(少一个即部署事故,当场红)。
★ 已实测它**有牙**:往 dsh 那份尾部加一行注释,判据立刻红并指名
`✗ dsh sha12=…`(不是笼统说"有分叉"),随后已还原、四份 md5 复验一致。
· 1 条**故意红**:`inboundHeadline` 不得只凭 `inReplyTo` 非空就宣称
「你上一封信的回复到了」—— 按形状断言(找方向判据字段),不点名实现。
这条红的就是 `docs/DEBTS.json` 的 `in-reply-to-ignores-direction`:
压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`,
投递通知却逐封宣称「回的是你那封:<上一封的 id>」,而没有任何一封是
pi 发出的。单向续信链同样满足「有父邮件」⇒ 纯单向的链被读成双向对话。
★ 判据先写好、修完转绿,不写就永远没人知道还欠着 —— 与本仓
「先钉判据再修」一致;到期动作不是「在提示词里写清楚」(本次已证明
写清楚没用:通知里逐字写着那句,模型照样每封去核一遍再被带着走)。
## 验证
· `node test/run-all.mjs`:files=35 ran=35 checks=582 pass=571 fail=2
**skip=9(不是通过)** red=2。
两个红:① 本文件那条故意红的;② `build-stamp`(BUILD_INFO 记 87c55ac
vs HEAD 359cb43)—— ★ **已在干净 HEAD 上复现,不是本次引入**,
与 `shared-workspace-unserialized-deploy` 同一形状(产物与源码分家)。
· 手改 SUITE 登记数字 5(套件只判下界,不手改将来删掉就不红)。
· go 侧 16/16 包绿(须 `GOCACHE=.tmp/gocache`,默认 `/root/.cache/go-build`
权限被拒)。
## 我自己踩的一个坑(第一次跑就撞上,已修)
初版裸 `readFileSync` ⇒ `criteria-hygiene` 第 2 条红
(「判据目录里不得出现裸 readFileSync」)。已改走 `prose()`。
★ 讽刺处:**本判据主题正是"手抄副本会分叉"**,而我第一版就制造了
一处新分叉(多写一个 import)。这条债说的就是这类形状。
169 lines
8.3 KiB
JavaScript
169 lines
8.3 KiB
JavaScript
/*
|
||
* 四个邮件桥的**提示词策略一致性**判据:`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 改成同时要求「有父邮件」且「父邮件由本方发出」。',
|
||
'',
|
||
'注意:提示词里已经逐字写着「回的是你那封:<id>」也救不了 ——',
|
||
'本次已证明,模型会去核、核完照样被这条结构化判据带着走。',
|
||
].join('\n')
|
||
);
|
||
});
|