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 均返回
219 lines
8.8 KiB
JavaScript
219 lines
8.8 KiB
JavaScript
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({}), /没有可见的邮件/);
|
||
});
|