Files
MailUI4Agents/plugins/zcode-mail-bridge/test/bounded.test.mjs
JianFeeeee e0e6f86d94 feat(zcode): AgentMail 的 ZCode 插件 —— MCP 工具面 + 官方宿主启动验证
ZCode 用插件扩展能力(.zcode-plugin/plugin.json 声明 skills/commands/hooks/
mcpServers),所以适配它的正确形状是**插件**而不是又一个独立桥进程。

本提交是第一步:把 AgentMail 的工具面做成 MCP 服务器。

协议层(lib/mcp-rpc.mjs)手写,不引 @modelcontextprotocol/sdk:
协议面只有 initialize / notifications/initialized / tools/list / tools/call,
手写可省掉一条构建链与 1MB 打包产物(与 pi/opencode/dsh 三桥零运行时依赖的
取向一致),并让这一层成为可穷举的纯函数。分帧照官方插件产物实测确认是
换行分隔 JSON(Content-Length 出现 0 次,StdioServerTransport + split("\n"))。

工具面(lib/tools.mjs)与另三个桥**同名同参**,渲染走共用的
addressing/inbox-format/discovery(逐字节同源,已纳入 check-shared-libs.sh)。
测试里有一条断言直接拿 pi 桥的工具名做对照:少一个就让某平台行为与其它平台不同,
那种问题只在单平台复现,排查代价最高。

两处按真实缺陷定的行为:
- 工具失败回 result+isError 而非 JSON-RPC error —— 后者会让模型看不到失败原因,
  只能重试(opencode 连试 6 次发不出附件正是这个后果)
- attachment_ids 声明放宽为 anyOf 数组/字符串并在桥侧归一 —— 模型常写成
  JSON 字符串,服务端严格解码会拒(同样来自 opencode 那次失败)

入口 mcp/server.mjs 修掉一个真实缺陷:stdin 关闭即 process.exit 会杀掉在途请求,
表现为「协议全对但访问网关的调用完全没有响应」。现按在途计数 drain,
且把 stdout 写入也计入,避免最后一条响应卡在缓冲区。

顺带修 check-shared-libs.sh 的一个既有假绿:本机 PATH 上的 diff 是鸿蒙 SDK
工具链的 diff,不认 -q 且对不同的文件仍返回 0 —— 于是该检查器**一直是永真输出**。
改用 cmp -s,并加自检(判据本身必须先被证明能发现差异)。反向验证:
让 zcode 或 pi 的共用模块分叉,检查器都正确报错并返回 1。

验证:
- 单元 33 项 + 继承共用测试 87 项 = 120/120
- `zcode plugins list` → agentmail@inline [enabled],mcp: plugin:agentmail:agentmail
- 经官方 `node zcode.cjs __zcode-plugin-host <server.mjs>` 启动 → 握手与 tools/list 正常
- 真实网关调用:以 zcode 身份 read_inbox / suggest_address / list_contacts 均返回
2026-09-12 13:47:51 +08:00

153 lines
5.5 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.

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);
});