Files
MailUI4Agents/plugins/pi-mail-bridge/test/turn.test.mjs
JianFeeeee 784192d8c4 Agent→Agent 不自动转发 + 提示词区分新活/回复/补投
## 设计规则:Agent 之间不自动转发

自动转发存在的理由是「人不该等模型记得调 send_mail」—— 收件方是人时这是
纯收益。**收件方是另一个 Agent 时这个理由不成立,而且有害**:双方的插件都
会自动回一封,于是两个模型都以为「我只要把话说完就行」,实际在持续互相唤醒。
生产实测 pi 与 dsh 客套 6 轮直到撞上连续 relay 跳数上限。

规则现在写死在共用模块 `lib/relay-policy.js`(三平台逐字节相同):
- `autoRelayDecision` — 插件该不该替模型开口
- `replyInstruction` — 提示词怎么跟模型说(人类 vs Agent 各一套措辞)
- `inboundHeadline` — 进来的是新活、回复、还是补投

`from_human` 缺失时保守按 Agent 处理:宁可让模型多调一次 send_mail,
也不能承诺一个不会发生的自动回信让发件方白等。

## Gateway 侧:`in_reply_to` + `from_human`

- `notify.Mail` 新增 `ParentMailID`(非空 = 这是对收件方某封信的回复)
- `notify.Mail` 新增 `FromHuman`(走 `repo.IsHumanUser`)
- SSE payload 里叫 `in_reply_to` / `from_human`
- 四个调用点全部传入:handler/mail(转发后产出的邮件,parentMailID 从
  resolveTarget 取)、handler/me(同理)、handler/forward(传空串,
  因为对收件方而言那封原邮件不在它的线索里)、scheduler/calendar(传空串)
- `ListInbox` 的 SELECT 加 `EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name)`
  → `models.Mail.FromHuman`,让补拉路径也有这个信号

## 提示词分流

三种处境各一套标题:
- 新活(人类):「你收到一封新邮件」+ 「回信不用你自己发:…」
- 新活(Agent):「你收到一封新邮件(对方是一个 Agent)」+ 「插件不会替你
  回信。需要回复时你必须自己调 send_mail…请先判断是否真的需要回复」
- 回复到了:「你上一封信的回复到了。**这不是新任务**。」
- 补投:在标题里说明「离线期间积压」

## homeagent 特殊处理

Go 插件不能直接 `import('../lib/relay-policy.js')`,因此新增 `relay_policy.go`
(Go 对应物)+ `relay_policy_test.go`(11 例,逐条对齐 Node 侧判据)。
`sseLoop` / `catchUp` 两条路径都接上。

## `mailEvent` 命名类型

homeagent 的 SSE 事件解析 / handleNewMail / handlePermissionDecision 三处
原来各写一遍匿名 struct(字段列表几乎相同),加 `from_human` / `in_reply_to`
时漏改一处 → 编译报错但错误信息是两串几乎相同的字段列表,极难定位。
提成 `mailEvent` 命名类型:一处改、三处跟着走。

## 测试

- `lib/relay-policy.test.mjs`(Node)16 例:含「replyInstruction 与
  autoRelayDecision 不得互相矛盾」「Agent 来信的标题要点名且回复要明确反对」
- `relay_policy_test.go`(Go)11 例:逐条对齐 Node 侧
- `turn.test.mjs` +3 例:from_human 缺失时按 Agent 处理 / Agent 来信时改口 /
  回复到了说「不是新任务」;删掉两条旧的「必定自动转发」断言
- 共用脚本 `check-shared-libs.sh` +1 个文件(relay-policy)
- pi 288 / dsh 241 / opencode 217 / homeagent 14 / gateway 8 包全绿
2026-09-04 23:52:52 +08:00

291 lines
11 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.

/**
* pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。
*
* 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的
* 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
stripRe,
replySubject,
lastAssistantText,
classifyTurnOutcome,
describeError,
buildMailPrompt,
relayKeyFor,
} 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() rejectNo 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('不变量:回信到达时明说「不是新任务」', () => {
// 把回复当新任务处理正是互相客套的起点。
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('不变量:提示词带 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');
});