Files
MailUI4Agents/plugins/pi-mail-bridge/test/permission-note.test.mjs
JianFeeeee 453f451fbb fix(permission): 人类的备注必须到达模型 + 决策回执不再被当成新任务
用户报的「很严重的问题」:被拒绝的 agent 看不到授权备注,且看不到他发的回复邮件。
按数据查到了两个**真缺陷**,都在桥的权限回路上(不是猜测,三层证据)。

## 缺陷一:备注在桥内被连丢三处

网关其实一路都带着备注(`CreateDecisionMail(..., req.Note)` 把备注写进决策邮件正文,
SSE payload 里也有 `"note"`),但桥的三个环节只传 decision:
  index.mjs  `pool.routePermission(relayKey, String(data.decision))`
  pool.mjs   `child.send({type:'permission_decision', relayKey, decision})`
  worker.mjs `resolve(String(msg.decision))`
模型最终看到的只有 `用户拒绝了这次 bash 调用`(pi 会话转录逐字可查)。

现场:人类写「我说了让你拉取仓库到program下你听不懂吗」,模型不知道要改什么,
把同一条命令换个写法又问了 —— 会话里连问 **9 次**(22:16–22:26)。

## 缺陷二:决策回执照样被当"新任务"投递 + 等人的邮件被堵在后面

决策是**双通道**送达:SSE `permission_decision`(唤醒停放的 worker)+ 一封普通形状的
邮件("Re: 权限请求 - 拒绝")。以前两条都会起动作 ⇒ 同一件事被处理两次;而这条会话
的新邮件在 worker 停放期间只能排队。实测:人类 22:18:08 发出的更正
「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」
直到 22:26:30(worker 回合结束)才被模型看到 —— **8 分钟**里它一直在错误的目录上打转。
转录里那封更正确实是模型自己 `read_mail` 读到的(不是没人给它)。

## 改动

- 网关:`CreateDecisionMail` 写 `mail_type='permission_decision'` —— 桥据此区分
  「控制面回执」与「新任务」。
- pi 桥(新增 `lib/denial-reason.js`、`lib/waiting-mails.js`):
  · 备注随决策一路透传到**模型看到的拒绝理由**(工具拦截与通知投递两条路都带);
  · 恢复停放的 worker 时,顺带把「等人期间新到、尚未标记已读」的邮件附进理由,
    模型当场就能改道(这正是那 8 分钟的洞);
  · 决策回执不再起新任务轮次(记进 deliveredMails);若决策事件尚未到达,
    退化为 B-4.3 的通知投递,且没有会话时不凭空新开。

## 判据

- `test/permission-note.test.mjs`:11 条(备注进理由、无备注不得凭空造说明、
  等人期间的邮件要点名 read_inbox、只挑本会话非权限类未交付的、上限、旧回包缺
  session_id 不能丢邮件、接线 8 处形状、判据自检)。
- **扰动验证**:把备注从 `pool.mjs` 的 send 里去掉 → 接线判据 2 条红;恢复 → 11 绿。
- 既有 pi 套件 420/420;server 10 包全绿(新增 1 条 Go 判据验决策邮件的类型与备注正文)。

## 现场证据(可复核)

- 桥日志:9 次 `权限 <key> 决策 同意/拒绝(决策人 jianf)已转交 worker`,全程不含备注;
  「worker 2135211 等待权限决策,让出并发额度(停放 1/5)」
- 会话转录:`{"toolName":"bash","content":[{"text":"用户拒绝了这次 bash 调用"}]}` ×6
2026-09-13 22:47:25 +08:00

144 lines
6.5 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.

/**
* 「人类对权限请求的说明必须到达模型」—— 2026-09-13 线上缺陷的判据。
*
* # 缺陷现场
*
* 用户在界面上拒绝一条 bash 请求并写下备注:
*
* 拒绝
*
* 备注: 我说了让你拉取仓库到program下你听不懂吗
*
* 而模型那一侧收到的工具结果只有:
*
* 用户拒绝了这次 bash 调用
*
* pi 会话转录可查(`~/.pi/agent/sessions/--home-program--/<id>.jsonl`)。模型于是
* 不知道要改什么,把同一条命令换个写法又问一遍 —— 现场连问 9 次。
*
* # 为什么既验纯函数又验接线
*
* 备注在 `index.mjs → pool.mjs → worker.mjs` 三处被逐个丢掉:纯函数测试对它无能为力
* (函数是对的,只是没人把参数传下去),而只验接线又验不出"渲染出来的字对不对"。
* 所以两层都要:纯函数验文本,接线验参数真的被透传(与 permission-forward-wiring 同一取舍)。
*/
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';
import { renderDecisionReason } from '../lib/denial-reason.js';
import { selectWaitingMails } from '../lib/waiting-mails.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, '..', 'src');
const read = (f) => readFileSync(join(SRC, f), 'utf8');
const NOTE = '我说了让你拉取仓库到program下你听不懂吗';
test('备注必须出现在模型看到的拒绝理由里', () => {
const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: NOTE });
assert.match(out, /用户拒绝了这次 bash 调用/);
assert.ok(out.includes(NOTE), `理由里必须原样带上人类写的备注,实际:${out}`);
});
test('反向对照:没写备注时不该凭空造出"用户的说明"', () => {
const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: '' });
assert.ok(!out.includes('用户的说明'), `无备注却出现了说明段:${out}`);
assert.match(out, /^用户拒绝了这次 bash 调用$/);
});
test('等人期间新到的邮件要附进理由,并指向 read_inbox', () => {
const waiting = ['[unread] jianf: 不对不是让你拉取到agentmail仓库是让你拉取到program仓库'];
const out = renderDecisionReason({ toolName: 'bash', decision: '拒绝', note: NOTE, waiting });
assert.ok(out.includes('是让你拉取到program仓库'), '更正内容必须出现在理由里');
assert.match(out, /read_inbox/, '要点明让模型去读全文(理由里只有摘要)');
assert.match(out, /1 封/, '要说明有几封,模型才知道要不要再拉');
});
test('关停路径的文案不回归(未及决策 ≠ 拒绝)', () => {
assert.match(renderDecisionReason({ toolName: 'bash', decision: 'shutdown' }),
/未及决策(桥已关停)/);
});
// ─── selectWaitingMails ───
const mk = (over = {}) => ({
mail_id: 'm-' + Math.random().toString(36).slice(2, 8),
session_id: 's1', mail_type: 'normal', from_name: 'jianf',
subject: 'Re: 权限请求 - 同意', body: '不对不是让你拉取到agentmail仓库是让你拉取到program仓库',
...over,
});
test('只挑这条会话、非权限类、且没交付过的邮件', () => {
const seen = new Set(['m-seen']);
const box = { mails: [
mk({ mail_id: 'm-seen' }),
mk({ mail_id: 'm-other-session', session_id: 's2' }),
mk({ mail_id: 'm-req', mail_type: 'permission_request' }),
mk({ mail_id: 'm-dec', mail_type: 'permission_decision' }),
mk({ mail_id: 'm-keep' }),
] };
const out = selectWaitingMails(box, { sessionID: 's1', seen });
assert.equal(out.length, 1, `只该有 1 封,实际:${JSON.stringify(out)}`);
assert.ok(out[0].includes('是让你拉取到program仓库'));
});
test('★ 反向对照:旧的权限回执不能被当成"人类的新更正"', () => {
const box = { mails: [mk({ mail_type: 'permission_decision' })] };
assert.deepEqual(selectWaitingMails(box, { sessionID: 's1' }), []);
});
test('上限生效(再多也不能把工具结果撑爆)', () => {
const box = { mails: Array.from({ length: 9 }, () => mk()) };
assert.equal(selectWaitingMails(box, { sessionID: 's1', limit: 3 }).length, 3);
});
test('回包没有 session_id 时不能把邮件全丢掉(宁可多给也不能漏更正)', () => {
const box = { mails: [mk({ session_id: undefined })] };
assert.equal(selectWaitingMails(box, { sessionID: 's1' }).length, 1);
});
test('空回包/字段缺失不抛异常', () => {
assert.deepEqual(selectWaitingMails(undefined, {}), []);
assert.deepEqual(selectWaitingMails({ mails: [null, {}, mk({ mail_id: undefined })] }, {}), []);
});
// ─── 接线:参数必须真的被传下去 ───
/** 找「决策从桥到模型」这条链上必须存在的形状。 */
export const WIRING = [
{ file: 'pool.mjs', what: 'routePermission 接住备注与等人期间的邮件',
must: /child\.send\(\{ type: 'permission_decision'[^}]*\bfreshMails\b/ },
{ file: 'pool.mjs', what: 'routePermission 签名带 note',
must: /function routePermission\(relayKey, decision, note/ },
{ file: 'worker.mjs', what: 'worker 收下 msg.note并暂存到 decidedExtra',
must: /decidedExtra\.set\(msg\.relayKey/ },
{ file: 'worker.mjs', what: '拒绝理由由 renderDecisionReason 渲染(而不是写死的字面量)',
must: /reason: renderDecisionReason\(/ },
{ file: 'index.mjs', what: '决策事件把 note 传给 pool',
must: /routePermission\(relayKey, decision, note, waiting\)/ },
{ file: 'index.mjs', what: '决策回执记成已交付(不再当新任务)',
must: /deliveredMails\.add\(decisionMailID\)/ },
{ file: 'index.mjs', what: 'new_mail 分支认得决策回执',
must: /mail_type === 'permission_decision'/ },
{ file: 'turn.mjs', what: '通知投递路径也带备注',
must: /lines\.push\(`用户的说明:\$\{note\}`\)/ },
];
test('接线齐全(缺一处备注就断在那一环)', () => {
for (const w of WIRING) {
const src = read(w.file);
assert.ok(w.must.test(src), `${w.file}: ${w.what}`);
}
});
test('★ 判据自检:拿一段没有该形状的源码喂进来必须判红', () => {
const fake = "entry.child.send({ type: 'permission_decision', relayKey, decision });";
const one = WIRING[0];
assert.equal(one.must.test(fake), false, '接线断言对"只传 decision"的旧写法必须判红');
assert.equal(one.must.test(read(one.file)), true, '对当前源码必须判绿');
});