## 起因:修部署漂移时撞出来的
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…
400 lines
17 KiB
JavaScript
400 lines
17 KiB
JavaScript
/**
|
||
* 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, /平台未指定模型/, '应当在日志里显式说明「平台未指定」');
|
||
});
|