Files
MailUI4Agents/plugins/dsh-mail-bridge/test/relay-policy.test.mjs
dsh b0c87192b0 fix(notify+四桥): ★ 投递通知带 parent_from(方向判据)—— in-reply-to-ignores-direction 转绿
为什么这次顺手修:网关部署门禁(redeploy-gateway.sh 跑全量 Go 测试)被
TestInReplyToCarriesParentSender 拦下 —— 那是 2026-09-28 判据先行的债,
死锁修复本身无涉,但不修它网关换不上去。已用 git worktree 在修复前的
HEAD(16bf474)上验证过该测试原本就红,不是本次改动引入。

服务端(数据本来就在手,零新增查询):
  · resolveTarget 的 reply_to 分支原本把父邮件整行读进内存、只用 SessionID
    就丢掉;现在把 mail.FromName 一并返回。
  · notify.Mail 增 ParentFrom;payload 增 "parent_from"。
  · 转发 / 人类发信(me.go)路径如实传 ""(转发本就是新线索)。

四桥(relay-policy.js 四份逐字相同的拷贝 + 各自调用点):
  · inboundHeadline 增方向判据:parentFrom === selfName 才说
    「你上一封信的回复到了」;parentFrom 非空但≠自己 ⇒ 明说
    「多方线索里的续谈(回的那封是 X 发的)」;服务端未升级(无
    parent_from)⇒ 退回旧行为(含糊的「回复到了」强于把真回复当新任务
    —— 那是互相客套的起点,回退语义被既有判据钉死)。
  · 「回的是你那封:<id>」一行同样只在父邮件确为本方发出时才输出。
  · 四份 lib + 四份 test 逐一 md5 相同(cross-bridge-prompt 1/2/3/4 继续绿),
    判据 5 转绿。

红绿:
  · 服务端 TestInReplyToCarriesParentSender 修复前红(16bf474 实测)、修复后绿;
  · 四桥新增 3 条方向判据测试(别人发的 / 自己发的 / 未升级回退);
  · client/electron 聚合套件 cross-bridge-prompt 5/5 绿。
2026-09-30 17:30:27 +08:00

160 lines
6.7 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.

/**
* 自动转发适用范围的判定(lib/relay-policy.js)。
*
* 三个函数是同一件事的三个出口,必须一起看:
* - autoRelayDecision 插件该不该替模型把结论发出去
* - replyInstruction 提示词里怎么跟模型说这件事
* - inboundHeadline 进来的这封是新活、是回复、还是补投
*
* 分开写必然分叉,而分叉的代价是模型被骗:以为插件会替它回信,于是把话说完
* 就停手,那封信却永远不会发出去。所以这里逐条钉住它们的一致性。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
addrName,
autoRelayDecision,
replyInstruction,
inboundHeadline,
} from '../lib/relay-policy.js';
// ─── addrName ───
test('addrName 取三维地址的名字段', () => {
assert.equal(addrName('pi@/home/program/agentmail.某别名'), 'pi');
assert.equal(addrName('jianf'), 'jianf');
assert.equal(addrName(' dsh@/x '), 'dsh');
assert.equal(addrName(''), '');
assert.equal(addrName(undefined), '');
});
// ─── autoRelayDecision ───
test('人类来信 → 自动转发', () => {
const d = autoRelayDecision({ fromHuman: true, replyTo: 'jianf' });
assert.equal(d.relay, true);
});
test('Agent 来信 → 不自动转发', () => {
const d = autoRelayDecision({ fromHuman: false, replyTo: 'dsh' });
assert.equal(d.relay, false,
'Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒');
assert.match(d.reason, /dsh/, '日志要说清是谁');
assert.match(d.reason, /Agent/);
});
test('不知道回给谁 → 不转发,且理由与「对方是 Agent」区分得开', () => {
const d = autoRelayDecision({ fromHuman: true, replyTo: '' });
assert.equal(d.relay, false);
assert.match(d.reason, /不知道回给谁/,
'「本轮没有回信」有三种原因,日志里必须能分辨');
});
test('replyTo 带三维地址时也能认出 Agent 名', () => {
const d = autoRelayDecision({ fromHuman: false, replyTo: 'opencode@/home/x.别名' });
assert.equal(d.relay, false);
assert.match(d.reason, /opencode/);
});
test('缺省参数不抛错(畸形事件不该弄死投递)', () => {
assert.equal(autoRelayDecision().relay, false);
assert.equal(autoRelayDecision({}).relay, false);
});
// ─── replyInstruction 与 autoRelayDecision 的一致性 ───
test('人类来信的提示词承诺「插件会替你发」,且这与决策一致', () => {
const lines = replyInstruction({ fromHuman: true });
const text = lines.join('\n');
assert.match(text, /回信不用你自己发/);
assert.equal(autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }).relay, true,
'承诺了就必须真的做');
});
test('Agent 来信的提示词必须明说「插件不会替你回信」', () => {
const text = replyInstruction({ fromHuman: false }).join('\n');
assert.match(text, /不会替你回信/);
assert.match(text, /send_mail/, '必须给出唯一可行的做法');
assert.doesNotMatch(text, /回信不用你自己发/,
'这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手');
});
test('Agent 来信的提示词要劝阻纯客套', () => {
const text = replyInstruction({ fromHuman: false }).join('\n');
assert.match(text, /收到|确认/, '要点名那种没有信息量的回复');
assert.match(text, /互相客套|无休止/, '要说清后果,否则模型不知道为什么被劝阻');
});
test('Agent 来信时把回信地址带进提示词(有就带)', () => {
const withAddr = replyInstruction({ fromHuman: false, replyAddress: 'dsh@/x.别名' }).join('\n');
assert.match(withAddr, /dsh@\/x\.别名/,
'要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话');
const without = replyInstruction({ fromHuman: false }).join('\n');
assert.doesNotMatch(without, /(回信地址:)/, '没有地址时不该留一个空括号');
});
// ─── inboundHeadline ───
test('回复到了 → 明说「这不是新任务」', () => {
const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false });
assert.match(h, /回复/);
assert.match(h, /不是新任务/,
'把回复当新任务处理正是互相客套的起点');
});
test('回复的标题优先于续谈/补投标记', () => {
const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: true, reused: true, catchup: true });
assert.match(h, /回复/, 'in_reply_to 是最强信号');
});
test('Agent 来信在标题里就标出来', () => {
assert.match(inboundHeadline({ fromHuman: false }), /Agent/);
assert.doesNotMatch(inboundHeadline({ fromHuman: true }), /Agent/,
'人类来信不该带这个括号 —— 那是噪音');
});
test('补投要说明,否则模型按「刚到的」语气回', () => {
const h = inboundHeadline({ fromHuman: true, catchup: true });
assert.match(h, /积压|补投/);
});
test('续谈与新会话的措辞不同', () => {
assert.match(inboundHeadline({ fromHuman: true, reused: true }), /本会话/);
assert.match(inboundHeadline({ fromHuman: true, reused: false }), /你收到/);
});
test('★ 方向判据:父邮件是别人发的 ⇒ 不是「你上一封信的回复到了」(in-reply-to-ignores-direction)', () => {
// 生产兜现:压测线索 stress-thread-21863-15348,8 封全是 opencode → pi,
// pi 的投递通知却逐封宣称「回的是你那封」—— 没有一封是 pi 发出的。
// 单向续信链同样满足「有父邮件」⇒ 纯单向链被读成双向对话,Agent 把
// 「收到」当新任务客套到撞 hop 上限。
const h = inboundHeadline({
inReplyTo: 'm-1', parentFrom: 'opencode', selfName: 'pi', fromHuman: false,
});
assert.doesNotMatch(h, /你上一封信的\*\*回复\*\*到了/,
'父邮件是 opencode 发的,pi 不该被告知「你的回复到了」');
assert.match(h, /opencode/, '要说清父邮件是谁发的,模型才能判断处境');
});
test('★ 方向判据:父邮件是自己发的 ⇒ 仍是「回复到了」', () => {
const h = inboundHeadline({
inReplyTo: 'm-1', parentFrom: 'pi', selfName: 'pi', fromHuman: false,
});
assert.match(h, /回复/);
assert.match(h, /不是新任务/);
});
test('★ 方向判据:服务端未升级(无 parent_from)⇒ 退回旧行为,不比原来更差', () => {
const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false });
assert.match(h, /回复/, '缺方向信号时不能把回信误报成新任务');
assert.doesNotMatch(h, /续谈/);
});
test('缺省参数不抛错', () => {
assert.equal(typeof inboundHeadline(), 'string');
assert.equal(typeof inboundHeadline({}), 'string');
});