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 均返回
245 lines
9.2 KiB
JavaScript
245 lines
9.2 KiB
JavaScript
/**
|
||
* 工具层的测试。
|
||
*
|
||
* 用假客户端,不发真请求 —— 这里要验的是**参数处理与渲染**,
|
||
* 那才是各平台容易走样的地方(真请求由端到端演练覆盖)。
|
||
*/
|
||
|
||
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_ids(opencode 上连试 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/);
|
||
});
|