Files
MailUI4Agents/client/electron/test/cross-bridge-prompt.test.mjs
JianFeeeee 018d5b3bd8 test(桥): 四个桥的 relay-policy 必须逐字相同 + 钉住 in_reply_to 缺方向判据
## 新增 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)。这条债说的就是这类形状。
2026-09-28 10:17:09 +08:00

169 lines
8.3 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.

/*
* 四个邮件桥的**提示词策略一致性**判据:`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')
);
});