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 均返回
146 lines
6.1 KiB
JavaScript
146 lines
6.1 KiB
JavaScript
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, '');
|
||
});
|