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
This commit is contained in:
40
plugins/pi-mail-bridge/lib/denial-reason.js
Normal file
40
plugins/pi-mail-bridge/lib/denial-reason.js
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 把「人类对这次权限请求的结论」渲染成模型能读懂的一段话。
|
||||
*
|
||||
* # 为什么单独一个模块
|
||||
*
|
||||
* 三条路径都要给模型同一句话:工具拦截(bash/write…)、无挂起 worker 时的通知投递、
|
||||
* 以及主动询问(kind=question)。而 2026-09-13 的线上缺陷正是这句话里**没有人类的备注**:
|
||||
*
|
||||
* - 用户原话:「被拒绝的 agent 好像看不到我给授权的备注」
|
||||
* - 现场证据(pi 会话转录):模型收到的工具结果只有
|
||||
* `用户拒绝了这次 bash 调用`;而人类那封决策邮件里写着
|
||||
* `拒绝\n\n备注: 我说了让你拉取仓库到program下你听不懂吗`
|
||||
* - 后果:模型不知道要改什么,把同一条命令换个写法又问了一遍(实测连问 9 次)。
|
||||
*
|
||||
* 备注在 `index.mjs → pool.mjs → worker.mjs` 三处被逐个丢掉,所以判据必须落在
|
||||
* **模型最终看到的那段文本**上(这个函数的返回值),而不是"某个字段有没有传"。
|
||||
*/
|
||||
|
||||
/** 模型最终看到的那句"被拒了",附上人类说明与等人期间新到的邮件。 */
|
||||
export function renderDecisionReason({ toolName, decision, note = '', waiting = [] }) {
|
||||
const base = decision === 'shutdown'
|
||||
? '用户未及决策(桥已关停)'
|
||||
: `用户拒绝了这次 ${toolName} 调用`;
|
||||
|
||||
const parts = [base];
|
||||
|
||||
const text = String(note ?? '').trim();
|
||||
if (text) parts.push(`用户的说明:${text}`);
|
||||
|
||||
const fresh = Array.isArray(waiting) ? waiting.filter(Boolean) : [];
|
||||
if (fresh.length) {
|
||||
parts.push([
|
||||
`【等人审批期间新到的邮件 ${fresh.length} 封,尚未标记已读】`,
|
||||
...fresh,
|
||||
'请先用 read_inbox 读这些邮件,再决定下一步 —— 里面可能是对当前做法的更正。',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
48
plugins/pi-mail-bridge/lib/waiting-mails.js
Normal file
48
plugins/pi-mail-bridge/lib/waiting-mails.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { renderMail } from './inbox-format.js';
|
||||
|
||||
/**
|
||||
* 「等人点头期间新到的邮件」—— 权限恢复时要一并交给模型的那几封。
|
||||
*
|
||||
* # 为什么需要它
|
||||
*
|
||||
* 一个 worker 在等人类点「同意/拒绝」时是**停放**的(不占并发额度,见 pool.mjs),
|
||||
* 而这条会话的新邮件会被排队等在它后面。于是人类在等待期间发来的更正(实测:
|
||||
* 22:18:08 发出的「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」)
|
||||
* 要等整个回合结束才被看到 —— 22:26:30,整整 8 分钟,期间模型一直在错误的目录上打转。
|
||||
*
|
||||
* 而那一刻 worker 正好卡在等决策,本来就是"要重新给模型喂上下文"的时刻 ——
|
||||
* 决策一到就把这些人发的新邮件一并附上,模型当场就能改道。
|
||||
*
|
||||
* # 为什么是纯函数
|
||||
*
|
||||
* 筛选规则容易写错(漏掉会话过滤就会把别人会话的信塞进来;忘了排除权限邮件就会
|
||||
* 把决策回执自己当成"新更正"),而这些错误都不会抛异常、只会让提示读起来不对 ——
|
||||
* 必须有判据钉住。
|
||||
*/
|
||||
|
||||
/** 权限请求/决策是控制面邮件,不是"新到的信"。 */
|
||||
const PERMISSION_TYPES = new Set(['permission_request', 'permission_decision']);
|
||||
|
||||
/**
|
||||
* @param {object|Array} inbox `/mail/inbox` 的回包(或它的 mails 数组)
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.sessionID] 只取这条会话里的(空则不筛)
|
||||
* @param {Set<string>} [opts.seen] 已经交付过的 mail_id(不重复给)
|
||||
* @param {number} [opts.limit] 最多几封(默认 3:再多会把工具结果撑爆)
|
||||
* @param {number} [opts.bodyLimit] 每封正文截断长度
|
||||
* @returns {string[]} 已渲染好的条目,可直接拼进拒绝理由
|
||||
*/
|
||||
export function selectWaitingMails(inbox, { sessionID = '', seen = new Set(), limit = 3, bodyLimit = 300 } = {}) {
|
||||
const list = Array.isArray(inbox) ? inbox : (inbox?.mails ?? []);
|
||||
const out = [];
|
||||
for (const m of list) {
|
||||
if (!m?.mail_id || seen.has(m.mail_id)) continue;
|
||||
if (PERMISSION_TYPES.has(m.mail_type)) continue;
|
||||
// 会话过滤只在两边都有值时生效:老回包没有 session_id 时不该把邮件全丢掉
|
||||
// (宁可多给一封,也不能让人类的更正消失)。
|
||||
if (sessionID && m.session_id && m.session_id !== sessionID) continue;
|
||||
out.push(renderMail(m, bodyLimit, ''));
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -46,6 +46,7 @@ import { BoundedSet, MAX_TRACKED_MAILS } from '../lib/bounded.js';
|
||||
import { snapshotPiModels } from '../lib/model-scope.js';
|
||||
import { snapshotPiSessions } from '../lib/session-snapshot.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
import { selectWaitingMails } from '../lib/waiting-mails.js';
|
||||
|
||||
// ─── 配置 ───
|
||||
|
||||
@ -161,19 +162,58 @@ function releaseLock() {
|
||||
* - 那条邮件会话从没被处理过:连通知都无处可投,只能记一行日志。
|
||||
*/
|
||||
function handlePermissionDecision(data) {
|
||||
const relayKey = data.relay_key || '';
|
||||
if (relayKey && pool.routePermission(relayKey, String(data.decision || '拒绝'))) {
|
||||
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker`);
|
||||
// 决策回执的内容马上随这次恢复交给 worker(备注 + 等人期间新到的邮件都在里面),
|
||||
// 所以先把它记成"已交付":稍后那封同内容的邮件就不会被当成新任务再起一轮。
|
||||
const decisionMailID = data?.decision_mail_id || '';
|
||||
if (decisionMailID) deliveredMails.add(decisionMailID);
|
||||
|
||||
// 拉"等人期间新到的邮件"要发一次 HTTP,而本函数跑在 SSE 读循环上(必须廉价)
|
||||
// —— 所以整体转异步:先把事件收下,几毫秒后带着上下文去唤醒 worker。
|
||||
routeDecision(data).catch((e) => log(`决策投递失败: ${describeError(e)}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把决策连同**人类说了什么**一起交给挂起的 worker。
|
||||
*
|
||||
* 缺了 `note` 就是这次线上缺陷:人类写「我说了让你拉取仓库到program下你听不懂吗」,
|
||||
* 模型只收到「用户拒绝了这次 bash 调用」,于是把同一条命令改个写法又问一遍(连问 9 次)。
|
||||
*/
|
||||
async function routeDecision(data) {
|
||||
const relayKey = data?.relay_key || '';
|
||||
const decision = String(data?.decision || '拒绝');
|
||||
const note = typeof data?.note === 'string' ? data.note : '';
|
||||
const sessionID = data?.session_id || '';
|
||||
const waiting = sessionID ? await collectWaitingMails(sessionID) : [];
|
||||
|
||||
if (relayKey && pool.routePermission(relayKey, decision, note, waiting)) {
|
||||
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})已转交 worker`
|
||||
+ (note ? ',带备注' : '')
|
||||
+ (waiting.length ? `,并附上等人期间新到的 ${waiting.length} 封邮件` : ''));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.session_id || !pool.hasSession(data.session_id)) {
|
||||
if (!sessionID || !pool.hasSession(sessionID)) {
|
||||
// **不得凭空新开会话**(B-4.3)
|
||||
log(`权限决策 ${relayKey} 无对应会话,忽略`);
|
||||
return;
|
||||
}
|
||||
log(`权限 ${relayKey} 无挂起项,退化为通知投递`);
|
||||
pool.submit('permission', data);
|
||||
pool.submit('permission', { ...data, note });
|
||||
}
|
||||
|
||||
/**
|
||||
* 等人点头期间新到的邮件(人类很可能在这期间补一句更正)。
|
||||
*
|
||||
* 拉取失败**不能拖垮决策投递**:备注还在,最坏情况是模型晚一步看到更正。
|
||||
*/
|
||||
async function collectWaitingMails(sessionID) {
|
||||
try {
|
||||
const box = await client.get('/mail/inbox?status=unread&limit=20');
|
||||
return selectWaitingMails(box, { sessionID, seen: deliveredMails });
|
||||
} catch (e) {
|
||||
log(`等人期间的邮件拉取失败(不影响决策投递): ${describeError(e)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 心跳(B-2)───
|
||||
@ -372,6 +412,25 @@ function handleSSEEvent(type, data) {
|
||||
const id = data?.mail_id;
|
||||
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
|
||||
deliveredMails.add(id);
|
||||
// ★ 决策回执**不是新任务**(2026-09-13 线上缺陷)。
|
||||
//
|
||||
// 它长得像普通邮件("Re: 权限请求 - 拒绝"),内容却已经随 SSE 的
|
||||
// permission_decision 直接交给了发起询问的 worker。以前它会**再**起一轮:
|
||||
// 同一件事处理两次,还把人类真正的新邮件挤在这条会话的队列后面 ——
|
||||
// 实测人类的更正 22:18:08 发出,直到 22:26:30 才被模型看到。
|
||||
//
|
||||
// 走到这里说明决策事件还没到(事件先到就已经记进 deliveredMails 了)——
|
||||
// 那就按 B-4.3 当**通知**投进原会话,而不是凭空当新任务。
|
||||
if (data?.mail_type === 'permission_decision') {
|
||||
if (!data?.session_id || !pool.hasSession(data.session_id)) {
|
||||
log(`决策回执 ${id} 无对应会话,忽略(不凭空新开)`);
|
||||
return;
|
||||
}
|
||||
log(`决策回执 ${id} 按通知投递(决策事件尚未到达)`);
|
||||
pool.submit('permission', data);
|
||||
return;
|
||||
}
|
||||
|
||||
pool.submit('mail', data);
|
||||
}
|
||||
|
||||
|
||||
@ -308,7 +308,7 @@ export function createWorkerPool({
|
||||
* (桥重启、硬超时被杀、或者处理已经结束)—— 调用方据此走 B-4.2 的
|
||||
* 降级路径(把决策当一封通知投进原会话)。
|
||||
*/
|
||||
function routePermission(relayKey, decision) {
|
||||
function routePermission(relayKey, decision, note = '', freshMails = []) {
|
||||
const key = permissionRoutes.get(relayKey);
|
||||
if (!key) return false;
|
||||
const entry = running.get(key);
|
||||
@ -323,7 +323,10 @@ export function createWorkerPool({
|
||||
armTimeout(entry);
|
||||
log(`worker ${entry.child.pid} 收到决策,恢复占用并发额度(停放 ${parkedCount()}/${maxParked})`);
|
||||
}
|
||||
entry.child.send({ type: 'permission_decision', relayKey, decision });
|
||||
// 备注与「等人期间新到的邮件」必须一起送到:以前只传 decision,于是人类写
|
||||
// 「我说了让你拉取仓库到program下你听不懂吗」,模型只看到「拒绝」,
|
||||
// 转头把同一条命令又问了一遍(2026-09-13 实测连问 9 次)。
|
||||
entry.child.send({ type: 'permission_decision', relayKey, decision, note, freshMails });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -123,11 +123,16 @@ export function describeError(err) {
|
||||
*/
|
||||
export function buildMailPrompt({ agentName, data, kind, reused }) {
|
||||
if (kind === 'permission') {
|
||||
return [
|
||||
const lines = [
|
||||
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
|
||||
`(决策人:${data?.decided_by || '用户'})。`,
|
||||
`请据此继续后续工作。`,
|
||||
].join('\n');
|
||||
];
|
||||
// 人类的备注必须带上:这条路(无挂起 worker 时把决策当通知投进会话)与工具
|
||||
// 拦截那条是同一个信息,缺失后果一样 —— 模型不知道要改什么。
|
||||
const note = typeof data?.note === 'string' ? data.note.trim() : '';
|
||||
if (note) lines.push(`用户的说明:${note}`);
|
||||
lines.push('请据此继续后续工作。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// 发件方是人还是 Agent,以及这封是不是回信 —— 两个信号都来自服务端。
|
||||
|
||||
@ -44,6 +44,7 @@ import { join } from 'node:path';
|
||||
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
import { GatewayClient } from './gateway.mjs';
|
||||
import { renderDecisionReason } from '../lib/denial-reason.js';
|
||||
import { createMailTools } from './tools.mjs';
|
||||
import { openSession, runTurn } from './session-pool.mjs';
|
||||
import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError, renderResumeFailure } from './turn.mjs';
|
||||
@ -89,6 +90,10 @@ const grants = new Set();
|
||||
|
||||
let job = null;
|
||||
let client = null;
|
||||
|
||||
// 每次权限询问「人类说了什么」(备注 + 等人期间新到的邮件),按 relayKey 暂存。
|
||||
// 见 lib/denial-reason.js 的说明。
|
||||
const decidedExtra = new Map();
|
||||
let modelRuntime = null;
|
||||
let piSessionId = '';
|
||||
let mailContext = { replyTo: '', subject: '', mailID: '', permissionMode: 'workspace' };
|
||||
@ -237,9 +242,16 @@ function permissionExtension() {
|
||||
log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`);
|
||||
return;
|
||||
}
|
||||
const extra = decidedExtra.get(relayKey) || {};
|
||||
decidedExtra.delete(relayKey);
|
||||
return {
|
||||
block: true,
|
||||
reason: `用户${decision === 'shutdown' ? '未及决策(桥已关停)' : `拒绝了这次 ${event.toolName} 调用`}`,
|
||||
reason: renderDecisionReason({
|
||||
toolName: event.toolName,
|
||||
decision,
|
||||
note: extra.note,
|
||||
waiting: extra.waiting,
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
@ -654,7 +666,13 @@ process.on('message', (msg) => {
|
||||
const resolve = pending.get(msg.relayKey);
|
||||
if (!resolve) return;
|
||||
pending.delete(msg.relayKey);
|
||||
resolve(String(msg.decision || '拒绝'));
|
||||
|
||||
// 备注与「等人期间新到的邮件」按 relayKey 暂存,等那次工具调用回来渲染
|
||||
// 拒绝理由时取走。不存全局变量:一个回合里可能有多次询问先后落地。
|
||||
decidedExtra.set(msg.relayKey, {
|
||||
note: typeof msg.note === 'string' ? msg.note : '',
|
||||
waiting: Array.isArray(msg.freshMails) ? msg.freshMails : [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (msg?.type === 'shutdown') {
|
||||
|
||||
143
plugins/pi-mail-bridge/test/permission-note.test.mjs
Normal file
143
plugins/pi-mail-bridge/test/permission-note.test.mjs
Normal file
@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 「人类对权限请求的说明必须到达模型」—— 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, '对当前源码必须判绿');
|
||||
});
|
||||
Reference in New Issue
Block a user