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 均返回
This commit is contained in:
2026-09-12 13:47:51 +08:00
parent 9204f019a1
commit e0e6f86d94
19 changed files with 3011 additions and 8 deletions

View File

@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatAddress,
roleOf,
replyAddressFor,
selfAddressFor,
participantsOfMail,
} from '../lib/addressing.js';
// 地址拼错不会报错,只会投到别处 —— 所以这一组测试全部落在
// 「拼出来的东西还能不能被正确解析回三段」上。
test('formatAddress: 空 path 仍保留 @ 与 .', () => {
// 生产事故:朴素拼接得到 admin.silent-harbor没有 @
// 整串被 ParseAddress 当成名字session 位静默丢失。
assert.equal(formatAddress('admin', '', 'silent-harbor'), 'admin@.silent-harbor');
});
test('formatAddress: 省略 session 位', () => {
assert.equal(formatAddress('dsh', '/home/program/agentmail', ''), 'dsh@/home/program/agentmail');
// 名字与 path 都有但都不带会话 → 默认会话语义
assert.equal(formatAddress('dsh', '', ''), 'dsh');
});
test('formatAddress: path 含 . 与 / 时仍按最后一个 . 切', () => {
// path 里允许 . 与 /,切分靠最后一个 . —— 拼出来的必须满足这个约定
const addr = formatAddress('bot', '/srv/app.v2', 'fix-leak');
assert.equal(addr, 'bot@/srv/app.v2.fix-leak');
assert.equal(addr.slice(addr.lastIndexOf('.') + 1), 'fix-leak');
});
test('formatAddress: 名字为空返回空串而不是残缺地址', () => {
// 返回 "@/path.alias" 会被投递端当成缺名字报错,
// 但那是在很后面才发现;这里直接给空串让调用方立刻看出没法拼。
assert.equal(formatAddress('', '/p', 'a'), '');
assert.equal(formatAddress(null, '/p', 'a'), '');
});
test('formatAddress: 去掉首尾空白', () => {
assert.equal(formatAddress(' dsh ', ' /home ', ' alias '), 'dsh@/home.alias');
});
const ccMail = {
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
};
test('roleOf: 区分主收件人与抄送方', () => {
// 被抄送方与主收件人职责不同:线上那封联调邮件里 dsh 负责汇报、
// opencode 只提供信息。不区分身份两方都会以为自己是负责人。
assert.equal(roleOf(ccMail, 'dsh'), 'to');
assert.equal(roleOf(ccMail, 'opencode'), 'cc');
assert.equal(roleOf(ccMail, 'someone-else'), 'unknown');
});
test('roleOf: 名字为空时不猜', () => {
assert.equal(roleOf(ccMail, ''), 'unknown');
assert.equal(roleOf(ccMail, undefined), 'unknown');
});
test('replyAddressFor: 用会话别名而非原地址的 .new', () => {
// 关键回归:把 .new 原样当回信地址会再建一条平行会话。
const addr = replyAddressFor(ccMail);
assert.equal(addr, 'admin@.silent-harbor');
assert.ok(!addr.endsWith('.new'), '回信地址不得以 .new 结尾');
});
test('replyAddressFor: 发件人一侧不带 path', () => {
// Agent 回信时 from_workspace 存的是 Agent 名而不是路径,
// 拿它拼会得到 dsh@dsh.alias —— 投不出去。
const mail = { from_name: 'dsh', from_workspace: 'dsh', session_alias: 'x' };
assert.equal(replyAddressFor(mail), 'dsh@.x');
});
test('replyAddressFor: 无别名时退回默认会话形式', () => {
const mail = { from_name: 'admin', session_alias: '' };
const addr = replyAddressFor(mail);
assert.equal(addr, 'admin');
// 调用方靠有没有 . 判断这是不是「投回同一条会话」
assert.ok(!addr.includes('.'), '默认会话形式不含 session 位');
});
test('selfAddressFor: 抄送方取自己那个地址的 path', () => {
// to_workspace 是主收件人的工作目录。抄送方拿它当自己的 path
// 「我是谁」这句话就指向了别人的目录。
assert.equal(selfAddressFor(ccMail, 'opencode'), 'opencode@/home.silent-harbor');
assert.equal(selfAddressFor(ccMail, 'dsh'), 'dsh@/home/program/llmsproxy.silent-harbor');
});
test('participantsOfMail: 抄送方的 path 是自己那个', () => {
const parts = participantsOfMail(ccMail, 'dsh');
const byName = Object.fromEntries(parts.map(p => [p.name, p]));
assert.equal(byName.opencode.path, '/home');
assert.equal(byName.opencode.address, 'opencode@/home.silent-harbor');
assert.equal(byName.dsh.path, '/home/program/llmsproxy');
// 发件人 path 留空,理由同 replyAddressFor
assert.equal(byName.admin.address, 'admin@.silent-harbor');
});
test('participantsOfMail: 地址一律用会话别名,不带 .new', () => {
// cc_list 里原本记的是 opencode@/home.new。参与方地址必须换成别名
// 否则「回给抄收方」这个动作每次都会新开会话。
for (const p of participantsOfMail(ccMail, 'dsh')) {
assert.ok(!p.address.endsWith('.new'), `${p.name} 的地址仍是 .new: ${p.address}`);
}
});
test('participantsOfMail: 自己被标记而不是被剔除', () => {
// 剔掉的话模型无法确认这封信是不是也发给了自己,
// 也就无法判断自己该不该回。
const parts = participantsOfMail(ccMail, 'opencode');
const me = parts.find(p => p.name === 'opencode');
assert.ok(me, '自己应出现在参与方列表里');
assert.equal(me.is_self, true);
assert.equal(parts.filter(p => p.is_self).length, 1);
});
test('participantsOfMail: 角色齐全且顺序为 from → to → cc', () => {
// 主收件人稳定排在抄送方之前,模型据此判断谁是负责人、谁是配合方
const parts = participantsOfMail(ccMail, 'dsh');
assert.deepEqual(parts.map(p => p.role), ['from', 'to', 'cc']);
});
test('participantsOfMail: 无抄送时只有两方', () => {
const mail = { from_name: 'admin', to_name: 'dsh', to_workspace: '/w', session_alias: 'a' };
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
});
test('participantsOfMail: 跳过空名字条目', () => {
// cc_list 里出现空对象(历史数据或解析残缺)不该产出一个 address 为空的参与方
const mail = {
from_name: 'admin', to_name: 'dsh', to_workspace: '/w',
cc_list: [{ name: '', path: '/x' }, {}],
session_alias: 'a',
};
const parts = participantsOfMail(mail, 'dsh');
assert.equal(parts.length, 2);
for (const p of parts) assert.notEqual(p.address, '');
});

View File

@ -0,0 +1,117 @@
/**
* normalizeAttachmentIDs 的判据。
*
* 每组用例都对应一种**模型真的会写出来的形状**,不是凑覆盖率。
* 尤其是第二条它是生产事故的原始形状opencode 连试 6 次、最后放弃整个任务),
* 如果哪天有人把 JSON 字符串分支删掉,这条会立刻红。
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeAttachmentIDs } from '../lib/attachment-ids.js';
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(` 通过 ${name}`);
} else {
fail++;
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
}
};
const ID = '10e73e9f-c2a9-4226-bdb5-34ef1b340eb8';
const ID2 = '5f1c2b3a-1111-2222-3333-444455556666';
// 数组:契约要求的形状
{
const got = normalizeAttachmentIDs([ID]);
check('数组原样通过', got.length === 1 && got[0] === ID, JSON.stringify(got));
const two = normalizeAttachmentIDs([ID, ID2]);
check('多元素数组保序', two.length === 2 && two[0] === ID && two[1] === ID2, JSON.stringify(two));
}
// JSON 数组字符串:**事故形状**
{
const got = normalizeAttachmentIDs(JSON.stringify([ID]));
check(
'JSON 数组字符串被解析(事故形状)',
got.length === 1 && got[0] === ID,
`得到 ${JSON.stringify(got)} —— 这一条红了就说明事故会复发`
);
const two = normalizeAttachmentIDs(JSON.stringify([ID, ID2]));
check('多元素 JSON 字符串保序', two.length === 2 && two[1] === ID2, JSON.stringify(two));
}
// 单个 id模型常见的偷懒写法拒绝它只会换来一次重试
{
const got = normalizeAttachmentIDs(ID);
check('裸单个 id 被接受', got.length === 1 && got[0] === ID, JSON.stringify(got));
check(
'裸单个 id 不会被误切uuid 含 - 但不含 , 与空白)',
got[0] === ID,
got[0]
);
}
// 逗号 / 空白分隔
{
const got = normalizeAttachmentIDs(`${ID}, ${ID2}`);
check('逗号分隔被切开', got.length === 2 && got[1] === ID2, JSON.stringify(got));
const got2 = normalizeAttachmentIDs(`${ID} ${ID2}`);
check('空白分隔被切开', got2.length === 2 && got2[1] === ID2, JSON.stringify(got2));
}
// 杂质:丢坏的留好的(三个里坏一个,不该变成一个都不发)
{
const got = normalizeAttachmentIDs([null, ID, 3, '', undefined, ID2]);
check(
'混杂 null/数字/空串时只保留合法 id',
got.length === 2 && got[0] === ID && got[1] === ID2,
JSON.stringify(got)
);
const got2 = normalizeAttachmentIDs(JSON.stringify([null, ID]));
check('JSON 字符串里的杂质同样被过滤', got2.length === 1 && got2[0] === ID, JSON.stringify(got2));
}
// 空值:不能返回 null调用方会当数组用
{
for (const v of [null, undefined, '', ' ', []]) {
const got = normalizeAttachmentIDs(v);
check(
`空值 ${JSON.stringify(v)} → 空数组(且不是 null`,
Array.isArray(got) && got.length === 0,
JSON.stringify(got)
);
}
}
// 非法输入不该抛异常:抛出去会让整个 send_mail 失败,
// 而那本来只需要「这个字段作废」
{
let threw = null;
try {
normalizeAttachmentIDs({ not: 'a list' });
normalizeAttachmentIDs(42);
normalizeAttachmentIDs('[坏 JSON');
} catch (e) {
threw = e;
}
check('非法输入不抛异常', threw === null, String(threw));
}
// 反向对照:坏 JSON 字符串不该被当成 id 原样带走
{
const got = normalizeAttachmentIDs('[坏 JSON');
check(
'坏 JSON 字符串不产生伪造的 id',
!got.includes('[坏 JSON'),
JSON.stringify(got)
);
}
console.log(`\n附件 id 归一:${pass} 通过,${fail} 失败`);
process.exit(fail === 0 ? 0 : 1);

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

View File

@ -0,0 +1,218 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread,
} from '../lib/discovery.js';
// 这一组渲染的唯一目的是让模型**不要自己拼地址**。
// 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。
test('renderNameSuggestions 只给名字并指向下一步', () => {
// 此时还不知道 path 与 session硬拼裸名字地址会投到「默认会话」——
// 那不一定是调用方想要的那条。
const got = renderNameSuggestions(['opencode', 'admin']);
assert.match(got, /opencode/);
assert.match(got, /admin/);
assert.match(got, /suggest_address/, '要告诉模型下一步查什么');
});
test('renderNameSuggestions 空列表给明确文案', () => {
assert.match(renderNameSuggestions([]), /没有可投递的收件人/);
assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/);
});
test('renderPathSuggestions 空列表要说清「仍然能发」', () => {
// 不解释的话模型会卡在这一步,或者编一个路径出来。
const got = renderPathSuggestions([], 'admin');
assert.match(got, /可以留空/);
assert.match(got, /admin/);
});
test('renderPathSuggestions 列出目录并指向下一步', () => {
const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode');
assert.match(got, /\/home\/program\/agentmail/);
assert.match(got, /最近使用/);
assert.match(got, /suggest_address\(name="opencode", path="/);
});
const sessionData = {
kind: 'session',
suggestions: ['silent-harbor', 'happy-tiger', 'new'],
addresses: [
'opencode@/home.silent-harbor',
'opencode@/home.happy-tiger',
'opencode@/home.new',
],
candidates: [
{ alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 },
{ alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 },
{ alias: 'new', title: '新建会话', source: 'new' },
],
};
test('renderSessionSuggestions 用服务端拼好的完整地址', () => {
// 插件自己拼过一次,拼错了(空 path 时漏掉 @。addresses 与 suggestions
// 同序由服务端保证,直接用。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /opencode@\/home\.happy-tiger/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('renderSessionSuggestions 带出标题与未读数', () => {
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
assert.match(got, /联调 llmsproxy/);
assert.match(got, /2 封未读/);
});
test('不变量new 不与已存在会话混列,且带警告', () => {
// new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
const got = renderSessionSuggestions(sessionData, 'opencode', '/home');
const lines = got.split('\n');
const newLineIdx = lines.findIndex(l => l.includes('.new'));
const harborIdx = lines.findIndex(l => l.includes('silent-harbor'));
assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后');
assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示');
});
test('renderSessionSuggestions 无已存在会话时引导命名', () => {
// 这是关键引导:开新会话时传 session_alias之后才能按名字续谈。
// 不传的话服务端会自动命名,但模型不知道那个名字。
const got = renderSessionSuggestions(
{ suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] },
'dsh', '/tmp',
);
assert.match(got, /还没有可续谈的会话/);
assert.match(got, /session_alias/);
});
const participantData = {
session_id: 'f3d824ce',
session_alias: 'silent-harbor',
participants: [
{ name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' },
{ name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' },
{ name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' },
],
};
test('renderParticipants 给出每个参与方的地址', () => {
const got = renderParticipants(participantData);
assert.match(got, /opencode@\/home\.silent-harbor/);
assert.match(got, /admin@\.silent-harbor/);
assert.match(got, /原样填进 send_mail 的 to/);
});
test('不变量:标出「尚未回应」的人', () => {
// mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件,
// 正是为了让这个判断成立。
const got = renderParticipants(participantData);
const line = got.split('\n').find(l => l.includes('opencode'));
assert.match(line, /尚未回应/);
// 自己不该被标「尚未回应」—— 自己正在处理这封
const selfLine = got.split('\n').find(l => l.includes('dsh'));
assert.ok(!selfLine.includes('尚未回应'));
assert.match(selfLine, /就是你/);
});
test('renderParticipants 用中文角色标签', () => {
// 模型读到「抄送方」比读到 cc 更容易判对分工。
const got = renderParticipants(participantData);
assert.match(got, /抄送方/);
assert.match(got, /发件人/);
});
test('renderParticipants 无地址时说明原因', () => {
const got = renderParticipants({
session_alias: '',
participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }],
});
assert.match(got, /尚未命名/);
});
test('renderParticipants 空会话不崩', () => {
assert.match(renderParticipants({ participants: [] }), /还没有参与方/);
assert.match(renderParticipants({}), /还没有参与方/);
});
test('renderContacts 未读优先排序', () => {
// 模型问「我还有什么没处理」时,有未读的那些才是答案。
const got = renderContacts({
contacts: [
{ address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' },
{ address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' },
],
});
const lines = got.split('\n').filter(l => l.startsWith('- '));
assert.match(lines[0], /b@\.y/, '有未读的应排在最前');
assert.match(lines[0], /3 封未读/);
});
test('renderContacts 带出剩余预算', () => {
const got = renderContacts({
contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }],
});
assert.match(got, /剩 3\/20 个来回/);
});
test('renderContacts 未命名会话说明只能 reply_to', () => {
const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] });
assert.match(got, /reply_to/);
});
test('renderContacts 空列表', () => {
assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/);
});
const threadData = {
anchor_mail_id: 'm-2',
total: 3,
hidden: 1,
nodes: [
{ mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 },
{ mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 },
{ mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true },
],
};
test('renderThread 用缩进表示层级', () => {
const got = renderThread(threadData, 'dsh');
const lines = got.split('\n');
const l1 = lines.find(l => l.includes('m-1'));
const l2 = lines.find(l => l.includes('m-2'));
assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进');
});
test('不变量detached 必须标出来', () => {
// 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。
const got = renderThread(threadData, 'dsh');
const line = got.split('\n').find(l => l.includes('m-3'));
assert.match(line, /父邮件无权查看/);
});
test('renderThread 标出自己发的与当前这封', () => {
const got = renderThread(threadData, 'dsh');
assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/);
assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/);
});
test('renderThread 报告不可见数量', () => {
// 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 ——
// 不说的话模型会以为自己看到了全貌。
assert.match(renderThread(threadData), /另有 1 封无权查看/);
});
test('renderThread 有更多时给出 offset', () => {
const got = renderThread({ ...threadData, has_more: true, next_offset: 60 });
assert.match(got, /offset=60/);
});
test('renderThread 空线索不崩', () => {
assert.match(renderThread({ nodes: [] }), /没有可见的邮件/);
assert.match(renderThread({}), /没有可见的邮件/);
});

View File

@ -0,0 +1,266 @@
/**
* 收件箱渲染与已读策略的测试。
*
* 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释):
* 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信;
* status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatSize,
renderMail,
renderInbox,
idsToMarkRead,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
const mail = (over = {}) => ({
mail_id: 'm-1',
from_name: 'admin',
subject: '缓存选型',
status: 'unread',
session_alias: 'brisk-harbor',
body_preview: '我们需要评估一下缓存层',
...over,
});
// ─── formatSize ───
test('formatSize 分档', () => {
assert.equal(formatSize(512), '512 B');
assert.equal(formatSize(2048), '2.0 KB');
assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB');
});
test('formatSize 容错', () => {
assert.equal(formatSize(undefined), '?');
assert.equal(formatSize(NaN), '?');
assert.equal(formatSize('x'), '?');
});
// ─── renderMail ───
test('renderMail 带出 mail_id 与会话别名', () => {
const got = renderMail(mail());
assert.match(got, /邮件 ID: m-1/);
assert.match(got, /#brisk-harbor/);
assert.match(got, /admin: 缓存选型/);
});
test('无别名时显示「未命名」而不是空', () => {
const got = renderMail(mail({ session_alias: '' }));
assert.match(got, /#未命名/);
});
test('不变量:附件必须带 attachment_id', () => {
// 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。
const got = renderMail(mail({
attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }],
}));
assert.match(got, /id=att-9/, `附件行缺 id${got}`);
assert.match(got, /report\.md/);
assert.match(got, /2\.0 KB/);
assert.match(got, /download_attachment/, '要提示模型用哪个工具下载');
});
test('多个附件都列出来', () => {
const got = renderMail(mail({
attachments: [
{ filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' },
{ filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' },
],
}));
assert.match(got, /att-1/);
assert.match(got, /att-2/);
});
test('不变量:抄送人要显示出来', () => {
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
const got = renderMail(mail({
cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }],
}));
assert.match(got, /抄送/);
assert.match(got, /opencode@\/home\.new/, '应优先用 raw带路径与会话段');
});
test('无抄送时不出现抄送行', () => {
assert.ok(!renderMail(mail()).includes('抄送'));
assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送'));
});
test('正文优先取 body_preview缺失时退回 body', () => {
assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/);
assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/);
});
test('正文按 bodyLimit 截断', () => {
const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50);
const line = got.split('\n').find(l => l.startsWith('内容: '));
assert.equal(line.length, '内容: '.length + 50);
});
test('renderMail 容错:字段全缺不崩', () => {
const got = renderMail({});
assert.match(got, /unknown/);
const got2 = renderMail(undefined);
assert.equal(typeof got2, 'string');
});
test('附件字段不是数组时忽略', () => {
const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' }));
assert.ok(!got.includes('附件:'));
assert.ok(!got.includes('抄送'));
});
// ─── 收件人与身份(只有知道自己是谁才能判定)───
test('不变量:收件人要显示出来', () => {
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
});
test('收件人无工作目录时只显名字', () => {
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
assert.match(got, /收件人: admin$/m);
});
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
const got = renderMail(mail({ to_name: 'dsh' }));
assert.ok(!got.includes('你的身份'));
});
test('不变量:区分收件人与抄送方身份', () => {
// 两者职责不同。不区分的话两方都会以为自己是负责人,
// 或者都以为自己只是旁观者。
const m = mail({
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
});
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
// 不相关的名字不编造身份
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
});
// ─── 可投递地址(「精准发信」的关键)───
const joint = () => mail({
mail_id: 'm-7',
from_name: 'admin',
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', session: 'new', raw: 'opencode@/home.new' }],
session_alias: 'silent-harbor',
});
test('不变量:给出每个参与方的可投递地址', () => {
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`
// 而那个地址回过去只会再建一条平行会话。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /可投递地址/);
assert.match(got, /opencode@\/home\.silent-harbor抄送方/);
assert.match(got, /admin@\.silent-harbor发件人/);
});
test('不变量:可投递地址里绝不出现 .new', () => {
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
// 把它当回信地址会让双方各说各话。
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(line, '应有可投递地址行');
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
});
test('可投递地址不列自己', () => {
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
});
test('同时给出 reply_to 这条更稳的路', () => {
// 地址可能拼错reply_to 不会 —— 两条路都告诉模型。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /reply_to=m-7/);
});
test('无会话别名时不给地址(宁可不给不可给错)', () => {
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
// 实际指向默认会话的地址,比不给危险。
const got = renderMail(mail({
to_name: 'dsh', session_alias: '',
cc_list: [{ name: 'opencode', path: '/home' }],
}), 200, 'dsh');
assert.ok(!got.includes('可投递地址'));
});
test('renderInbox 透传 selfName', () => {
const got = renderInbox([joint()], 200, 'opencode');
assert.match(got, /你的身份: 抄送方/);
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor收件人/);
});
// ─── renderInbox ───
test('renderInbox 空收件箱给明确文案', () => {
assert.equal(renderInbox([]), '收件箱为空。');
assert.equal(renderInbox(undefined), '收件箱为空。');
assert.equal(renderInbox(null), '收件箱为空。');
});
test('renderInbox 用空行分隔多封', () => {
const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/);
});
// ─── idsToMarkRead ───
test('不变量:只标本次列出的那些', () => {
// limit 之外的还没看过,一并标掉等于让它们凭空消失。
const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.deepEqual(ids, ['a', 'b']);
});
test('不变量status=all 时不标记', () => {
// 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件
// 混在里面认不出来。
assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []);
});
test('status 省略时按默认unread标记', () => {
assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']);
});
test('idsToMarkRead 过滤掉无 id 的条目', () => {
const ids = idsToMarkRead('unread', [
mail({ mail_id: 'a' }),
mail({ mail_id: '' }),
mail({ mail_id: undefined }),
{ },
]);
assert.deepEqual(ids, ['a']);
});
test('idsToMarkRead 容错非数组', () => {
assert.deepEqual(idsToMarkRead('unread', undefined), []);
assert.deepEqual(idsToMarkRead('unread', 'oops'), []);
});
// ─── 默认值 ───
test('默认只看未读', () => {
// 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。
assert.equal(DEFAULT_INBOX_STATUS, 'unread');
});
test('默认条数是个小数字', () => {
// 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。
assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10);
});

View File

@ -0,0 +1,195 @@
/**
* MCP 协议层的测试。
*
* 这一层是手写的,所以它必须被穷举 —— 否则「工具没出现」「模型收不到错误」
* 这类问题只能连上 ZCode 才能发现,而那时线索要少得多。
*
* 每条断言都对应一个**真实的失败模式**,不是为覆盖率写的。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handleMessage, handleLine, RPC_ERROR } from '../lib/mcp-rpc.mjs';
const TOOLS = [
{ name: 'read_inbox', description: '读收件箱', inputSchema: { type: 'object' } },
{ name: 'send_mail', description: '发信', inputSchema: { type: 'object' } }
];
/** 造一个 ctx`call` 默认成功,可换成抛错来验失败路径。 */
const makeCtx = (impl = async () => '结果文本') => ({
tools: TOOLS,
call: impl
});
test('initialize 回显客户端给的协议版本', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26' } },
makeCtx()
);
assert.equal(out.result.protocolVersion, '2025-03-26');
assert.deepEqual(out.result.capabilities, { tools: { listChanged: false } });
assert.equal(out.result.serverInfo.name, 'agentmail');
});
test('initialize 缺参数时用默认版本兜底,而不是崩', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' }, makeCtx());
assert.ok(out.result.protocolVersion);
});
test('notifications/initialized 不回响应(回了会让后续调用错配)', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', method: 'notifications/initialized' },
makeCtx()
);
assert.equal(out, null);
});
test('任何无 id 的消息都不回响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', method: 'tools/list' }, makeCtx());
assert.equal(out, null);
});
test('tools/list 只暴露 name/description/inputSchema多带的字段会被客户端拒绝', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, makeCtx());
assert.equal(out.result.tools.length, 2);
for (const t of out.result.tools) {
assert.deepEqual(Object.keys(t).sort(), ['description', 'inputSchema', 'name']);
}
});
test('tools/call 成功时回 content 文本数组', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_inbox', arguments: {} } },
makeCtx()
);
assert.deepEqual(out.result, { content: [{ type: 'text', text: '结果文本' }] });
assert.equal(out.result.isError, undefined);
});
test('tools/call 把 arguments 原样交给工具', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = { name, args };
return 'ok';
});
await handleMessage(
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'send_mail', arguments: { to: 'admin@/tmp', subject: 's' } }
},
ctx
);
assert.deepEqual(seen, { name: 'send_mail', args: { to: 'admin@/tmp', subject: 's' } });
});
test('tools/call 缺 arguments 时当空对象,不抛错', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = args;
return 'ok';
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'read_inbox' } },
ctx
);
assert.deepEqual(seen, {});
assert.equal(out.result.isError, undefined);
});
test('★ 工具执行失败回 result+isError不回 JSON-RPC error', async () => {
// 判据的关键:模型必须能看到失败原因。若回 JSON-RPC error
// 客户端只会显示一次协议错误,模型拿不到「为什么失败」,
// 也就无法改正opencode 上连试 6 次发不出附件就是这个后果)。
const ctx = makeCtx(async () => {
throw new Error('HTTP 409附件已随其他邮件发出');
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
ctx
);
assert.equal(out.error, undefined, '不该是 JSON-RPC error');
assert.equal(out.result.isError, true);
assert.match(out.result.content[0].text, /附件已随其他邮件发出/);
});
test('★ 反向对照:成功时绝不带 isError', async () => {
// 与上一条构成对照:同样的入参、同样的方法,只翻转工具行为,
// isError 必须跟着翻转。否则「总是 isError」也会让上一条通过。
const ok = await handleMessage(
{ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx()
);
const bad = await handleMessage(
{ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx(async () => {
throw new Error('x');
})
);
assert.equal(ok.result.isError, undefined);
assert.equal(bad.result.isError, true);
});
test('tools/call 未知工具名回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'not_a_tool' } },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
assert.equal(out.result, undefined);
});
test('tools/call 缺 name 回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 10, method: 'tools/call', params: {} },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
});
test('未知方法回 METHOD_NOT_FOUND', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 11, method: 'x/y' }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.METHOD_NOT_FOUND);
});
test('ping 有响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 12, method: 'ping' }, makeCtx());
assert.deepEqual(out.result, {});
});
test('缺 method 回 INVALID_REQUEST', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 13 }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.INVALID_REQUEST);
});
test('id 原样回显(含 0 与字符串 id', async () => {
for (const id of [0, 'abc', 42]) {
const out = await handleMessage({ jsonrpc: '2.0', id, method: 'ping' }, makeCtx());
assert.equal(out.id, id);
}
});
// ─── handleLine分帧与解析 ───────────────────────────────────────
test('handleLine 空行不产生响应', async () => {
assert.equal(await handleLine('', makeCtx()), null);
assert.equal(await handleLine(' ', makeCtx()), null);
});
test('handleLine 非法 JSON 回带 id=null 的解析错误', async () => {
// 必须回:不回的话客户端会一直等这一条的响应。
const out = await handleLine('{not json', makeCtx());
const parsed = JSON.parse(out);
assert.equal(parsed.error.code, RPC_ERROR.PARSE);
assert.equal(parsed.id, null);
});
test('handleLine 输出是单行(换行会破坏分帧)', async () => {
const out = await handleLine(
JSON.stringify({ jsonrpc: '2.0', id: 14, method: 'tools/call', params: { name: 'read_inbox' } }),
makeCtx(async () => '多行\n文本\n在此')
);
assert.equal(out.includes('\n'), false, '响应里不能有裸换行(应被转义进 JSON 字符串)');
assert.match(JSON.parse(out).result.content[0].text, /多行\n文本/);
});

View File

@ -0,0 +1,244 @@
/**
* 工具层的测试。
*
* 用假客户端,不发真请求 —— 这里要验的是**参数处理与渲染**
* 那才是各平台容易走样的地方(真请求由端到端演练覆盖)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile, writeFile, mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { buildTools, indexTools } from '../lib/tools.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
/** 造一个假客户端:记录调用,按需返回。 */
function fakeClient({ config = [], responses = {} } = {}) {
const calls = [];
return {
calls,
baseURL: 'http://fake',
agentName: 'zcode',
checkConfig: () => config,
async get(path) {
calls.push({ method: 'GET', path });
for (const key of Object.keys(responses)) {
if (path.startsWith(key)) return responses[key];
}
return {};
},
async post(path, body) {
calls.push({ method: 'POST', path, body });
return { mail_id: 'sent-1', session_id: 'sess-1' };
},
async uploadFile() {
return { attachment_id: 'att-1', filename: 'a.txt', size_bytes: 12 };
},
async downloadFile() {
return Buffer.from('hello');
}
};
}
const toolsOf = client => indexTools(buildTools({ client, agentName: 'zcode' }));
test('工具数量与名称稳定(改名会破坏跨平台一致性)', () => {
const t = toolsOf(fakeClient());
assert.deepEqual([...t.keys()].sort(), [
'connect_to_server',
'download_attachment',
'forward_mail',
'list_contacts',
'read_inbox',
'read_mail',
'read_thread',
'send_mail',
'session_participants',
'suggest_address',
'upload_attachment'
]);
});
test('★ 与 pi 桥的工具名逐一对齐(少一个就会让某平台「不会回信」)', async () => {
// 跨平台一致性是被真实问题逼出来的约定:模型在某个平台上找不到
// 熟悉的工具名,行为就与其它平台不同。这条断言让「改名」在 CI 里红,
// 而不是等到某个平台的演练才发现。
const piSrc = await readFile(
join(HERE, '../../pi-mail-bridge/src/tools.mjs'),
'utf8'
).catch(() => null);
if (piSrc === null) {
// pi 桥不在旁边(例如插件被单独拷走)时无法对照 ——
// 明确说明跳过,而不是假装通过。
assert.ok(true, '跳过:找不到 pi 桥源码用于对照');
return;
}
const piNames = new Set([...piSrc.matchAll(/name:\s*'([a-z_]+)'/g)].map(m => m[1]));
const mine = new Set(toolsOf(fakeClient()).keys());
const missing = [...piNames].filter(n => !mine.has(n));
assert.deepEqual(missing, [], `本插件缺少 pi 桥有的工具:${missing.join(', ')}`);
});
// ─── send_mail 的参数处理 ─────────────────────────────────────────
test('★ send_mail 接受 JSON 字符串形式的 attachment_idsopencode 上连试 6 次失败的形状)', async () => {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'admin@/tmp',
subject: 's',
body: 'b',
attachment_ids: '["10e73e9f-1"]' // ← 模型实际会这么写
});
const sent = c.calls.find(x => x.path === '/mail/send');
assert.deepEqual(sent.body.attachment_ids, ['10e73e9f-1']);
});
test('send_mail 也接受数组、单 id、逗号分隔', async () => {
for (const [input, want] of [
[['a', 'b'], ['a', 'b']],
['a', ['a']],
['a, b', ['a', 'b']],
['a b', ['a', 'b']]
]) {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'x@/p',
subject: 's',
body: 'b',
attachment_ids: input
});
assert.deepEqual(c.calls.find(x => x.path === '/mail/send').body.attachment_ids, want);
}
});
test('★ 没有附件时不带 attachment_ids 字段(带空数组会被服务端当「要挂附件」)', async () => {
for (const input of [undefined, null, '', [], ['', null]]) {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'x@/p',
subject: 's',
body: 'b',
attachment_ids: input
});
const body = c.calls.find(x => x.path === '/mail/send').body;
assert.equal('attachment_ids' in body, false, `输入 ${JSON.stringify(input)} 时不该带`);
}
});
test('send_mail 缺必填字段时明确报错(且不发请求)', async () => {
const t = toolsOf(fakeClient());
for (const args of [{}, { to: 'x@/p' }, { to: 'x@/p', subject: 's' }]) {
await assert.rejects(() => t.get('send_mail').run(args), /缺少必填字段/);
}
});
test('send_mail 只透传有值的可选字段', async () => {
const c = fakeClient();
await toolsOf(c).get('send_mail').run({
to: 'x@/p',
subject: 's',
body: 'b',
cc: '',
reply_to: 'm1',
session_alias: '',
max_rounds: 5
});
const body = c.calls.find(x => x.path === '/mail/send').body;
assert.deepEqual(Object.keys(body).sort(), ['body', 'max_rounds', 'reply_to', 'subject', 'to']);
});
// ─── read_inbox ───────────────────────────────────────────────────
test('read_inbox 只把本次列出来的未读标为已读', async () => {
const c = fakeClient({
responses: {
'/mail/inbox': {
mails: [
{ mail_id: 'm1', subject: '一', body: 'x', from: 'pi' },
{ mail_id: 'm2', subject: '二', body: 'y', from: 'dsh' }
]
}
}
});
const out = await toolsOf(c).get('read_inbox').run({});
assert.match(out, /一/);
const mark = c.calls.find(x => x.path === '/mail/read');
assert.ok(mark, '应该标记已读');
assert.deepEqual(mark.body.mail_ids, ['m1', 'm2']);
});
test('read_inbox 空收件箱给出可读文本', async () => {
const c = fakeClient({ responses: { '/mail/inbox': { mails: [] } } });
assert.match(await toolsOf(c).get('read_inbox').run({}), /收件箱为空/);
});
test('★ 标记已读失败不影响读取结果', async () => {
// 正文已经拿到了,代价只是下次重复看到 —— 比丢掉这次读取轻得多。
const c = fakeClient({ responses: { '/mail/inbox': { mails: [{ mail_id: 'm1', subject: '一', body: 'x' }] } } });
c.post = async () => {
throw new Error('500');
};
const out = await toolsOf(c).get('read_inbox').run({});
assert.match(out, /一/);
});
// ─── 配置缺失 ─────────────────────────────────────────────────────
test('★ 未配置密钥时每次调用都明确报错(而不是收到 401 再猜)', async () => {
const c = fakeClient({ config: ['AGENTMAIL_AGENT_KEY'] });
const t = toolsOf(c);
await assert.rejects(
() => t.get('read_inbox').run({}),
/未配置完成.*AGENTMAIL_AGENT_KEY/
);
// 关键:真的一次请求都没发出去
assert.equal(c.calls.length, 0);
});
test('每个工具都受配置校验保护(漏一个就会发出匿名请求)', async () => {
const c = fakeClient({ config: ['AGENTMAIL_AGENT_NAME'] });
const t = toolsOf(c);
const argsByName = {
read_inbox: {},
read_mail: { mail_id: 'm' },
read_thread: { mail_id: 'm' },
send_mail: { to: 'x@/p', subject: 's', body: 'b' },
forward_mail: { mail_id: 'm', to: 'x@/p' },
upload_attachment: { file_path: '/tmp/x' },
download_attachment: { attachment_id: 'a', save_path: '/tmp/y' },
suggest_address: {},
list_contacts: {},
connect_to_server: {},
session_participants: { session_id: 's' }
};
for (const [name, tool] of t) {
if (name === 'connect_to_server') {
// 它是唯一**刻意**绕过 guard 的工具:配置缺失时它负责说清楚缺什么
// (见 lib/tools.mjs 里的注释),所以要断言另一种行为。
const out = await tool.run({});
assert.match(out, /未配置完成|已连接/, name);
continue;
}
await assert.rejects(() => tool.run(argsByName[name]), /未配置完成/, name);
}
assert.equal(c.calls.length, 0, '任何工具都不该在缺配置时发出请求');
});
// ─── 附件 ─────────────────────────────────────────────────────────
test('upload_attachment 提示必须把 id 带进 send_mail 才发得出去', async () => {
// 用真文件:这里要连真实路径一起验(读文件 → multipart 上传),
// 把 uploadLocalFile 抹掉就测不到「路径写错」这种最常见的失败。
const dir = await mkdtemp(join(tmpdir(), 'zc-upload-'));
const filePath = join(dir, 'a.txt');
await writeFile(filePath, 'hello');
const out = await toolsOf(fakeClient()).get('upload_attachment').run({ file_path: filePath });
assert.match(out, /att-1/);
assert.match(out, /attachment_ids/);
});
test('download_attachment 报告落盘路径与大小', async () => {
const out = await toolsOf(fakeClient())
.get('download_attachment')
.run({ attachment_id: 'a1', save_path: '/tmp/out.bin' });
assert.match(out, /\/tmp\/out\.bin/);
});