From 018d5b3bd89046fa84dc3a908a8c10c4085dc43b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 28 Sep 2026 10:17:09 +0800 Subject: [PATCH] =?UTF-8?q?test(=E6=A1=A5):=20=E5=9B=9B=E4=B8=AA=E6=A1=A5?= =?UTF-8?q?=E7=9A=84=20relay-policy=20=E5=BF=85=E9=A1=BB=E9=80=90=E5=AD=97?= =?UTF-8?q?=E7=9B=B8=E5=90=8C=20=EF=BC=8B=20=E9=92=89=E4=BD=8F=20in=5Frepl?= =?UTF-8?q?y=5Fto=20=E7=BC=BA=E6=96=B9=E5=90=91=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增 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)。这条债说的就是这类形状。 --- .../test/cross-bridge-prompt.test.mjs | 168 ++++++++++++++++++ client/electron/test/run-all.mjs | 16 ++ 2 files changed, 184 insertions(+) create mode 100644 client/electron/test/cross-bridge-prompt.test.mjs diff --git a/client/electron/test/cross-bridge-prompt.test.mjs b/client/electron/test/cross-bridge-prompt.test.mjs new file mode 100644 index 0000000..d1603f8 --- /dev/null +++ b/client/electron/test/cross-bridge-prompt.test.mjs @@ -0,0 +1,168 @@ +/* + * 四个邮件桥的**提示词策略一致性**判据:`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') + ); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 91106af..7962fcb 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -100,6 +100,22 @@ const SUITE = [ * 它就是靠这个数字把"新加的判据已经进保护圈"这件事说清楚的。 */ ['test/cross-client-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 7], + /* + * ★★ 2026-09-28 新增(显式编辑,手改数字见上面的纪律)。 + * + * 四个邮件桥各存一份 `relay-policy.js`(手抄副本),本次**逐字比对**它们。 + * 与 `cross-client-logic` 的区别:那边是比**行为**(两套类型系统,只能跑表), + * 这边是比**字节**(同一个 node 运行时下的四份 JS 拷贝,没有语言差异要归一)。 + * + * 为它登记的债:`in-reply-to-ignores-direction` —— `inboundHeadline` 只问 + * 「有没有父邮件」、不问「父邮件是不是我发的」,于是一条 8 封全单向的压测链 + * 被逐封读成「对方在回我」,Agent 把「收到」当新任务,客套到撞 hop 上限。 + * 修那一族要改四个地方,**漏一个就会让分叉活到线上** —— 这条判据就是防那个。 + * + * ★ 5 条中 4 条当前绿、1 条**故意红**(方向判据那条):判据先写好, + * 修完转绿。不写就永远没人知道还欠着 —— 与本仓「先钉判据再修」一致。 + */ + ['test/cross-bridge-prompt.test.mjs', [], 5], // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 ['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],