Files
MailUI4Agents/plugins/pi-mail-bridge/test/permission-note.test.mjs
pi f0884d03be fix(pi-bridge): 决策到了没唤醒等待者 —— 整条授权链断掉(用户报「授权机制有问题」)
现场(用户:「我发现授权机制有问题,你看看 webui4frpc 的那个 session」):
同一条会话一天被问 6 次「是否允许执行 bash?」,**每次人都在 8 秒内点了同意**,
而每一轮都恰好烧满 10 分钟(TURN_TIMEOUT_MS),回信只有一句 59 字的开场白。
该 agent 自己的会话转录里写着:**"The bash tool keeps returning 'No result provided'"**。

根因:`worker.mjs` 的 `permission_decision` 分支取出了等待者、删了表项、存了备注,
**却没有调用 `resolve`**:

    const resolve = pending.get(msg.relayKey);
    if (!resolve) return;
    pending.delete(msg.relayKey);
    decidedExtra.set(msg.relayKey, { ... });
    return;                       // ← 等的人永远醒不过来

于是一条命令走完下面这一整圈:
  ① 工具调用挂着不动 → 一轮跑到 10 分钟 TURN_TIMEOUT_MS 才结束;
  ② 桥把模型那半句开场白当「本轮总结」发回(59 字);
  ③ 会话里留下**没有 toolResult 的 toolCall** → 下一轮 pi SDK 给它补一条
     `isError: true` 的「No result provided」→ 模型重试 bash → 人又被问一遍。

为什么之前全绿:`permission-note.test.mjs` 的 WIRING 钉的是
`decidedExtra.set(msg.relayKey)`(**备注=装饰**)与 `renderDecisionReason`,
**没有一条钉"唤醒"**。2026-09-13 那次修备注时把唤醒弄丢,判据照样全绿
—— 钉装饰不钉机制。

改:
- `resolve(msg.decision)` 补回,放在 `decidedExtra.set` **之后**(hook 醒来要读备注渲染
  拒绝理由,顺序反了会复现 2026-09-13 的「备注丢失 → 模型重复追问」)
- 判据:WIRING 补一条「必须唤醒」;另加 `checkDecisionBranch` **按标记切出决策分支正文**
  判"存在 + 归属 + 顺序"(不用"相距 N 字符"的窗口断言 —— 第一版就是那样假红的)
- 三种反面写法做变异自检:删掉 resolve / resolve 早于备注 / resolve 挪出分支,都必须红
- 全套 445 条通过

跨端核对:dsh 桥的 `pendingApprovals` 有 `pending.resolve(outcome)`(没这个问题);
opencode 走原生 permission 回复、zcode 走事件钩子 —— 这条路径只有 pi 桥有。
2026-09-14 19:16:22 +08:00

207 lines
10 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/ },
/*
* ★ 2026-09-14:这条是补的,因为上面那条**只钉住了备注(装饰),没钉住唤醒**。
*
* 生产实况(webui4frpc 那条会话):`resolve` 被弄丢之后,工具调用永远等不到结果 ——
* 一轮烧满 10 分钟 TURN_TIMEOUT_MS、发回一句 59 字的开场白、会话里留下一个
* 没有 toolResult 的 toolCall,下一轮 SDK 补「No result provided」,模型重试 bash,
* 人又被问一次。同一条会话一天被问 6 次,而每次人都在 8 秒内点了同意。
* 备注接线全绿,机制整条断掉 —— 判据钉装饰不钉机制,就是这个下场。
*
* 这里只钉"存在";**顺序**(先落备注再唤醒)与**分支归属**由下面的
* `checkDecisionBranch` 用结构取正文来判 —— 不用"两个形状相距 N 字符"那种窗口断言,
* 那类断言会因为中间多写一段注释而假红(这一条的第一版就是这么红的)。
*/
{ file: 'worker.mjs', what: '决策必须**唤醒**等待中的工具调用(只存备注不算)',
must: /resolve\(msg\.decision\)/ },
{ 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, '对当前源码必须判绿');
});
/*
* ★ 决策分支的**结构**检查(2026-09-14)。
*
* 抽成函数是为了能被两侧验证:真实源码必须绿;三种反面写法必须红。
* 用"按标记切出分支正文"而不是"两个形状相距 N 字符":
* 后者会因为我在这两行之间写了一段注释就假红(第一版就是这样红的)。
*/
export function checkDecisionBranch(src) {
const start = src.indexOf("if (msg?.type === 'permission_decision')");
const stop = src.indexOf("if (msg?.type === 'shutdown')", start);
if (start < 0 || stop < 0) return { branch: '', setAt: -1, resolveAt: -1, ok: false };
const branch = src.slice(start, stop);
const setAt = branch.indexOf('decidedExtra.set(msg.relayKey');
const resolveAt = branch.indexOf('resolve(msg.decision)');
return { branch, setAt, resolveAt, ok: setAt > 0 && resolveAt > 0 && setAt < resolveAt };
}
test('★ 决策分支:先落备注、再唤醒(缺唤醒整条授权链断掉;顺序反了备注被丢掉)', () => {
const r = checkDecisionBranch(read('worker.mjs'));
assert.ok(r.setAt > 0, '决策分支里要暂存备注(decidedExtra.set)');
assert.ok(r.resolveAt > 0,
'决策分支里必须 resolve(msg.decision) —— 缺了它工具调用永远等不到结果:' +
'一轮烧满 TURN_TIMEOUT_MS、会话留下没有 toolResult 的 toolCall、下一轮模型看到 ' +
'「No result provided」并重问,人一天被问 6 次(webui4frpc 实测)');
assert.ok(r.setAt < r.resolveAt, '必须先 decidedExtra.set 再 resolve(hook 醒来要读备注渲染拒绝理由)');
});
test('★ 判据自检:三种写法里那两种坏的必须判红', () => {
const good = read('worker.mjs');
assert.equal(checkDecisionBranch(good).ok, true, '当前源码必须判绿');
// ① 生产事故那次:整行 resolve 被删掉
const missing = good.replace(' resolve(msg.decision);\n', '');
assert.equal(checkDecisionBranch(missing).ok, false, '删掉 resolve 的写法必须判红');
// ② 2026-09-13 那次:resolve 写在 decidedExtra.set 之前(备注丢失 → 模型重复追问)
const reordered = good
.replace(' decidedExtra.set(msg.relayKey, {', ' resolve(msg.decision);\n decidedExtra.set(msg.relayKey, {')
.replace(' resolve(msg.decision);\n return;', ' return;');
assert.equal(checkDecisionBranch(reordered).ok, false, 'resolve 早于 decidedExtra.set 必须判红');
// ③ 唤醒写到了别的分支(形状在、机制不在)
const moved = good
.replace(' resolve(msg.decision);\n return;', ' return;')
.replace("if (msg?.type === 'shutdown') {", "if (msg?.type === 'shutdown') {\n resolve(msg.decision);");
assert.equal(checkDecisionBranch(moved).ok, false, 'resolve 挪出决策分支必须判红(形状在≠机制在)');
});