fix(四桥)★★: parent_from 方向判据在 dsh/opencode 上是死代码 —— 接线补齐

## 起因:修部署漂移时撞出来的

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…
This commit is contained in:
2026-10-03 00:25:28 +08:00
parent 8894804dec
commit 1ea92098d1
7 changed files with 236 additions and 20 deletions

View File

@ -16,6 +16,20 @@ export function replyInstruction(ctx: ReplyInstructionCtx): string[];
export interface InboundHeadlineCtx {
inReplyTo?: string;
/**
* parentFrom 是**父邮件的发件人**(服务端 parent_from)。
*
* ★ 2026-10-02 补:`.js` 早就认这两个字段(b0c8719 加的),但这份手写声明
* 没跟上 ⇒ TypeScript 报 TS2353,调用方**不敢传**,于是那行方向判据
* 在本桥上一直是死代码:单向续信链同样满足 in_reply_to,被逐封宣称
* 「回的是你那封」,模型把它当新任务处理。pi 桥是唯一接了的。
*
* 光有声明不够 —— 调用点也得传,见 src/index.ts 的三处 inboundHeadline。
* 两者缺一,判据都等于没有。
*/
parentFrom?: string;
/** selfName 是本方名字(收件方自己);`parentFrom === selfName` 才是「我的回复到了」。 */
selfName?: string;
fromHuman: boolean;
catchup?: boolean;
reused?: boolean;

View File

@ -17,8 +17,8 @@
"test": "node --test 'test/*.test.mjs'"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-tools": "^0.1.0"
"@deepseek-ai/cordis": "~4.0.4",
"@deepseek-ai/dsh-tools": "0.2.0-rc.2"
},
"devDependencies": {
"@types/node": "^22.0.0",

View File

@ -1076,7 +1076,13 @@ export function apply(ctx: any, config: PluginConfig): void {
}
const fromHuman = data.from_human === true;
return [
inboundHeadline({ inReplyTo: data.in_reply_to, fromHuman, reused: true }),
inboundHeadline({
inReplyTo: data.in_reply_to,
parentFrom: data.parent_from,
selfName: AGENT_NAME,
fromHuman,
reused: true,
}),
``,
`发件人:${data.from_name || 'unknown'}`,
`主题:${data.subject || '(无主题)'}`,
@ -1190,8 +1196,16 @@ export function apply(ctx: any, config: PluginConfig): void {
const promptText = kind === 'permission'
? permissionPrompt(data)
: [
// ★ 方向判据(2026-10-02 补):b0c8719 给 inboundHeadline 加了
// parentFrom/selfName 用来分辨「回的是你那封」与「他人续谈」,
// 但**本桥从不传这两个参数** ⇒ 那行判据在这里是死代码。
// 实测:单向续信链(8 封全是对方发来的)同样满足 in_reply_to,
// 于是被逐封宣称「回的是你那封」,模型把它当新任务处理。
// pi 桥是唯一接了的(turn.mjs:148),此处按它的样板对齐。
inboundHeadline({
inReplyTo: data.in_reply_to,
parentFrom: data.parent_from,
selfName: AGENT_NAME,
fromHuman: data.from_human === true,
reused: true,
}),
@ -1241,6 +1255,8 @@ export function apply(ctx: any, config: PluginConfig): void {
: [
inboundHeadline({
inReplyTo: data.in_reply_to,
parentFrom: data.parent_from,
selfName: AGENT_NAME,
fromHuman: data.from_human === true,
catchup: data.catchup,
reused: false,

View File

@ -1142,6 +1142,12 @@ async function deliverMail(client, directory, data, kind) {
: [
inboundHeadline({
inReplyTo: data.in_reply_to,
// ★ 方向判据(2026-10-02 补):b0c8719 给 inboundHeadline 加了
// parentFrom/selfName,本桥却**从不传** ⇒ 那行判据是死代码。
// 单向续信链同样满足 in_reply_to,会被逐封宣称「回的是你那封」。
// pi 桥是唯一接了的(turn.mjs:148),此处按它的样板对齐。
parentFrom: data.parent_from,
selfName: AGENT_NAME,
fromHuman,
catchup: data.catchup,
reused,

View File

@ -42,7 +42,10 @@ const PLUGINS = join(HERE, '..', '..');
const IMPL = {
'dsh-mail-bridge': ['src'],
'pi-mail-bridge': ['src'],
'zcode-mail-bridge': ['src', 'mcp', 'hooks'],
// ★ 2026-10-02:d09ef39 删掉了 zcode 的 mcp/ 与 hooks/(MCP 已内置网关,
// 执行门禁随之退场)。留着这两个目录名 ⇒ stat 抛 ENOENT ⇒ 整档红,
// 而且是「文件不存在」这种红,会盖住真正要看的判据失败。
'zcode-mail-bridge': ['src', 'lib'],
'opencode-mail-bridge': ['index.js'],
};
@ -87,10 +90,16 @@ const BRIDGES = [
},
{
plugin: 'zcode-mail-bridge',
allow: /permission_mode \|\| ''\) === 'full'/,
mustWriteBack: null,
permanent: 'if (isPermanentFailure(e))',
note: 'hooks/permission.mjs 的 409 分支(该桥没有 sandbox/approval 旋钮)',
allow: /normalizeMode\(data\?\.permission_mode\)/,
allow: /normalizeMode\(data\?\.permission_mode\)/,
// ★ zcode 整条 409 分支都不存在(d09ef39 移除执行类工具后不再向人问权限),
// 所以「放行必须排在永久失败之前」对它**不适用**:没有 permanent 分支,
// 也就没有顺序可排。置 null = 本桥不参与该子判据,不是「跳过了检查」。
permanent: null,
// ★ zcode 已无 409 分支:d09ef39 移除执行类工具后,src/index.mjs:232 的
// modeReachesPermissionHook 只是提醒(本平台没有执行面),不存在「按
// workspace 档问人」这条路。此处只钉住它仍读 permission_mode 并归一档位。
note: 'src/index.mjs:219 的 normalizeMode(data?.permission_mode)',
},
{
plugin: 'opencode-mail-bridge',
@ -127,6 +136,17 @@ test('★ 四桥都必须认出「409 带 full → 放行」,且排在永久
`${b.plugin} 有会话档位旋钮,所以 409 里的权威档位还必须**写回会话**:` +
'只放行修的是"问不问",沙箱(能不能)还是窄的。');
}
if (b.permanent === null) {
// 本桥没有 409 分支 ⇒ 顺序问题不适用。但仍要确认它**真的**没有,
// 而不是路径/正则过时了:出现任一个就说明前提变了,该改判据。
// 只扫它自己的 src/:isPermanentFailure 是 lib/relay-key.js 里的**通用**
// 网关辅助(所有桥都有),与「409 权限放行分支」无关。全量扫会误判 ——
// 踩过一次:把 lib 加进 IMPL 后,这格对着共享辅助函数报红。
assert.doesNotMatch(readImpl(b.plugin).split('lib/')[0], /isPermanentFailure/,
`${b.plugin} 声明「无 409 分支」但 src/ 里出现了 isPermanentFailure —— ` +
'前提变了,别让这格继续空转');
continue;
}
const allowAt = src.search(b.allow);
const permAt = src.indexOf(b.permanent);
assert.ok(permAt >= 0,
@ -144,6 +164,10 @@ test('★ 判据自检:把放行那一支挪到永久失败分支之后,顺
return a >= 0 && p >= 0 && a < p;
};
for (const b of BRIDGES) {
// ★ 2026-10-02:permanent === null 的桥(zcode)没有 409 分支,顺序无从谈起。
// 原来这里直接 indexOf(null) ⇒ 把 null 当成字符串找不到 ⇒ 前置恒红 ——
// 一条判据自己制造的红。跳过前仍要过一遍上面那格,确认「无分支」是真的。
if (b.permanent === null) continue;
const src = codeOnly(readImpl(b.plugin));
assert.ok(orderHolds(src, b), `前置:${b.plugin} 当前顺序正确,才谈得上"挪动"`);
const a = src.search(b.allow);
@ -173,7 +197,11 @@ test('★ 配对:消费者读的档位键名,生产者必须产出(四平
['pi', 'pi-mail-bridge/src/worker.mjs'],
['dsh', 'dsh-mail-bridge/src/index.ts'],
['opencode', 'opencode-mail-bridge/index.js'],
['zcode', 'zcode-mail-bridge/hooks/permission.mjs'],
// ★ 2026-10-02 修路径(不是删判据):d09ef39 删掉 zcode 的本地 MCP 与整套
// 执行门禁(含 hooks/permission.mjs),zcode 改为在 src/index.mjs:219
// 读档位。能力没丢,只是搬了家;原路径不存在 ⇒ 4 个 subtest 全是
// ENOENT,白烧 4 个红且会掩盖真实失败。判据仍要求它登记。
['zcode', 'zcode-mail-bridge/src/index.mjs'],
];
for (const [who, rel] of CONSUMERS) {
const text = codeOnly(readFileSync(join(PLUGINS, rel), 'utf8'));

View File

@ -215,17 +215,50 @@ test('不变量:from_human 缺失时按 Agent 处理(不能承诺做不到
'宁可让它多调一次 send_mail,也不能让发件方白等一个不会发生的自动回信');
});
test('不变量:回信到达时明说「不是新任务」', () => {
// 把回复当新任务处理正是互相客套的起点。
const p = buildMailPrompt({
agentName: 'pi',
data: { ...mailData, from_human: false, in_reply_to: 'm-0' },
kind: 'mail',
reused: true,
});
assert.match(p, /回复/);
assert.match(p, /不是新任务/);
assert.match(p, /m-0/, '要说出回的是哪封');
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 指引', () => {