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 负向对照项
This commit is contained in:
52
plugins/dsh-mail-bridge/lib/bounded.d.ts
vendored
Normal file
52
plugins/dsh-mail-bridge/lib/bounded.d.ts
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 类型声明 —— 实现在 bounded.js(三平台共用,逐字节相同)。
|
||||
*
|
||||
* DSH 插件是 TypeScript,import 一个纯 JS 模块需要这份声明才能过 tsc。
|
||||
* 声明与实现分开维护有分叉风险,所以这里只描述**插件真正调用的**那些成员,
|
||||
* 少写一个方法会在编译期被抓到,而多写一个不存在的方法不会 —— 后者靠
|
||||
* bounded.test.mjs 里那些逐方法的断言兜住。
|
||||
*/
|
||||
|
||||
/** 已投递邮件 id 的记忆上限。 */
|
||||
export const MAX_TRACKED_MAILS: number;
|
||||
|
||||
/** 会话级映射的条目上限。 */
|
||||
export const MAX_TRACKED_SESSIONS: number;
|
||||
|
||||
/** 有界 Map,超过上限时丢弃最久未访问的条目。 */
|
||||
export class BoundedMap<K, V> {
|
||||
constructor(limit: number);
|
||||
readonly limit: number;
|
||||
readonly size: number;
|
||||
/** 累计淘汰条数,观测用。 */
|
||||
evicted: number;
|
||||
has(key: K): boolean;
|
||||
/** 取值并刷新活跃度。 */
|
||||
get(key: K): V | undefined;
|
||||
/** 取值但不刷新活跃度。 */
|
||||
peek(key: K): V | undefined;
|
||||
set(key: K, value: V): this;
|
||||
delete(key: K): boolean;
|
||||
clear(): void;
|
||||
keys(): IterableIterator<K>;
|
||||
values(): IterableIterator<V>;
|
||||
entries(): IterableIterator<[K, V]>;
|
||||
[Symbol.iterator](): IterableIterator<[K, V]>;
|
||||
}
|
||||
|
||||
/** 有界 Set,超过上限时丢弃最久未访问的成员。 */
|
||||
export class BoundedSet<T> {
|
||||
constructor(limit: number);
|
||||
readonly limit: number;
|
||||
readonly size: number;
|
||||
evicted: number;
|
||||
/** 判断存在并刷新活跃度。 */
|
||||
has(value: T): boolean;
|
||||
/** 判断存在但不刷新活跃度。 */
|
||||
peek(value: T): boolean;
|
||||
add(value: T): this;
|
||||
delete(value: T): boolean;
|
||||
clear(): void;
|
||||
values(): IterableIterator<T>;
|
||||
[Symbol.iterator](): IterableIterator<T>;
|
||||
}
|
||||
208
plugins/dsh-mail-bridge/lib/bounded.js
Normal file
208
plugins/dsh-mail-bridge/lib/bounded.js
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
|
||||
*
|
||||
* # 为什么需要它
|
||||
*
|
||||
* 桥是**常驻进程**(pi 的守护进程能跑几十天,opencode/DSH 的插件跟着平台一起活)。
|
||||
* 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部
|
||||
* 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。
|
||||
*
|
||||
* 单条成本很小(uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆
|
||||
* 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目,
|
||||
* 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现,
|
||||
* 只在生产上跑了几周后表现为「重启一下就好了」。
|
||||
*
|
||||
* # 淘汰策略:丢最久没被访问的
|
||||
*
|
||||
* JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。
|
||||
* 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 ——
|
||||
* 被淘汰的总是「很久没人问过」的那些。
|
||||
*
|
||||
* # 上限分表定义,因为丢一条的后果差别很大
|
||||
*
|
||||
* - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种
|
||||
* 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级,
|
||||
* 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。
|
||||
*
|
||||
* - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。
|
||||
* 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived`
|
||||
* 主动清理,让上限只当兜底。
|
||||
*
|
||||
* # 不要用它装「还在等结果的东西」
|
||||
*
|
||||
* 待决权限询问(opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`)
|
||||
* 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** ——
|
||||
* 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件
|
||||
* 时 fail closed),不该套上界。上界只适合「记录已经发生过的事实」的表。
|
||||
*
|
||||
* 三平台共用,必须逐字节相同(deploy/check-shared-libs.sh 校验)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 已投递邮件 id 的记忆上限。
|
||||
*
|
||||
* 2000 覆盖的是去重真正需要的时间窗:SSE 重放最多回放服务端环形缓冲的 500 条
|
||||
* 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。
|
||||
*/
|
||||
export const MAX_TRACKED_MAILS = 2000;
|
||||
|
||||
/**
|
||||
* 会话级映射的条目上限。
|
||||
*
|
||||
* 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的
|
||||
* 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。
|
||||
*
|
||||
* 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性
|
||||
* 时机;上限兜的是「一直不归档」。
|
||||
*/
|
||||
export const MAX_TRACKED_SESSIONS = 500;
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
const n = Number(limit);
|
||||
// 上限必须是正整数:0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部
|
||||
// 失效且不报错),NaN 会让 while 条件恒假(退化成无界)。两种都是静默的
|
||||
// 错误行为,不如当场拒绝。
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`);
|
||||
}
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Map,超过上限时丢弃最久未访问的条目。
|
||||
*
|
||||
* 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖
|
||||
* 「这张表是有界的」这个必须被看见的事实。
|
||||
*/
|
||||
export class BoundedMap {
|
||||
/** @param {number} limit 条目上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Map<any, any>} */
|
||||
this.map = new Map();
|
||||
/** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取值并把该键移到队尾。
|
||||
*
|
||||
* 读也算访问:一条会话只要还在收信就会被反复 get,不刷新的话它会因为
|
||||
* 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。
|
||||
*/
|
||||
get(key) {
|
||||
if (!this.map.has(key)) return undefined;
|
||||
const value = this.map.get(key);
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */
|
||||
peek(key) {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
// 已存在时先删:Map 的 set 不改变已有键的位置,不删就刷不了活跃度。
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
while (this.map.size > this.limit) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
this.map.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
return this.map.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this.map.keys();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.map.values();
|
||||
}
|
||||
|
||||
entries() {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.map[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有界 Set,超过上限时丢弃最久未访问的成员。
|
||||
*
|
||||
* `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着
|
||||
* 「刚被去重挡下的那封」会留得更久,正合语义。
|
||||
*/
|
||||
export class BoundedSet {
|
||||
/** @param {number} limit 成员上限 */
|
||||
constructor(limit) {
|
||||
this.limit = normalizeLimit(limit);
|
||||
/** @type {Set<any>} */
|
||||
this.set = new Set();
|
||||
this.evicted = 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.set.size;
|
||||
}
|
||||
|
||||
has(value) {
|
||||
if (!this.set.has(value)) return false;
|
||||
this.set.delete(value);
|
||||
this.set.add(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 判断存在但**不**刷新顺序。 */
|
||||
peek(value) {
|
||||
return this.set.has(value);
|
||||
}
|
||||
|
||||
add(value) {
|
||||
if (this.set.has(value)) this.set.delete(value);
|
||||
this.set.add(value);
|
||||
while (this.set.size > this.limit) {
|
||||
const oldest = this.set.values().next().value;
|
||||
this.set.delete(oldest);
|
||||
this.evicted++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(value) {
|
||||
return this.set.delete(value);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set.clear();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this.set.values();
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this.set[Symbol.iterator]();
|
||||
}
|
||||
}
|
||||
10
plugins/dsh-mail-bridge/lib/catchup.d.ts
vendored
10
plugins/dsh-mail-bridge/lib/catchup.d.ts
vendored
@ -13,8 +13,16 @@ export interface CatchupEvent {
|
||||
|
||||
export declare function mailToEvent(mail: any): CatchupEvent;
|
||||
|
||||
/**
|
||||
* `seen` 只被用来做 `has()` 去重查询(见 lib/catchup.js)。
|
||||
*
|
||||
* 声明成结构类型而不是 `Set<string>`:调用方传的是 `BoundedSet`(有界去重表,
|
||||
* 见 lib/bounded.js),它故意只实现桥真正用到的方法 —— 不做 Set 的完整替身,
|
||||
* 那样会掩盖「这张表是有界的」这个必须被看见的事实。把这里写成 `Set` 会逼着
|
||||
* BoundedSet 去补 forEach/entries/keys 一堆没人调的成员。
|
||||
*/
|
||||
export declare function selectCatchup(
|
||||
mails: any,
|
||||
seen: Set<string> | undefined,
|
||||
seen: { has(value: string): boolean } | undefined,
|
||||
max?: number,
|
||||
): CatchupEvent[];
|
||||
|
||||
8
plugins/dsh-mail-bridge/lib/relay-dedup.d.ts
vendored
8
plugins/dsh-mail-bridge/lib/relay-dedup.d.ts
vendored
@ -1,9 +1,15 @@
|
||||
import type { BoundedMap } from './bounded.js';
|
||||
|
||||
export interface RelayRecord {
|
||||
names: Set<string>;
|
||||
replyTos: Set<string>;
|
||||
}
|
||||
|
||||
export declare const explicitSends: Map<string, RelayRecord>;
|
||||
/**
|
||||
* 有界(见 bounded.js):清理路径都要求「这个会话之后还有事发生」,
|
||||
* 而只发过一次信就没动静的会话会让条目永久留下。
|
||||
*/
|
||||
export declare const explicitSends: BoundedMap<string, RelayRecord>;
|
||||
|
||||
export function addrName(addr: string): string;
|
||||
export function noteExplicitSend(sessionID: string | undefined, to: string, replyTo: string): void;
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
|
||||
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
|
||||
|
||||
import { BoundedMap, MAX_TRACKED_SESSIONS } from './bounded.js';
|
||||
|
||||
/** 取三维地址的名字段:admin@root.alias -> admin */
|
||||
export function addrName(addr) {
|
||||
return String(addr || "").split("@")[0].trim();
|
||||
@ -25,8 +27,18 @@ export function addrName(addr) {
|
||||
*
|
||||
* 窗口是「一轮」:deliverMail 投递新邮件时清空(新一轮开始),
|
||||
* relaySummary 用完即清。
|
||||
*
|
||||
* # 为什么仍要有界
|
||||
*
|
||||
* 上面那些清理路径都要求「这个会话之后还有事发生」。一条只发过一次信、
|
||||
* 之后既没有新邮件也没有 idle 的会话(模型自己发完就没动静了,或者插件在
|
||||
* 那一轮之后重连),条目就永久留下。桥是常驻进程,这种残留会一直累积。
|
||||
*
|
||||
* 淘汰是安全的:条目的语义是「本轮已经亲手回过」,而「本轮」是分钟级的。
|
||||
* 丢掉一条很久以前的记录最坏结果是那条会话下一次 idle 时多转一封总结,
|
||||
* 而服务端的 relay_key 幂等还会兜一层。
|
||||
*/
|
||||
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
|
||||
export const explicitSends = new BoundedMap(MAX_TRACKED_SESSIONS); // opencode session id -> { names:Set, replyTos:Set }
|
||||
|
||||
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
|
||||
export function noteExplicitSend(sessionID, to, replyTo) {
|
||||
|
||||
152
plugins/dsh-mail-bridge/test/bounded.test.mjs
Normal file
152
plugins/dsh-mail-bridge/test/bounded.test.mjs
Normal file
@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
|
||||
|
||||
// ─── 上限常量 ───
|
||||
|
||||
test('两个上限的相对大小编码了「丢一条的后果」', () => {
|
||||
// 会话级映射丢一条会让那条会话失去平台侧上下文(真的行为退化),
|
||||
// 而 deliveredMails 丢一条只是理论上可能重复投递一封几千封之前的邮件。
|
||||
// 所以邮件窗口可以给得比会话映射宽。
|
||||
assert.ok(MAX_TRACKED_MAILS >= MAX_TRACKED_SESSIONS,
|
||||
'已投递邮件的窗口应当比会话映射更宽(它的淘汰代价更小)');
|
||||
assert.ok(MAX_TRACKED_SESSIONS > 0);
|
||||
});
|
||||
|
||||
// ─── BoundedMap ───
|
||||
|
||||
test('BoundedMap 未达上限时与普通 Map 行为一致', () => {
|
||||
const m = new BoundedMap(10);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 1);
|
||||
assert.equal(m.get('b'), 2);
|
||||
assert.equal(m.has('a'), true);
|
||||
assert.equal(m.has('zzz'), false);
|
||||
assert.equal(m.get('zzz'), undefined);
|
||||
assert.equal(m.evicted, 0);
|
||||
});
|
||||
|
||||
test('BoundedMap 超过上限时丢最老的,size 不再增长', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3).set('d', 4);
|
||||
assert.equal(m.size, 3, '上限之后 size 必须封顶 —— 这正是泄露的反面');
|
||||
assert.equal(m.has('a'), false, 'a 是最老的,应当被淘汰');
|
||||
assert.deepEqual([...m.keys()], ['b', 'c', 'd']);
|
||||
assert.equal(m.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 get 刷新活跃度,长期被读的键不会被淘汰', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.get('a'); // a 变成最新
|
||||
m.set('d', 4); // 淘汰最老的 —— 现在是 b,不是 a
|
||||
assert.equal(m.has('a'), true, '读也算访问:还在收信的会话不该因为建得早被丢');
|
||||
assert.equal(m.has('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedMap 的 peek 不刷新活跃度', () => {
|
||||
const m = new BoundedMap(3);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
m.peek('a');
|
||||
m.set('d', 4);
|
||||
assert.equal(m.has('a'), false, 'peek 是「只看一眼」,不该改变淘汰顺序');
|
||||
});
|
||||
|
||||
test('BoundedMap 重复 set 同一个键只占一个位置且刷新顺序', () => {
|
||||
const m = new BoundedMap(2);
|
||||
m.set('a', 1).set('b', 2).set('a', 9);
|
||||
assert.equal(m.size, 2);
|
||||
assert.equal(m.get('a'), 9);
|
||||
m.set('c', 3);
|
||||
assert.equal(m.has('b'), false, 'a 被重新 set 过,b 才是最老的');
|
||||
assert.equal(m.has('a'), true);
|
||||
});
|
||||
|
||||
test('BoundedMap 支持 delete / clear / 迭代', () => {
|
||||
const m = new BoundedMap(5);
|
||||
m.set('a', 1).set('b', 2);
|
||||
assert.equal(m.delete('a'), true);
|
||||
assert.equal(m.delete('a'), false);
|
||||
assert.deepEqual([...m.entries()], [['b', 2]]);
|
||||
assert.deepEqual([...m.values()], [2]);
|
||||
assert.deepEqual([...m], [['b', 2]]);
|
||||
m.clear();
|
||||
assert.equal(m.size, 0);
|
||||
});
|
||||
|
||||
// ─── BoundedSet ───
|
||||
|
||||
test('BoundedSet 超过上限时丢最老的成员', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('m1').add('m2').add('m3').add('m4');
|
||||
assert.equal(s.size, 3);
|
||||
assert.equal(s.peek('m1'), false);
|
||||
assert.deepEqual([...s.values()], ['m2', 'm3', 'm4']);
|
||||
assert.equal(s.evicted, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 的 has 刷新活跃度', () => {
|
||||
const s = new BoundedSet(3);
|
||||
s.add('a').add('b').add('c');
|
||||
assert.equal(s.has('a'), true);
|
||||
s.add('d');
|
||||
assert.equal(s.peek('a'), true, '刚被去重挡下的那封应当留得更久');
|
||||
assert.equal(s.peek('b'), false);
|
||||
});
|
||||
|
||||
test('BoundedSet 重复 add 不占额外位置', () => {
|
||||
const s = new BoundedSet(2);
|
||||
s.add('a').add('a').add('a');
|
||||
assert.equal(s.size, 1);
|
||||
});
|
||||
|
||||
test('BoundedSet 支持 delete / clear / 迭代,且能喂给 new Set()', () => {
|
||||
const s = new BoundedSet(5);
|
||||
s.add('a').add('b');
|
||||
assert.equal(s.delete('a'), true);
|
||||
assert.deepEqual([...s], ['b']);
|
||||
// pool.mailDrivenIDs() 会 `new Set(retired)` —— 少了 Symbol.iterator 就炸
|
||||
assert.deepEqual([...new Set(s)], ['b']);
|
||||
s.clear();
|
||||
assert.equal(s.size, 0);
|
||||
});
|
||||
|
||||
// ─── 负向对照:非法上限必须当场报错 ───
|
||||
|
||||
test('上限为 0 时抛错,而不是静默变成一张永远空着的表', () => {
|
||||
// 0 的后果最隐蔽:每次 set 之后立刻把自己淘汰掉,于是去重全部失效,
|
||||
// 邮件被反复投递,而代码里一行错误都不打。
|
||||
assert.throws(() => new BoundedMap(0), RangeError);
|
||||
assert.throws(() => new BoundedSet(0), RangeError);
|
||||
});
|
||||
|
||||
test('上限为 NaN / 负数 / 非数字时抛错,而不是退化成无界', () => {
|
||||
for (const bad of [NaN, -1, 'abc', undefined, null]) {
|
||||
assert.throws(() => new BoundedMap(bad), RangeError, `BoundedMap(${String(bad)}) 应当抛错`);
|
||||
assert.throws(() => new BoundedSet(bad), RangeError, `BoundedSet(${String(bad)}) 应当抛错`);
|
||||
}
|
||||
});
|
||||
|
||||
test('小数上限向下取整', () => {
|
||||
const m = new BoundedMap(2.9);
|
||||
assert.equal(m.limit, 2);
|
||||
m.set('a', 1).set('b', 2).set('c', 3);
|
||||
assert.equal(m.size, 2);
|
||||
});
|
||||
|
||||
// ─── 压力:确认 size 真的封顶(这条是「不泄露」的直接断言)───
|
||||
|
||||
test('灌一万条之后 size 仍等于上限', () => {
|
||||
const s = new BoundedSet(100);
|
||||
for (let i = 0; i < 10_000; i++) s.add(`mail-${i}`);
|
||||
assert.equal(s.size, 100);
|
||||
assert.equal(s.evicted, 9900);
|
||||
assert.equal(s.peek('mail-9999'), true, '最新的必须还在');
|
||||
assert.equal(s.peek('mail-0'), false);
|
||||
|
||||
const m = new BoundedMap(100);
|
||||
for (let i = 0; i < 10_000; i++) m.set(`s-${i}`, { n: i });
|
||||
assert.equal(m.size, 100);
|
||||
});
|
||||
192
plugins/dsh-mail-bridge/test/denial-reason.test.mjs
Normal file
192
plugins/dsh-mail-bridge/test/denial-reason.test.mjs
Normal file
@ -0,0 +1,192 @@
|
||||
// 拒绝原因递给模型的语义验证。
|
||||
//
|
||||
// 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})与 opencode(output.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 条应当在写入时被清掉,只剩新的那条');
|
||||
});
|
||||
Reference in New Issue
Block a user