Files
MailUI4Agents/plugins/zcode-mail-bridge/test/tools.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

245 lines
9.2 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 { 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/);
});