Files
MailUI4Agents/plugins/zcode-mail-bridge/test/prompt.test.mjs
JianFeeeee 56c699b338 test(zcode): MCP 提示词测试的陈旧断言 —— b0c8719 加方向判据时漏改本文件
## 现象

跑 zcode-mail-bridge 全套(AgentMail 自身 MCP 服务面的测试),
`prompt.test.mjs` 红 1 格:「带 in_reply_to 时要说清是回哪封」。

## 根因:不是回归,是 b0c8719(09-30)漏改测试

b0c8719 给四桥的 in_reply_to 加了**方向判据**:

    只有 parent_from === 自己 时才说「回的是你那封」,
    否则是多方线索里的他人续谈(单向续信链曾被逐封误读成双向对话)。

那次改了 lib/relay-policy.js + src/prompt.mjs + relay-policy.test.mjs,
**漏改了 prompt.test.mjs**。旧夹具只传 in_reply_to、断言旧的无条件文案
「回的是你那封:m0」,而新代码正确地不再指认方向 ⇒ 假红。

实测三场景确认代码行为符合 b0c8719 设计意图:

    只有 in_reply_to       → 「回复到了」但不指认方向   ✓(退回旧行为)
    + parent_from=自己     → 「回的是你那封:m0」       ✓
    + parent_from=别人     → 「多方线索里的续谈…」      ✓

## 修法

夹具补上 parent_from,并把三个方向场景钉全(真回复 / 多方续谈 /
服务端未升级),含反向断言。

## 变异验证

    变异①:prompt.mjs 丢掉 parent_from===agentName 守卫 → 红 1 格 ✓
    变异②:relay-policy 的 mine 永真               → 红 1 格 ✓
    复原后 16/16 绿;全套 25 文件 397/397。

## 附带发现(未改)

`node --test test/`(目录级)会报一个 test:1:1 的幽灵 fail——
node 把 test/manual/(e2e 手册与证据 json)当测试目录递归了。
用 `node --test "test/*.test.mjs"` 跑即干净。manual/ 内容非自动测试,保留不动。
2026-10-02 11:52:34 +08:00

173 lines
8.0 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.

/**
* 提示词与回信文案的测试。
*
* 这层没有 I/O,但它决定模型看到什么 —— 而模型看到的东西错了,表现是
* 「这个 Agent 就是不回信」或「两个 Agent 无限客套」,都不会报错。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildMailPrompt, replySubject, renderTurnFailure } from '../src/prompt.mjs';
const mail = (over = {}) => ({
mail_id: 'm1',
from_name: 'gui-lab',
from_human: true,
subject: '帮我看看',
reply_address: 'gui-lab@/w.alias',
...over
});
test('新任务:说明这是新邮件,并给出发件人/主题/邮件 id', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail() });
assert.match(p, /gui-lab/);
assert.match(p, /帮我看看/);
assert.match(p, /m1/);
assert.match(p, /身份:你是 zcode/);
});
test('★ 回信不是新任务:带 in_reply_to 时要说清是回哪封', () => {
// 模型分不清「新任务」与「回信」时,会把对方一句「已收到」再当待办做一遍。
//
// ★ 2026-10-02 修陈旧断言(b0c8719 漏改):b0c8719 给 in_reply_to 加了
// **方向判据**——只有 parent_from === 自己时才说「回的是你那封」
// (单向续信链曾被逐封误读成双向对话)。本测试当时只传了 in_reply_to,
// 断言的却是旧的无条件文案 ⇒ 假红。夹具补上 parent_from,并顺手把
// 三个方向场景钉全(改 relay-policy 而漏改 prompt 的测试正是这个教训)。
const mine = buildMailPrompt({ agentName: 'zcode', data: mail({ in_reply_to: 'm0', parent_from: 'zcode' }) });
assert.match(mine, /回的是你那封:m0/);
// 多方续谈:父邮件是别人发的,不得宣称「回的是你那封」
const others = buildMailPrompt({ agentName: 'zcode', data: mail({ in_reply_to: 'm0', parent_from: 'pi' }) });
assert.doesNotMatch(others, /回的是你那封/);
assert.match(others, /续谈/);
// 服务端未升级(无 parent_from):退回「回复到了」但同样不指认方向
const noParent = buildMailPrompt({ agentName: 'zcode', data: mail({ in_reply_to: 'm0' }) });
assert.doesNotMatch(noParent, /回的是你那封/);
assert.match(noParent, /回复.*到了|回复到了/);
});
test('★ 人来信 vs Agent 来信:回信责任必须不同', () => {
const fromHuman = buildMailPrompt({ agentName: 'z', data: mail({ from_human: true }) });
const fromAgent = buildMailPrompt({ agentName: 'z', data: mail({ from_human: false }) });
assert.match(fromHuman, /回信不用你自己发/);
assert.match(fromAgent, /不会替你回信/);
// 反向对照:两者不能出现对方的措辞
assert.doesNotMatch(fromHuman, /不会替你回信/);
assert.doesNotMatch(fromAgent, /回信不用你自己发/);
});
test('★ from_human 缺失时按「不是人」处理(宁多一次 send_mail,不许诺空头回信)', () => {
const p = buildMailPrompt({ agentName: 'z', data: mail({ from_human: undefined }) });
assert.match(p, /不会替你回信/);
});
test('补投的邮件标注 catchup(模型该知道这不是刚发生的)', () => {
const p = buildMailPrompt({ agentName: 'z', data: mail({ catchup: true }) });
assert.ok(p.length > 0);
// 复用会话时不重复交代身份(省 token,且身份没变过)
const reused = buildMailPrompt({ agentName: 'z', data: mail(), reused: true });
assert.doesNotMatch(reused, /身份:你是/);
});
test('权限结论走单独路径,不写成「新邮件」', () => {
const p = buildMailPrompt({
agentName: 'z',
kind: 'permission',
data: { decision: '同意', decided_by: 'gui-lab' }
});
assert.match(p, /同意/);
assert.match(p, /gui-lab/);
assert.doesNotMatch(p, /read_inbox/);
});
// ─── 回信主题 ─────────────────────────────────────────────────────
test('Re: 前缀不会越滚越长', () => {
assert.equal(replySubject('帮我看看'), 'Re: 帮我看看');
assert.equal(replySubject('Re: 帮我看看'), 'Re: 帮我看看');
assert.equal(replySubject('RE:帮我看看'), 'Re: 帮我看看');
assert.equal(replySubject('回复: 帮我看看'), 'Re: 帮我看看');
});
test('空主题回落到「本轮工作总结」', () => {
for (const s of ['', ' ', undefined, null]) {
assert.equal(replySubject(s), '本轮工作总结');
}
});
// ─── 失败回信 ─────────────────────────────────────────────────────
test('★ 失败回信给出 ZCode 自己的成因,而不是别处的建议', () => {
// 共用库那份 renderFailureReport 的建议是「调整可用模型范围」——
// 对 ZCode 而言那条建议什么也解决不了(它的常见成因是没登录)。
const body = renderTurnFailure([{ kind: 'CLI 失败', error: 'Model config is missing.' }], '帮我看看');
assert.match(body, /Model config is missing/);
assert.match(body, /没有登录/);
assert.match(body, /~\/\.zcode\/cli\/config\.json/);
assert.match(body, /AGENTMAIL_ZCODE_CLI/);
assert.doesNotMatch(body, /调整可用模型范围/);
});
test('失败回信列出每一次尝试', () => {
const body = renderTurnFailure(
[
{ kind: '超时', error: '回合超时' },
{ kind: 'CLI 失败', error: '退出码 7' }
],
's'
);
assert.match(body, /已尝试 2 次/);
assert.match(body, /超时/);
assert.match(body, /退出码 7/);
});
test('失败回信在没有任何尝试记录时也不崩', () => {
const body = renderTurnFailure(undefined, undefined);
assert.match(body, /已尝试 0 次/);
});
// ─── 能力说明(平台把自带危险工具禁掉了,模型必须知道)─────────────────
test('★ workspace 档:说清自带工具被禁、动手要用我们的工具、会被请示', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'workspace' }) });
assert.match(p, /Bash \/ Write \/ Edit \/ js/, '必须点名哪些自带工具不可用');
assert.match(p, /禁用/);
assert.match(p, /run_command/);
assert.match(p, /write_file/);
assert.match(p, /申请授权/, '模型必须知道动手会先请示');
// 被拒是业务结果而非故障,且**不能靠重试或绕道** —— 这三件事必须都说
assert.match(p, /报错并给出原因/);
assert.match(p, /不要重试/);
assert.match(p, /绕道|其它执行手段/);
// 只读工具要明确可用,否则模型会以为自己什么都干不了
assert.match(p, /Read \/ Glob \/ Grep/);
});
test('★ plan 档:明说不能动手,别浪费一轮去试', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'plan' }) });
assert.match(p, /plan 档/);
assert.match(p, /不能\*\*执行命令或写文件|不能\*\*执行/);
assert.match(p, /一律拒绝/);
assert.doesNotMatch(p, /申请授权/, 'plan 档不该说会去申请授权(它根本不会发请求)');
});
test('★ full 档:明说免问(否则模型会以为每步都要等人,反而不敢动手)', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'full' }) });
assert.match(p, /full 档/);
assert.match(p, /直接生效/);
assert.match(p, /不会打扰|无需/);
assert.doesNotMatch(p, /第一次调用会先向发件人申请授权/);
});
test('★ 反向对照:三个档位的说明互不相同(写死一档会让另两档撒谎)', () => {
const texts = ['plan', 'workspace', 'full'].map(t =>
buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: t }) })
);
assert.equal(new Set(texts).size, 3, '三个档位给出的能力说明必须各不相同');
});
test('没有 permission_mode 时按 workspace(平台默认档)说明', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail() });
assert.match(p, /workspace 档/);
});