Files
MailUI4Agents/plugins/dsh-mail-bridge/test/denial-reason.test.mjs
JianFeeeee 79c4171c9d feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心:
- 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点
- DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields
- 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag)
- 日历 status 校验(create 补字段 + update 拦非法值)
- 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例

A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按
解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了

A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*),
SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查,
48h 年龄下限兜上传窗口。已接进每小时 sweep 循环

C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human
(EXISTS users 判定),models.Mail 加 ToHuman。前端判据从
workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart
从 session_workspace 取 path(修 dsh@dsh 拼接 bug)

契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/
permission_mode/permission_enforcement),B-5 加 B-5.6
(Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement
+ unknown_fields,demo 死链修复 + from_human 检查
验收清单加 Agent→Agent 负向对照项
2026-09-06 15:18:06 +08:00

193 lines
6.9 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.

// 拒绝原因递给模型的语义验证。
//
// noteDenial / takeDenial / DENIED_REASON_TTL_MS 是 apply() 外的模块级私有量,
// 但入口文件 import 不进来(导入 src/index.ts 会拉起整个 Cordis 插件),
// 所以把结构原样复刻在这里验语义 —— 与 turnwait.test.mjs 同一手法。
//
// ## 为什么要有这一层
//
// DSH 把 approval/request 的 'rejected' 翻译成 dsh-tools 里写死的一句:
//
// case "rejected": reason = `the user rejected tool "${exec.name}"`
//
// 插件因为「这条链上没有人类可问」或「转发遇 4xx 永久失败」主动拒绝时,
// **没有任何用户拒绝过它**。模型看到一句不存在的拒绝,只会以为人不同意,
// 不会去换一条路;服务端给的 suggestion 则只进了 journalctl。
//
// pi{block:true, reason})与 opencodeoutput.reason的 reason 直达模型,
// 只有 DSH 需要 tools/post-execute 这道绕行。
import { test } from 'node:test';
import assert from 'node:assert/strict';
const DENIED_REASON_TTL_MS = 10 * 60 * 1000;
function makeDenialStore(now = () => Date.now()) {
const deniedReasons = new Map();
const denialKey = (agentId, callId) => `${agentId}:${String(callId ?? 'nocall')}`;
function noteDenial(agentId, callId, text) {
const t = now();
for (const [k, v] of deniedReasons) {
if (t - v.at > DENIED_REASON_TTL_MS) deniedReasons.delete(k);
}
deniedReasons.set(denialKey(agentId, callId), { text, at: t });
}
function takeDenial(agentId, callId) {
const key = denialKey(agentId, callId);
const hit = deniedReasons.get(key);
if (!hit) return undefined;
deniedReasons.delete(key);
if (now() - hit.at > DENIED_REASON_TTL_MS) return undefined;
return hit.text;
}
return { noteDenial, takeDenial, size: () => deniedReasons.size };
}
// post-execute 处理器的结构复刻(与 src/index.ts 中的判据一致)。
function makeHandler(store, mailDrivenSessions) {
return async function postExecute(exec, result, next) {
const agentId = String(exec?.agent?.id ?? '');
if (!agentId || !mailDrivenSessions.has(agentId)) return next();
if (!result?.isError) return next();
const reason = store.takeDenial(agentId, exec?.callId);
if (!reason) return next();
return {
kind: 'block',
feedback: [{ type: 'text', text: `无法执行 ${exec?.name}${reason}` }],
};
};
}
const NEXT = { kind: 'accept' };
const next = async () => NEXT;
test('记下的原因会替换掉平台写死的文案', async () => {
const store = makeDenialStore();
const handler = makeHandler(store, new Set(['a1']));
store.noteDenial('a1', 'call-1', '权限询问无法送达:这条任务链上没有人类用户\n请改用不需要授权的方式完成。');
const out = await handler(
{ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' },
{ isError: true },
next,
);
assert.equal(out.kind, 'block');
assert.match(out.feedback[0].text, /没有人类用户/);
assert.match(out.feedback[0].text, /请改用不需要授权的方式/);
// 必须提到工具名,否则模型不知道是哪一次调用被挡了
assert.match(out.feedback[0].text, /bash/);
});
test('没记原因时放过,不干扰别的工具失败', async () => {
const store = makeDenialStore();
const handler = makeHandler(store, new Set(['a1']));
const out = await handler(
{ agent: { id: 'a1' }, callId: 'call-x', name: 'bash' },
{ isError: true, content: [{ type: 'text', text: 'Error: command not found' }] },
next,
);
assert.equal(out, NEXT);
});
test('成功的结果一律放过 —— 被拒绝的调用不可能成功', async () => {
const store = makeDenialStore();
const handler = makeHandler(store, new Set(['a1']));
store.noteDenial('a1', 'call-1', '不该被用到');
const out = await handler(
{ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' },
{ isError: false },
next,
);
assert.equal(out, NEXT, '成功结果不该被 block');
});
test('非邮件驱动的会话不接管(人坐在 TUI 前面,平台文案没问题)', async () => {
const store = makeDenialStore();
const handler = makeHandler(store, new Set(['a1']));
store.noteDenial('other', 'call-1', '不该被用到');
const out = await handler(
{ agent: { id: 'other' }, callId: 'call-1', name: 'bash' },
{ isError: true },
next,
);
assert.equal(out, NEXT);
});
test('一次性:同一次调用只替换一次', async () => {
const store = makeDenialStore();
const handler = makeHandler(store, new Set(['a1']));
store.noteDenial('a1', 'call-1', '原因文本');
const first = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
assert.equal(first.kind, 'block');
const second = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
assert.equal(second, NEXT, '第二次不该再 block否则一个原因会污染后续同 callId 的失败)');
});
test('按 (会话, callId) 隔离:别的会话拿不到这条原因', async () => {
const store = makeDenialStore();
const handler = makeHandler(store, new Set(['a1', 'a2']));
store.noteDenial('a1', 'call-1', 'a1 的原因');
const other = await handler({ agent: { id: 'a2' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
assert.equal(other, NEXT, '同 callId 但不同会话不该命中');
const mine = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
assert.equal(mine.kind, 'block');
});
test('callId 缺失时也能对上DSH 允许 callId 为空)', async () => {
const store = makeDenialStore();
const handler = makeHandler(store, new Set(['a1']));
store.noteDenial('a1', undefined, '没有 callId 的拒绝');
const out = await handler({ agent: { id: 'a1' }, name: 'bash' }, { isError: true }, next);
assert.equal(out.kind, 'block');
assert.match(out.feedback[0].text, /没有 callId 的拒绝/);
});
test('过期的原因不再使用(避免把十分钟前的拒绝贴到新失败上)', async () => {
let clock = 1_000_000;
const store = makeDenialStore(() => clock);
const handler = makeHandler(store, new Set(['a1']));
store.noteDenial('a1', 'call-1', '很久以前的原因');
clock += DENIED_REASON_TTL_MS + 1;
const out = await handler({ agent: { id: 'a1' }, callId: 'call-1', name: 'bash' }, { isError: true }, next);
assert.equal(out, NEXT, '过期条目应当被忽略');
});
test('写入时顺带清理过期条目,表不会无限增长', () => {
let clock = 1_000_000;
const store = makeDenialStore(() => clock);
for (let i = 0; i < 5; i++) store.noteDenial('a1', `old-${i}`, 'x');
assert.equal(store.size(), 5);
clock += DENIED_REASON_TTL_MS + 1;
store.noteDenial('a1', 'fresh', 'y');
assert.equal(store.size(), 1, '过期的 5 条应当在写入时被清掉,只剩新的那条');
});