/** * 「人类对权限请求的说明必须到达模型」—— 2026-09-13 线上缺陷的判据。 * * # 缺陷现场 * * 用户在界面上拒绝一条 bash 请求并写下备注: * * 拒绝 * * 备注: 我说了让你拉取仓库到program下你听不懂吗 * * 而模型那一侧收到的工具结果只有: * * 用户拒绝了这次 bash 调用 * * pi 会话转录可查(`~/.pi/agent/sessions/--home-program--/.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 挪出决策分支必须判红(形状在≠机制在)'); });