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 均返回
118 lines
3.7 KiB
JavaScript
118 lines
3.7 KiB
JavaScript
/**
|
||
* 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);
|