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 均返回
This commit is contained in:
2026-09-12 13:47:51 +08:00
parent 9204f019a1
commit e0e6f86d94
19 changed files with 3011 additions and 8 deletions

View File

@ -0,0 +1,19 @@
{
"name": "agentmail",
"version": "0.1.0",
"description": "AgentMail 邮件协作:让 ZCode 用邮件与其他 Agent人收发任务、传附件、申请授权。",
"author": {
"name": "AgentMail"
},
"license": "AGPL-3.0-only",
"mcpServers": {
"agentmail": {
"command": "node",
"args": ["${ZCODE_PLUGIN_ROOT}/mcp/server.mjs"],
"cwd": "${ZCODE_PROJECT_DIR}",
"env": {
"ZCODE_PLUGIN_ID": "agentmail"
}
}
}
}

View File

@ -0,0 +1,141 @@
/**
* 三维寻址的构造与判读 —— 所有平台插件共用。
*
* 为什么这些函数必须共用、且必须是纯函数:
*
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
* 生产上真实发生过两次:
*
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
* 再建一条平行会话,双方从此各说各话。
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`
* 整串被当成名字session 位静默丢失。
*
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
*/
/**
* 拼一个可寻址的 `name@path.session`。
*
* **空 path 也必须留下 `@` 与 `.`**`admin@.silent-harbor` 才解析成
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
* `admin.silent-harbor` 会被整串当作名字。
*
* session 省略时不写那一位(默认会话语义)。
*
* @param {string} name 收件方名Agent 名或人类用户名)
* @param {string} [path] 工作目录,可为空
* @param {string} [session] 会话别名;空则省略该位
* @returns {string} 地址name 为空时返回空串
*/
export function formatAddress(name, path, session) {
const n = String(name ?? '').trim();
const p = String(path ?? '').trim();
const s = String(session ?? '').trim();
if (!n) return '';
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 判断自己在这封邮件里是收件人还是抄送方。
*
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
* admin 主发 dsh、抄送 opencode分工是「dsh 提供源码解读、opencode 提供部署
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
* 或者都以为自己只是旁观者。
*
* @param {any} mail `/mail/inbox` 返回的一封邮件
* @param {string} selfName 自己的 Agent 名
* @returns {'to'|'cc'|'unknown'}
*/
export function roleOf(mail, selfName) {
const self = String(selfName ?? '').trim();
if (!self) return 'unknown';
if (mail?.to_name === self) return 'to';
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
return 'cc';
}
return 'unknown';
}
/**
* 给出「把回信发回这条会话」的地址。
*
* 发件人一侧**不带 path**Agent 回信时 `from_workspace` 存的是 Agent 名而不是
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
* 人类发件人本来就没有工作目录。
*
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
*
* @param {any} mail 一封邮件
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function replyAddressFor(mail, alias) {
const a = alias ?? mail?.session_alias ?? '';
return formatAddress(mail?.from_name, '', a);
}
/**
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
*
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
*
* @param {any} mail 一封邮件
* @param {string} selfName 自己的 Agent 名
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function selfAddressFor(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
let path = mail?.to_workspace ?? '';
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
const mine = mail.cc_list.find(c => c?.name === selfName);
if (mine) path = mine.path ?? '';
}
return formatAddress(selfName, path, a);
}
/**
* 列出这封邮件的全部参与方及各自可投递的地址。
*
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
* 抄送方的 path 取它自己那个地址的 path 位。
*
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
*
* @param {any} mail 一封邮件
* @param {string} [selfName] 自己的名字,用于标记 is_self
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
*/
export function participantsOfMail(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
const self = String(selfName ?? '').trim();
const out = [];
const add = (role, name, path) => {
const n = String(name ?? '').trim();
if (!n) return;
out.push({
role,
name: n,
path: String(path ?? ''),
address: formatAddress(n, path, a),
is_self: !!self && n === self,
});
};
// 发件人一侧 path 留空,理由同 replyAddressFor
add('from', mail?.from_name, '');
add('to', mail?.to_name, mail?.to_workspace);
if (Array.isArray(mail?.cc_list)) {
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
}
return out;
}

View File

@ -0,0 +1,72 @@
/**
* 把工具参数里的附件 id 列表归一成 `string[]`。
*
* # 为什么需要它(生产实测)
*
* opencode 上一轮把四个步骤全做完了 —— 下载两个附件、读出内容、上传回传文件 ——
* 却在最后一步卡住:`send_mail` 连续 **6 次**失败,模型自己总结为
* 「attachment_ids 参数有框架级序列化 bug」然后放弃了整个任务。
*
* 真实原因不是框架 bug而是模型把数组写成了 **JSON 字符串**
*
* attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]"
*
* 桥把这个字符串原样转发给服务端,服务端的严格解码器按契约拒收
* `字段 "attachment_ids" 类型不对:期望 string 数组,收到 string`)。
*
* # 修在哪一层
*
* **不在服务端放宽。** 服务端那个"严格"是刻意的,它挡的是字段名拼错、结构写错
* 这类真错误 —— 松开之后真 bug 会被静默接受同一封邮件少几个附件HTTP 仍是
* 200
*
* **在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠(它按自然语言直觉填
* 参数),而适配器的职责就是把不可靠的输入归一成契约要求的形状。对模型宽容、
* 对服务端严格,这与 homeagent 那个 Go 插件里的 `stringList` 是同一个判断
* (那边的注释写着:「也接受单个字符串……拒绝它只会换来一次重试,而意图毫无
* 歧义」)。
*
* # 接受的形状
*
* - `["a", "b"]` 数组
* - `"[\"a\", \"b\"]"` JSON 数组字符串 ← **本次事故的形状**
* - `"a"` 单个 id
* - `"a, b"` / `"a b"` 逗号或空白分隔
* - `[null, "a", 3]` 混进杂质:丢掉坏的、留下好的
*
* 逐项过滤而不是整体放弃:三个附件里有一个写坏,不该变成"一个都不发"。
* 空串与 null 一并丢掉 —— 服务端的 parseAttachmentIDs 也跳过空串,
* 与它保持一致,免得插件放过去的东西换个形状在服务端再失败一次。
*
* @param {unknown} value 工具参数里的原始值
* @returns {string[]} 归一后的 id 列表(永不为 null
*/
export function normalizeAttachmentIDs(value) {
if (value === null || value === undefined) return [];
if (Array.isArray(value)) return value.flatMap(normalizeAttachmentIDs);
if (typeof value !== 'string') return [];
const raw = value.trim();
if (raw === '') return [];
// JSON 数组字符串:本次事故的形状。解析失败不算错 —— 它可能就是普通 id。
if (raw.startsWith('[')) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.flatMap(normalizeAttachmentIDs);
} catch {
// 落到下面的分隔符分支
}
}
// 逗号/空白分隔:模型偶尔写成 "a, b"。
// 只在确实含分隔符时切分,避免把单个 id 切坏 —— uuid 里没有这些字符。
if (/[,\s]/.test(raw)) {
return raw
.split(/[,\s]+/)
.map(s => s.trim())
.filter(s => s !== '');
}
return [raw];
}

View File

@ -0,0 +1,208 @@
/**
* 有界容器 —— 给插件里那些「只增不减」的映射表兜底。
*
* # 为什么需要它
*
* 桥是**常驻进程**pi 的守护进程能跑几十天opencode/DSH 的插件跟着平台一起活)。
* 里面每一张 `Map`/`Set` 都在回答「这条会话/这封邮件我处理过吗」,键来自外部
* 事件流 —— 会话数与邮件数随时间单调增长,键却没有出口。
*
* 单条成本很小uuid 键 + 短字符串值,几十到几百字节),所以它不是几小时内撑爆
* 内存的那种故障。实际形态是:跑够久之后进程里躺着几十万个再也不会被查到的条目,
* 且 **GC 回收不了**(还被强引用着)。这类问题不会在开发和测试里出现,
* 只在生产上跑了几周后表现为「重启一下就好了」。
*
* # 淘汰策略:丢最久没被访问的
*
* JS 的 `Map`/`Set` 保证插入顺序,所以「删掉再插入」等价于「移到队尾」。
* 读也算访问(`get`/`has` 会刷新顺序),于是长期活跃的会话不会因为条目老被丢掉 ——
* 被淘汰的总是「很久没人问过」的那些。
*
* # 上限分表定义,因为丢一条的后果差别很大
*
* - `deliveredMails` 丢一条 → 那封邮件**理论上**可能被重复投递。但它防的两种
* 重复(心跳与 SSE 建连之间的窗口、SSE 断线重放)都发生在秒到分钟级,
* 几千封之前的 mail_id 不可能再来 —— 淘汰是安全的。
*
* - 会话级映射丢一条 → 那条会话下次来信时被当成新会话,平台侧上下文断掉。
* 这是**真的行为退化**,所以上限给得大得多,并且优先靠 `session_archived`
* 主动清理,让上限只当兜底。
*
* # 不要用它装「还在等结果的东西」
*
* 待决权限询问opencode 的 `pendingPermissions`、DSH 的 `pendingApprovals`
* 里存的是 `resolve` 回调。静默淘汰一条会让对应的 `await` **永远不返回** ——
* 平台侧那次工具调用就挂死了。那些表有确定的清理路径(决策到达 / 超时 / 拆插件
* 时 fail closed不该套上界。上界只适合「记录已经发生过的事实」的表。
*
* 三平台共用必须逐字节相同deploy/check-shared-libs.sh 校验)。
*/
/**
* 已投递邮件 id 的记忆上限。
*
* 2000 覆盖的是去重真正需要的时间窗SSE 重放最多回放服务端环形缓冲的 500 条
* 事件,一次补拉最多 5 封。留 2000 是三个数量级的余量,内存代价约 200KB。
*/
export const MAX_TRACKED_MAILS = 2000;
/**
* 会话级映射的条目上限。
*
* 淘汰一条会让那条会话失去平台侧上下文,所以这个数字要远大于「同时在推进的
* 任务数」。500 条 × 每条几百字节 ≈ 150KB —— 便宜到没有理由抠。
*
* 真正的清理来自 `session_archived`:会话归档后它的映射再无用处,那是确定性
* 时机;上限兜的是「一直不归档」。
*/
export const MAX_TRACKED_SESSIONS = 500;
function normalizeLimit(limit) {
const n = Number(limit);
// 上限必须是正整数0 会让每次 set 之后立刻把自己淘汰掉(表恒空,去重全部
// 失效且不报错NaN 会让 while 条件恒假(退化成无界)。两种都是静默的
// 错误行为,不如当场拒绝。
if (!Number.isFinite(n) || n < 1) {
throw new RangeError(`有界容器的上限必须是 >= 1 的整数,收到 ${limit}`);
}
return Math.floor(n);
}
/**
* 有界 Map超过上限时丢弃最久未访问的条目。
*
* 只实现桥里真正用到的那几个方法 —— 不做成 Map 的完整替身,那样会掩盖
* 「这张表是有界的」这个必须被看见的事实。
*/
export class BoundedMap {
/** @param {number} limit 条目上限 */
constructor(limit) {
this.limit = normalizeLimit(limit);
/** @type {Map<any, any>} */
this.map = new Map();
/** 累计淘汰条数,观测用(日志里能看出上限是否设得太小)。 */
this.evicted = 0;
}
get size() {
return this.map.size;
}
has(key) {
return this.map.has(key);
}
/**
* 取值并把该键移到队尾。
*
* 读也算访问:一条会话只要还在收信就会被反复 get不刷新的话它会因为
* 「插入得早」被淘汰 —— 那恰好淘汰了最该留的那些。
*/
get(key) {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key);
this.map.delete(key);
this.map.set(key, value);
return value;
}
/** 取值但**不**刷新顺序。给「只是想看一眼」的场合。 */
peek(key) {
return this.map.get(key);
}
set(key, value) {
// 已存在时先删Map 的 set 不改变已有键的位置,不删就刷不了活跃度。
if (this.map.has(key)) this.map.delete(key);
this.map.set(key, value);
while (this.map.size > this.limit) {
const oldest = this.map.keys().next().value;
this.map.delete(oldest);
this.evicted++;
}
return this;
}
delete(key) {
return this.map.delete(key);
}
clear() {
this.map.clear();
}
keys() {
return this.map.keys();
}
values() {
return this.map.values();
}
entries() {
return this.map.entries();
}
[Symbol.iterator]() {
return this.map[Symbol.iterator]();
}
}
/**
* 有界 Set超过上限时丢弃最久未访问的成员。
*
* `has` 也刷新顺序:与 `BoundedMap.get` 同理。对 `deliveredMails` 这意味着
* 「刚被去重挡下的那封」会留得更久,正合语义。
*/
export class BoundedSet {
/** @param {number} limit 成员上限 */
constructor(limit) {
this.limit = normalizeLimit(limit);
/** @type {Set<any>} */
this.set = new Set();
this.evicted = 0;
}
get size() {
return this.set.size;
}
has(value) {
if (!this.set.has(value)) return false;
this.set.delete(value);
this.set.add(value);
return true;
}
/** 判断存在但**不**刷新顺序。 */
peek(value) {
return this.set.has(value);
}
add(value) {
if (this.set.has(value)) this.set.delete(value);
this.set.add(value);
while (this.set.size > this.limit) {
const oldest = this.set.values().next().value;
this.set.delete(oldest);
this.evicted++;
}
return this;
}
delete(value) {
return this.set.delete(value);
}
clear() {
this.set.clear();
}
values() {
return this.set.values();
}
[Symbol.iterator]() {
return this.set[Symbol.iterator]();
}
}

View File

@ -0,0 +1,237 @@
/**
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
*
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
*
* # 这一组端点解决的问题
*
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
* 人类侧从来不是这样三段式输入框逐段查候选name / path / session 每一段都从
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
* 那个错误路径静默变成了新会话的 workspace。
*
* # 渲染的取舍
*
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
*/
/**
* 渲染候选收件人清单(`kind: "name"`)。
*
* 只给名字,不给地址:此时还不知道 path 与 session硬拼出来的
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
*
* @param {string[]} names
* @returns {string}
*/
export function renderNameSuggestions(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
if (list.length === 0) return '当前没有可投递的收件人。';
return [
`可投递的收件人(${list.length} 个):`,
list.map(n => `- ${n}`).join('\n'),
'',
'下一步:用 suggest_address 带上 name 查它可用的工作目录path 位)。',
].join('\n');
}
/**
* 渲染工作目录候选(`kind: "path"`)。
*
* 空列表要说清「这不代表不能发」path 位允许为空(人类用户没有工作目录),
* 不解释的话模型会卡在这一步,或者编一个路径出来。
*
* @param {string[]} paths
* @param {string} name 正在查的收件人名,用于拼下一步的提示
* @returns {string}
*/
export function renderPathSuggestions(paths, name) {
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
if (list.length === 0) {
return [
`${name} 没有记录在案的工作目录。`,
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}`,
].join('\n');
}
return [
`${name} 用过的工作目录(按最近使用排序):`,
list.map(p => `- ${p}`).join('\n'),
'',
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
].join('\n');
}
/**
* 渲染会话候选(`kind: "session"`)。
*
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
*
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
*
* @param {object} data `/agent/contacts/suggest` 的返回体
* @param {string} name
* @param {string} path
* @returns {string}
*/
export function renderSessionSuggestions(data, name, path) {
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
const existing = aliases.filter(a => a !== 'new');
if (existing.length === 0) {
return [
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`}`,
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
].join('\n');
}
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
for (let i = 0; i < aliases.length; i++) {
const alias = aliases[i];
const addr = addresses[i] || '';
const c = candidates[i] || {};
if (alias === 'new') continue; // new 单独放最后
const bits = [];
if (c.title) bits.push(c.title);
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
if (c.source === 'platform') bits.push('平台侧会话');
lines.push(`- ${addr || alias}${bits.length ? ` ${bits.join('')}` : ''}`);
}
lines.push('');
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
const newAddr = addressAt(addresses, aliases, 'new');
if (newAddr) {
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}`);
}
return lines.join('\n');
}
/** 按别名在同序的 addresses 里取地址。 */
function addressAt(addresses, aliases, alias) {
const i = aliases.indexOf(alias);
return i >= 0 ? addresses[i] || '' : '';
}
/**
* 渲染会话参与方清单。
*
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
* 「作为发件人」的邮件,正是为了让这个判断成立。
*
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
* @returns {string}
*/
export function renderParticipants(data) {
const parts = Array.isArray(data?.participants) ? data.participants : [];
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
const alias = data?.session_alias || '';
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
for (const p of parts) {
const tags = [];
if (p.is_self) tags.push('就是你');
if (Array.isArray(p.roles) && p.roles.length) {
tags.push(p.roles.map(roleLabel).join('/'));
}
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join('')}]` : ''}`);
}
lines.push('');
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
return lines.join('\n');
}
/**
* 渲染联系人清单(本 Agent 参与过的全部会话)。
*
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
* 有未读的那些才是答案。
*
* @param {object} data `/agent/contacts` 的返回体
* @param {number} limit 最多列出多少条
* @returns {string}
*/
export function renderContacts(data, limit = 20) {
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
if (list.length === 0) return '还没有任何往来会话。';
list.sort((a, b) => {
const ua = a?.unread_count || 0;
const ub = b?.unread_count || 0;
if (ua !== ub) return ub - ua;
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
});
const shown = list.slice(0, limit);
const lines = [`往来会话(共 ${list.length}${list.length > shown.length ? `,列出前 ${shown.length}` : ''}`];
for (const c of shown) {
const bits = [];
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
if (c.subject) bits.push(c.subject);
if (c.max_rounds > 0) {
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
bits.push(`${left}/${c.max_rounds} 个来回`);
}
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
lines.push(`- ${addr}${bits.length ? ` ${bits.join('')}` : ''}`);
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return String(role);
}
}
/**
* 渲染对话树,回答「谁已经回了、谁还没回」。
*
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
*
* @param {object} data `/agent/mail/{id}/thread` 的返回体
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
* @returns {string}
*/
export function renderThread(data, selfName = '') {
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
const lines = [`线索共 ${data?.total ?? nodes.length}${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}`];
for (const n of nodes) {
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
const indent = ' '.repeat(Math.min(depth, 8));
const marks = [];
if (selfName && n?.from_name === selfName) marks.push('你发的');
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
lines.push(
`${indent}- ${n?.from_name ?? '?'}${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join('')})` : ''}`
);
}
if (data?.has_more) {
lines.push('');
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
}
return lines.join('\n');
}

View File

@ -0,0 +1,128 @@
/**
* AgentMail 网关的 HTTP 客户端。
*
* 与 pi / dsh / opencode 三个桥的同名模块**同一套请求头与端点**
* `Authorization: Bearer <key>` + `X-Agent-Name`,路径前缀 `/api/v1`)。
* 刻意不共用文件:那三个桥的客户端与各自平台的会话生命周期耦合,
* 而这个只服务 MCP 的请求-响应模型;共用的部分(地址解析、收件箱渲染、
* 附件 id 归一)已经抽在 `lib/` 里逐字节同源。
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, basename } from 'node:path';
/** 与网关一致的默认值;无头部署时由 ZCode 的 userConfig / 环境变量覆盖。 */
const DEFAULT_BASE = 'http://127.0.0.1:8180';
export class GatewayError extends Error {
constructor(status, body, path) {
// 把状态码与响应体一起带上:只报「请求失败」会让模型无从改正
// (是密钥不对?会话别名被占?预算用尽?三种要完全不同的应对)。
super(`HTTP ${status} ${path}${body ? `${body}` : ''}`);
this.status = status;
this.body = body;
this.path = path;
}
}
export class GatewayClient {
constructor(env = process.env) {
this.baseURL = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_BASE).replace(/\/+$/, '');
this.agentKey = String(env.AGENTMAIL_AGENT_KEY || '').trim();
this.agentSecret = String(env.AGENTMAIL_AGENT_SECRET || '').trim();
this.agentName = String(env.AGENTMAIL_AGENT_NAME || '').trim();
}
/** 配置是否足以发请求 —— 缺密钥时要在第一次调用就明确报错,而不是收到 401 再猜。 */
checkConfig() {
const missing = [];
if (!this.agentKey && !this.agentSecret) missing.push('AGENTMAIL_AGENT_KEY');
if (!this.agentName) missing.push('AGENTMAIL_AGENT_NAME');
return missing;
}
authHeaders() {
const headers = { 'X-Agent-Name': this.agentName };
// 密钥优先;没有密钥时退回 secret与 pi/opencode/dsh 三桥同款兜底,
// 服务端两条路都认)。两者都没有时上面 checkConfig 已经拦住了。
if (this.agentKey) headers.Authorization = `Bearer ${this.agentKey}`;
else if (this.agentSecret) headers['X-Agent-Secret'] = this.agentSecret;
return headers;
}
async get(path) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
return this.#parse(res, path);
}
async post(path, body) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST',
headers: { ...this.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {})
});
return this.#parse(res, path);
}
async #parse(res, path) {
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) {
// 服务端的错误信息是给人看的(中文、可操作),优先透传给模型
const message = (data && (data.error || data.message)) || text.slice(0, 300);
throw new GatewayError(res.status, message, path);
}
return data;
}
/**
* 上传附件。字段名必须是 `file`(服务端 `FormFile("file")`
* 返回的是 `{attachment:{...}}` 这种**嵌套**形状 —— 按顶层解会得到空 id
* 而那是静默的homeagent 踩过HTTP 200、附件数为 0
*/
async uploadFile(buf, filename) {
const form = new FormData();
form.append('file', new Blob([buf]), filename);
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
method: 'POST',
headers: this.authHeaders(),
body: form
});
const data = await this.#parse(res, '/attachments');
const attachment = data?.attachment;
if (!attachment?.attachment_id) {
throw new Error('上传响应里没有 attachment_id服务端响应结构可能已变更');
}
return attachment;
}
async downloadFile(attachmentID) {
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
headers: this.authHeaders()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new GatewayError(res.status, body.slice(0, 200), `/attachments/${attachmentID}`);
}
return Buffer.from(await res.arrayBuffer());
}
}
/** 读本地文件并上传,返回附件的展示用信息。 */
export async function uploadLocalFile(client, filePath) {
const data = await readFile(filePath);
return client.uploadFile(data, basename(filePath));
}
/** 下载附件并落盘,必要时建父目录。 */
export async function downloadToFile(client, attachmentID, savePath) {
const buf = await client.downloadFile(attachmentID);
await mkdir(dirname(savePath), { recursive: true });
await writeFile(savePath, buf);
return buf.length;
}

View File

@ -0,0 +1,144 @@
/**
* 收件箱渲染与已读策略 —— 所有平台插件共用。
*
* 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与
* 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
*/
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
/** 人类可读的字节数,用于附件清单展示。 */
export function formatSize(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 把一封邮件渲染成模型可读的文本块。
*
* @param {any} m `/mail/inbox` 返回的一封邮件
* @param {number} bodyLimit 正文截断长度
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
* @returns {string}
*/
export function renderMail(m, bodyLimit = 200, selfName = '') {
const alias = m?.session_alias || '';
const lines = [
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
`会话: #${alias || '未命名'}`,
];
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
// 抄送方却看不到收件人叫什么。
if (m?.to_name) {
let toLine = `收件人: ${m.to_name}`;
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
lines.push(toLine);
}
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
}
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
// 以为自己是负责人,或者都以为自己只是旁观者。
if (selfName) {
const role = roleOf(m, selfName);
if (role === 'to') lines.push('你的身份: 收件人(主办)');
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
}
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
lines.push(
'附件: ' +
m.attachments
.map(a => `${a?.filename ?? '?'}${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'}`)
.join('、')
);
lines.push('下载附件请用 download_attachment 工具。');
}
// 列表接口只给 body_preview省带宽单封接口才有 body。两者都兜住。
const body = m?.body_preview || m?.body || '';
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
//
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
if (selfName && alias) {
const parts = participantsOfMail(m, selfName, alias);
const others = parts.filter(p => !p.is_self && p.address);
if (others.length > 0) {
lines.push(
'可投递地址: ' +
others.map(p => `${p.address}${roleLabel(p.role)}`).join('、')
);
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}`);
}
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return role;
}
}
/**
* 渲染整个收件箱。
* @param {any[]} mails
* @param {number} bodyLimit
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
* @returns {string}
*/
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
const list = Array.isArray(mails) ? mails : [];
if (list.length === 0) return '收件箱为空。';
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
}
/**
* 判断本次读取该标记哪些邮件为已读。
*
* 两条规则:
*
* 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过,
* 一并标掉等于让它们凭空消失。
* 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读
* 会让下一轮真正的新邮件混在里面认不出来。
*
* 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起,
* 模型分不清哪封该回。
*
* @param {string|undefined} status 本次查询用的过滤条件
* @param {any[]} mails 本次返回的邮件
* @returns {string[]} 待标记的 mail_id空数组表示不需要标记
*/
export function idsToMarkRead(status, mails) {
if (status === 'all') return [];
const list = Array.isArray(mails) ? mails : [];
return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id);
}
/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */
export const DEFAULT_INBOX_STATUS = 'unread';
/** 收件箱默认返回条数。 */
export const DEFAULT_INBOX_LIMIT = 5;

View File

@ -0,0 +1,139 @@
/**
* MCPModel Context Protocol的 stdio 传输层与 JSON-RPC 分发。
*
* # 为什么手写而不引 `@modelcontextprotocol/sdk`
*
* 协议面很小:`initialize` / `notifications/initialized` / `tools/list` /
* `tools/call`。SDK 会带来一个 1MB 上下的打包产物与一条构建链,而本插件的
* 其余部分(网关客户端 + 工具)本就零运行时依赖 —— 与 pi/opencode/dsh 三个桥
* 的取向一致。手写还能让这一层成为**可单测的纯函数**,而不是只能靠连上宿主才验。
*
* # 分帧
*
* stdio 传输是**换行分隔的 JSON**一行一条消息UTF-8不是 Content-Length 分帧。
* 这一点是照官方插件实测确认的:它的打包产物里出现 `StdioServerTransport` 与
* `split("\n")`,而 `Content-Length` 出现 **0 次**。
*
* # 职责边界
*
* 本模块只做「消息进 → 消息出」,不碰 stdin/stdout也不认识具体工具 ——
* 于是它可以在测试里被穷举,而 I/O 只剩 server.mjs 里那一小段胶水。
*/
export const PROTOCOL_VERSION = '2024-11-05';
export const SERVER_NAME = 'agentmail';
export const SERVER_VERSION = '0.1.0';
/** JSON-RPC 错误码(只列我们真的会返回的)。 */
export const RPC_ERROR = {
PARSE: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL: -32603
};
const result = (id, value) => ({ jsonrpc: '2.0', id, result: value });
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
/**
* 处理一条已解析的 JSON-RPC 消息。
*
* @param {any} msg 解析后的消息
* @param {{tools: Array<{name:string, description:string, inputSchema:object}>,
* call: (name: string, args: object) => Promise<string>}} ctx
* @returns {Promise<object|null>} 要写回的消息notification无 id返回 null
*/
export async function handleMessage(msg, ctx) {
// 通知(没有 id不需要回复。`notifications/initialized` 就走这条 ——
// 若它也回一条,客户端会把响应与请求错配,后续调用全乱。
const isNotification = msg === null || typeof msg !== 'object' || !('id' in msg);
const id = isNotification ? null : msg.id;
if (typeof msg !== 'object' || msg === null || typeof msg.method !== 'string') {
return isNotification
? null
: failure(id, RPC_ERROR.INVALID_REQUEST, '请求缺少 method');
}
switch (msg.method) {
case 'initialize':
return isNotification
? null
: result(id, {
// 回显客户端给的协议版本:不认识的版本也回显,交由客户端决定是否降级 ——
// 自作主张改成我们的版本会让客户端以为协商成功而按新语义调用。
protocolVersion: msg.params?.protocolVersion || PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
});
case 'notifications/initialized':
return null; // 纯通知
case 'ping':
return isNotification ? null : result(id, {});
case 'tools/list':
return isNotification
? null
: result(id, {
tools: ctx.tools.map(t => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema
}))
});
case 'tools/call': {
if (isNotification) return null;
const name = msg.params?.name;
const args = msg.params?.arguments ?? {};
if (typeof name !== 'string' || name === '') {
return failure(id, RPC_ERROR.INVALID_PARAMS, 'tools/call 缺少 name');
}
const known = ctx.tools.some(t => t.name === name);
if (!known) {
return failure(id, RPC_ERROR.INVALID_PARAMS, `没有名为 ${name} 的工具`);
}
try {
const text = await ctx.call(name, args);
return result(id, { content: [{ type: 'text', text: String(text ?? '') }] });
} catch (error) {
// 工具失败**不能**回 JSON-RPC error —— 那样模型看不到失败原因,
// 只会看到一次协议错误。MCP 的约定是 result + isError:true
// 于是错误文本进入对话,模型能据此改正(例如换一个 attachment_id
return result(id, {
content: [
{ type: 'text', text: `工具 ${name} 执行失败:${error?.message || error}` }
],
isError: true
});
}
}
default:
return isNotification
? null
: failure(id, RPC_ERROR.METHOD_NOT_FOUND, `不支持的方法 ${msg.method}`);
}
}
/**
* 把一行文本解析成消息并处理,返回要写回的行(或不返回)。
*
* 解析失败时**必须**回一条带 id=null 的解析错误JSON-RPC 规定),
* 否则客户端会一直等这一条的响应。
*/
export async function handleLine(line, ctx) {
const text = String(line ?? '').trim();
if (text === '') return null;
let msg;
try {
msg = JSON.parse(text);
} catch {
return JSON.stringify(failure(null, RPC_ERROR.PARSE, '不是合法的 JSON'));
}
const out = await handleMessage(msg, ctx);
return out === null ? null : JSON.stringify(out);
}

View File

@ -0,0 +1,393 @@
/**
* 暴露给 ZCode 模型的 AgentMail 工具。
*
* # 为什么工具集与另三个桥完全相同
*
* 同一件事在不同平台上应该有同一种做法。工具名(`read_inbox` / `send_mail` /
* `download_attachment` …)、参数名、以及**渲染文本**都对齐 pi / dsh / opencode
* 渲染走 `lib/inbox-format.js` 与 `lib/discovery.js`(逐字节同源),
* 所以模型在任一平台上看到的收件箱是同一个样子。
*
* 一旦这里少一个参数或换一种说法,就会出现「某个平台上模型不会回信」这类
* 只在单一平台复现的问题 —— 而排查时最费时间的正是「它到底和别的平台哪里不一样」。
*
* # 与平台无关
*
* 本模块不认识 MCP也不认识 ZCode它只是一组
* `{name, description, inputSchema, run(args) -> string}`。
* 协议那层在 lib/mcp-rpc.mjs入口在 mcp/server.mjs。
*/
import {
renderInbox,
renderMail,
idsToMarkRead,
formatSize,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT
} from './inbox-format.js';
import {
renderNameSuggestions,
renderPathSuggestions,
renderSessionSuggestions,
renderParticipants,
renderContacts,
renderThread
} from './discovery.js';
import { normalizeAttachmentIDs } from './attachment-ids.js';
import { uploadLocalFile, downloadToFile } from './gateway.mjs';
/** 正文在列表里的截断长度(与另三端一致)。 */
const BODY_LIMIT = 200;
const str = (v, fallback = '') => (typeof v === 'string' ? v : fallback);
const obj = v => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
/**
* 构造工具集。
*
* @param {{client: import('./gateway.mjs').GatewayClient, agentName: string}} deps
*/
export function buildTools({ client, agentName }) {
/**
* 每次调用前校验配置。缺密钥时在此明确报错 ——
* 否则模型看到的是一个 401而它会去重试而不是告诉人「插件没配密钥」。
*/
const guard = () => {
const missing = client.checkConfig();
if (missing.length) {
throw new Error(
`AgentMail 未配置完成:缺少 ${missing.join('、')}` +
`请在 ZCode 的插件设置里填写,或为 ZCode 进程设置同名环境变量。`
);
}
};
const tools = [];
// ─── 读 ────────────────────────────────────────────────────────
tools.push({
name: 'read_inbox',
description:
'查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' +
'每封含 mail_id、发件人、主题、正文与附件清单带 attachment_id。',
inputSchema: {
type: 'object',
properties: {
status: { type: 'string', description: '过滤条件 unread|all默认 unread' },
limit: { type: 'number', description: '返回数量,默认 5' }
}
},
async run(args) {
guard();
const a = obj(args);
const status = str(a.status) || DEFAULT_INBOX_STATUS;
const limit = Number.isFinite(a.limit) ? a.limit : DEFAULT_INBOX_LIMIT;
const { mails } = await client.get(
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
);
const listed = renderInbox(mails, BODY_LIMIT, agentName);
const ids = idsToMarkRead(a.status, mails);
if (ids.length) {
// 标记失败不该让读取失败:正文已经取到了,代价只是下次重复看到。
client.post('/mail/read', { mail_ids: ids }).catch(() => {});
}
return listed;
}
});
tools.push({
name: 'read_mail',
description: '读取一封邮件的完整正文、附件清单与可投递地址mail_id 从 read_inbox 获得)。',
inputSchema: {
type: 'object',
properties: { mail_id: { type: 'string', description: '要读哪封' } },
required: ['mail_id']
},
async run(args) {
guard();
const id = str(obj(args).mail_id);
if (!id) throw new Error('缺少 mail_id');
const data = await client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`);
const mail = data?.mail || data;
const lines = [renderMail(mail, 0, agentName)];
if (Array.isArray(data?.participants) && data.participants.length) {
lines.push('', renderParticipants(data));
}
if (data?.reply_address) {
lines.push('', `回信给发件人用 ${data.reply_address},或传 reply_to=${id}`);
}
return lines.join('\n');
}
});
tools.push({
name: 'read_thread',
description: '查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方协作时用它避免重复提问。',
inputSchema: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '线索中任一封邮件的 ID' },
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' }
},
required: ['mail_id']
},
async run(args) {
guard();
const a = obj(args);
const id = str(a.mail_id);
if (!id) throw new Error('缺少 mail_id');
const qs = Number.isFinite(a.offset) ? `?offset=${a.offset}` : '';
const data = await client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`);
return renderThread(data, agentName);
}
});
// ─── 写 ────────────────────────────────────────────────────────
tools.push({
name: 'send_mail',
description:
'发送邮件。三维地址 name@path.session省略 session 投递到默认会话,' +
'.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
inputSchema: {
type: 'object',
properties: {
to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' },
subject: { type: 'string', description: '邮件主题' },
body: { type: 'string', description: '邮件正文Markdown' },
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' },
attachment_ids: {
// 声明成「数组或字符串」而不是纯数组:
// 模型常把数组写成 JSON 字符串(`"[\"id\"]"`
// opencode 上就是这样连试 6 次失败、最后放弃整个任务。
// 声明放宽 + 下面归一,两条一起才拦得住。
description: '附件 ID 列表(先用 upload_attachment 取得)',
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'string', description: '单个 ID或形如 ["a","b"] 的 JSON 数组字符串' }
]
},
max_rounds: { type: 'number', description: '给这条新会话设定往返预算(仅新建时有效)' }
},
required: ['to', 'subject', 'body']
},
async run(args) {
guard();
const a = obj(args);
const to = str(a.to);
const subject = str(a.subject);
const body = str(a.body);
if (!to || !subject || !body) throw new Error('缺少必填字段to, subject, body');
const payload = { to, subject, body };
if (str(a.cc)) payload.cc = str(a.cc);
if (str(a.reply_to)) payload.reply_to = str(a.reply_to);
if (str(a.session_alias)) payload.session_alias = str(a.session_alias);
if (Number.isFinite(a.max_rounds)) payload.max_rounds = a.max_rounds;
const ids = normalizeAttachmentIDs(a.attachment_ids);
if (ids.length) payload.attachment_ids = ids;
const result = await client.post('/mail/send', payload);
const parts = [`邮件已发送ID: ${result?.mail_id ?? '?'}`];
if (result?.session_id) parts.push(`,会话: ${result.session_id}`);
if (result?.session_alias) parts.push(`,别名: ${result.session_alias}`);
parts.push(')。');
if (result?.budget_remaining !== undefined) {
parts.push(`本任务剩余往返:${result.budget_remaining}`);
}
return parts.join('');
}
});
tools.push({
name: 'forward_mail',
description:
'转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。',
inputSchema: {
type: 'object',
properties: {
mail_id: { type: 'string', description: '要转发的邮件 ID' },
to: { type: 'string', description: '新收件人的三维地址' },
comment: { type: 'string', description: '转发说明,置于引用原文之前' }
},
required: ['mail_id', 'to']
},
async run(args) {
guard();
const a = obj(args);
if (!str(a.mail_id) || !str(a.to)) throw new Error('缺少 mail_id 或 to');
const result = await client.post(
`/mail/${encodeURIComponent(str(a.mail_id))}/forward`,
{ to: str(a.to), comment: str(a.comment) }
);
return `已转发(新邮件 ID: ${result?.mail_id ?? '?'},会话: ${result?.session_id ?? '?'})。`;
}
});
// ─── 附件 ──────────────────────────────────────────────────────
tools.push({
name: 'upload_attachment',
description:
'上传本地文件作为邮件附件,返回 attachment_id。' +
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。',
inputSchema: {
type: 'object',
properties: { file_path: { type: 'string', description: '本地文件绝对路径' } },
required: ['file_path']
},
async run(args) {
guard();
const p = str(obj(args).file_path);
if (!p) throw new Error('缺少 file_path');
const a = await uploadLocalFile(client, p);
return (
`已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}\n` +
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`
);
}
});
tools.push({
name: 'download_attachment',
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
inputSchema: {
type: 'object',
properties: {
attachment_id: { type: 'string', description: '附件 ID' },
save_path: { type: 'string', description: '保存到的本地绝对路径' }
},
required: ['attachment_id', 'save_path']
},
async run(args) {
guard();
const a = obj(args);
const id = str(a.attachment_id);
const save = str(a.save_path);
if (!id || !save) throw new Error('缺少 attachment_id 或 save_path');
const size = await downloadToFile(client, id, save);
return `已保存到 ${save}${formatSize(size)}`;
}
});
// ─── 寻址发现 ──────────────────────────────────────────────────
tools.push({
name: 'suggest_address',
description:
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' +
'工作目录name+path 都带则给该目录下可续谈的会话与现成地址。',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: '收件人名,如 pi / admin' },
path: { type: 'string', description: '工作目录绝对路径' }
}
},
async run(args) {
guard();
const a = obj(args);
const name = str(a.name);
const path = str(a.path);
const qs = new URLSearchParams();
if (name) qs.set('name', name);
if (path) qs.set('path', path);
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
if (!name) return renderNameSuggestions(data?.names || data?.suggestions || []);
if (!path) return renderPathSuggestions(data?.paths || [], name);
return renderSessionSuggestions(data, name, path);
}
});
tools.push({
name: 'list_contacts',
description: '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。',
inputSchema: {
type: 'object',
properties: { limit: { type: 'number', description: '最多列出多少条,默认 20' } }
},
async run(args) {
guard();
const limit = Number.isFinite(obj(args).limit) ? obj(args).limit : 20;
const data = await client.get(`/agent/contacts?limit=${limit}`);
return renderContacts(data, limit);
}
});
tools.push({
name: 'session_participants',
description:
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。' +
'要回给抄收方或向第三方转达时先用它拿地址。',
inputSchema: {
type: 'object',
properties: { session_id: { type: 'string', description: '会话 ID' } },
required: ['session_id']
},
async run(args) {
guard();
const sid = str(obj(args).session_id);
if (!sid) throw new Error('缺少 session_id');
const data = await client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`);
return renderParticipants(data);
}
});
// ─── 连接与登记 ────────────────────────────────────────────────
tools.push({
name: 'connect_to_server',
description:
'连接到 AgentMail Gateway用当前配置的身份完成登记并报告连通性。' +
'首次安装或换了 Gateway 地址时调用。',
inputSchema: {
type: 'object',
properties: {
gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用当前配置' }
}
},
async run(args) {
// 刻意**不走 guard**:密钥没配好时,这个工具正是用来把问题说清楚的那个。
// 若也直接抛「未配置完成」,模型只能转述一句抱怨,人不知道该去哪里填。
const a = obj(args);
const url = (str(a.gateway_url) || client.baseURL).replace(/\/+$/, '');
const key = str(a.key_token) || client.agentKey;
const missing = client.checkConfig();
if (!agentName || (!key && !client.agentSecret)) {
return (
`AgentMail 尚未配置完成:缺少 ${missing.join('、')}\n` +
`请在 ZCode 的插件设置里填写,或为 ZCode 进程设置同名环境变量后重启。\n` +
`(当前解析到的 Gateway 地址:${url}`
);
}
const res = await fetch(`${url}/api/v1/agent/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(key
? { Authorization: `Bearer ${key}` }
: { 'X-Agent-Secret': client.agentSecret })
},
body: JSON.stringify({ name: agentName, platform: 'zcode' })
});
const text = await res.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = {};
}
if (!res.ok) {
return `登记失败HTTP ${res.status}${data.error || data.message || text.slice(0, 200)}`;
}
return `已连接 ${url},身份 ${agentName}(状态:${data.status || 'ok'})。`;
}
});
return tools;
}
/** 便捷:把工具集变成 `name -> tool` 的映射,供分发层使用。 */
export function indexTools(tools) {
return new Map(tools.map(t => [t.name, t]));
}

View File

@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* AgentMail 的 ZCode MCP 服务器入口。
*
* ZCode 按插件清单里的 `mcpServers` 启动本文件:
*
* node <zcode.cjs> __zcode-plugin-host <plugin>/mcp/server.mjs
*
* 启动后说 MCP换行分隔 JSON-RPC走 stdio工具实现在 lib/tools.mjs。
*
* # stdout 是协议通道
*
* stdout 上**只能**出现协议消息。任何一行 `console.log` 都会被客户端当成
* JSON 解析失败 —— 于是服务器看起来「起来了但一个工具都没有」。
* 本文件里所有诊断一律 `console.error`stderr 被客户端当日志转发,不影响协议)。
*
* # 起不来要说清楚
*
* 这是邮件驱动会话的一部分:没有本地 UI 让人看见崩溃。所以缺配置时
* 不是静默退出,而是把原因写到 stderr进 ZCode 日志),并在**每次工具调用**时
* 再报一次(模型能读,于是它会告诉人)。
*/
import { createInterface } from 'node:readline';
import { GatewayClient } from '../lib/gateway.mjs';
import { buildTools, indexTools } from '../lib/tools.mjs';
import { handleLine, SERVER_NAME, SERVER_VERSION } from '../lib/mcp-rpc.mjs';
const log = (...parts) => console.error('[agentmail-mcp]', ...parts);
export async function main() {
const client = new GatewayClient(process.env);
// 常见情况是没配 agent_nameuserConfig 没填、环境变量没继承)——
// 用网关的默认值兜底会让它以别人的身份发信,所以宁可留空并在调用时报错。
const agentName = client.agentName;
const tools = buildTools({ client, agentName });
const byName = indexTools(tools);
const ctx = {
tools,
call: async (name, args) => {
const tool = byName.get(name);
if (!tool) throw new Error(`没有名为 ${name} 的工具`);
return tool.run(args);
}
};
log(`启动 v${SERVER_VERSION},网关 ${client.baseURL},身份 ${agentName || '(未配置)'}` +
`工具 ${tools.length}`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
// 关闭 stdin 不等于「可以立刻退出」:此刻可能还有在途的工具调用。
// 直接 `process.exit(0)` 会把它们的响应丢掉 —— 实测表现是
// 「协议消息全对,但访问网关的那两个调用完全没有响应」,
// 而客户端只能等到超时(看起来像服务器挂了)。
// 所以:计数在途工作,关闭后等它归零再退,且把 stdout 写入也计入,
// 否则最后一条响应可能在缓冲区里被丢掉。
let pending = 0;
let stdinClosed = false;
const exitIfDrained = () => {
if (stdinClosed && pending === 0) process.exit(0);
};
const writeOut = text =>
new Promise(resolve => {
process.stdout.write(text + '\n', resolve);
});
rl.on('line', line => {
pending++;
// 不串行化每条消息各自发起谁先完成谁先写回MCP 靠 id 配对,
// 乱序是合法的)。实测确实会乱序 —— 两条 suggest_address 的耗时不同,
// 后发的先回。不要在这里排 Promise 链:那会让一个慢调用
// (例如 upload_attachment 传大文件)把后面的 read_inbox 堵住。
Promise.resolve()
.then(() => handleLine(line, ctx))
.then(out => (out === null || out === undefined ? undefined : writeOut(out)))
.catch(error => {
log('处理消息失败:', error?.message || error);
})
.finally(() => {
pending--;
exitIfDrained();
});
});
rl.on('close', () => {
stdinClosed = true;
log('stdin 关闭,等 ' + pending + ' 件在途工作结束后退出');
exitIfDrained();
});
}
// 直接执行时启动;被 import 时只导出(便于测试与宿主按需调用)。
const isDirect = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
if (isDirect) {
main().catch(error => {
log('致命错误:', error?.stack || error);
process.exit(1);
});
}

View File

@ -0,0 +1,13 @@
{
"name": "zcode-mail-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "AgentMail 的 ZCode 适配:让 ZCode 以 MCP 工具收发邮件、以 hook 承接授权与回信",
"license": "AGPL-3.0-only",
"main": "mcp/server.mjs",
"scripts": {
"test": "node --test 'test/*.test.mjs'",
"verify": "node -e \"import('./mcp/server.mjs')\" 2>/dev/null || true"
}
}

View File

@ -0,0 +1,145 @@
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, '');
});

View File

@ -0,0 +1,117 @@
/**
* 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);

View File

@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { BoundedMap, BoundedSet, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js';
// ─── 上限常量 ───
test('两个上限的相对大小编码了「丢一条的后果」', () => {
// 会话级映射丢一条会让那条会话失去平台侧上下文(真的行为退化),
// 而 deliveredMails 丢一条只是理论上可能重复投递一封几千封之前的邮件。
// 所以邮件窗口可以给得比会话映射宽。
assert.ok(MAX_TRACKED_MAILS >= MAX_TRACKED_SESSIONS,
'已投递邮件的窗口应当比会话映射更宽(它的淘汰代价更小)');
assert.ok(MAX_TRACKED_SESSIONS > 0);
});
// ─── BoundedMap ───
test('BoundedMap 未达上限时与普通 Map 行为一致', () => {
const m = new BoundedMap(10);
m.set('a', 1).set('b', 2);
assert.equal(m.size, 2);
assert.equal(m.get('a'), 1);
assert.equal(m.get('b'), 2);
assert.equal(m.has('a'), true);
assert.equal(m.has('zzz'), false);
assert.equal(m.get('zzz'), undefined);
assert.equal(m.evicted, 0);
});
test('BoundedMap 超过上限时丢最老的size 不再增长', () => {
const m = new BoundedMap(3);
m.set('a', 1).set('b', 2).set('c', 3).set('d', 4);
assert.equal(m.size, 3, '上限之后 size 必须封顶 —— 这正是泄露的反面');
assert.equal(m.has('a'), false, 'a 是最老的,应当被淘汰');
assert.deepEqual([...m.keys()], ['b', 'c', 'd']);
assert.equal(m.evicted, 1);
});
test('BoundedMap 的 get 刷新活跃度,长期被读的键不会被淘汰', () => {
const m = new BoundedMap(3);
m.set('a', 1).set('b', 2).set('c', 3);
m.get('a'); // a 变成最新
m.set('d', 4); // 淘汰最老的 —— 现在是 b不是 a
assert.equal(m.has('a'), true, '读也算访问:还在收信的会话不该因为建得早被丢');
assert.equal(m.has('b'), false);
});
test('BoundedMap 的 peek 不刷新活跃度', () => {
const m = new BoundedMap(3);
m.set('a', 1).set('b', 2).set('c', 3);
m.peek('a');
m.set('d', 4);
assert.equal(m.has('a'), false, 'peek 是「只看一眼」,不该改变淘汰顺序');
});
test('BoundedMap 重复 set 同一个键只占一个位置且刷新顺序', () => {
const m = new BoundedMap(2);
m.set('a', 1).set('b', 2).set('a', 9);
assert.equal(m.size, 2);
assert.equal(m.get('a'), 9);
m.set('c', 3);
assert.equal(m.has('b'), false, 'a 被重新 set 过b 才是最老的');
assert.equal(m.has('a'), true);
});
test('BoundedMap 支持 delete / clear / 迭代', () => {
const m = new BoundedMap(5);
m.set('a', 1).set('b', 2);
assert.equal(m.delete('a'), true);
assert.equal(m.delete('a'), false);
assert.deepEqual([...m.entries()], [['b', 2]]);
assert.deepEqual([...m.values()], [2]);
assert.deepEqual([...m], [['b', 2]]);
m.clear();
assert.equal(m.size, 0);
});
// ─── BoundedSet ───
test('BoundedSet 超过上限时丢最老的成员', () => {
const s = new BoundedSet(3);
s.add('m1').add('m2').add('m3').add('m4');
assert.equal(s.size, 3);
assert.equal(s.peek('m1'), false);
assert.deepEqual([...s.values()], ['m2', 'm3', 'm4']);
assert.equal(s.evicted, 1);
});
test('BoundedSet 的 has 刷新活跃度', () => {
const s = new BoundedSet(3);
s.add('a').add('b').add('c');
assert.equal(s.has('a'), true);
s.add('d');
assert.equal(s.peek('a'), true, '刚被去重挡下的那封应当留得更久');
assert.equal(s.peek('b'), false);
});
test('BoundedSet 重复 add 不占额外位置', () => {
const s = new BoundedSet(2);
s.add('a').add('a').add('a');
assert.equal(s.size, 1);
});
test('BoundedSet 支持 delete / clear / 迭代,且能喂给 new Set()', () => {
const s = new BoundedSet(5);
s.add('a').add('b');
assert.equal(s.delete('a'), true);
assert.deepEqual([...s], ['b']);
// pool.mailDrivenIDs() 会 `new Set(retired)` —— 少了 Symbol.iterator 就炸
assert.deepEqual([...new Set(s)], ['b']);
s.clear();
assert.equal(s.size, 0);
});
// ─── 负向对照:非法上限必须当场报错 ───
test('上限为 0 时抛错,而不是静默变成一张永远空着的表', () => {
// 0 的后果最隐蔽:每次 set 之后立刻把自己淘汰掉,于是去重全部失效,
// 邮件被反复投递,而代码里一行错误都不打。
assert.throws(() => new BoundedMap(0), RangeError);
assert.throws(() => new BoundedSet(0), RangeError);
});
test('上限为 NaN / 负数 / 非数字时抛错,而不是退化成无界', () => {
for (const bad of [NaN, -1, 'abc', undefined, null]) {
assert.throws(() => new BoundedMap(bad), RangeError, `BoundedMap(${String(bad)}) 应当抛错`);
assert.throws(() => new BoundedSet(bad), RangeError, `BoundedSet(${String(bad)}) 应当抛错`);
}
});
test('小数上限向下取整', () => {
const m = new BoundedMap(2.9);
assert.equal(m.limit, 2);
m.set('a', 1).set('b', 2).set('c', 3);
assert.equal(m.size, 2);
});
// ─── 压力:确认 size 真的封顶(这条是「不泄露」的直接断言)───
test('灌一万条之后 size 仍等于上限', () => {
const s = new BoundedSet(100);
for (let i = 0; i < 10_000; i++) s.add(`mail-${i}`);
assert.equal(s.size, 100);
assert.equal(s.evicted, 9900);
assert.equal(s.peek('mail-9999'), true, '最新的必须还在');
assert.equal(s.peek('mail-0'), false);
const m = new BoundedMap(100);
for (let i = 0; i < 10_000; i++) m.set(`s-${i}`, { n: i });
assert.equal(m.size, 100);
});

View File

@ -0,0 +1,218 @@
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({}), /没有可见的邮件/);
});

View File

@ -0,0 +1,266 @@
/**
* 收件箱渲染与已读策略的测试。
*
* 每条断言都对应一次真实的错误行为(见 lib/inbox-format.js 里的注释):
* 漏掉 attachment_id 模型就无从下载附件;漏掉抄送它会以为这是私信;
* status=all 时标记已读会让下一轮的新邮件混在历史里认不出来。
*
* node --test 'test/*.test.mjs'
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatSize,
renderMail,
renderInbox,
idsToMarkRead,
DEFAULT_INBOX_STATUS,
DEFAULT_INBOX_LIMIT,
} from '../lib/inbox-format.js';
const mail = (over = {}) => ({
mail_id: 'm-1',
from_name: 'admin',
subject: '缓存选型',
status: 'unread',
session_alias: 'brisk-harbor',
body_preview: '我们需要评估一下缓存层',
...over,
});
// ─── formatSize ───
test('formatSize 分档', () => {
assert.equal(formatSize(512), '512 B');
assert.equal(formatSize(2048), '2.0 KB');
assert.equal(formatSize(3 * 1024 * 1024), '3.0 MB');
});
test('formatSize 容错', () => {
assert.equal(formatSize(undefined), '?');
assert.equal(formatSize(NaN), '?');
assert.equal(formatSize('x'), '?');
});
// ─── renderMail ───
test('renderMail 带出 mail_id 与会话别名', () => {
const got = renderMail(mail());
assert.match(got, /邮件 ID: m-1/);
assert.match(got, /#brisk-harbor/);
assert.match(got, /admin: 缓存选型/);
});
test('无别名时显示「未命名」而不是空', () => {
const got = renderMail(mail({ session_alias: '' }));
assert.match(got, /#未命名/);
});
test('不变量:附件必须带 attachment_id', () => {
// 只说「有附件」模型就无从下载 —— download_attachment 要的正是这个 id。
const got = renderMail(mail({
attachments: [{ filename: 'report.md', size_bytes: 2048, attachment_id: 'att-9' }],
}));
assert.match(got, /id=att-9/, `附件行缺 id${got}`);
assert.match(got, /report\.md/);
assert.match(got, /2\.0 KB/);
assert.match(got, /download_attachment/, '要提示模型用哪个工具下载');
});
test('多个附件都列出来', () => {
const got = renderMail(mail({
attachments: [
{ filename: 'a.md', size_bytes: 10, attachment_id: 'att-1' },
{ filename: 'b.md', size_bytes: 20, attachment_id: 'att-2' },
],
}));
assert.match(got, /att-1/);
assert.match(got, /att-2/);
});
test('不变量:抄送人要显示出来', () => {
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
const got = renderMail(mail({
cc_list: [{ name: 'opencode', raw: 'opencode@/home.new' }],
}));
assert.match(got, /抄送/);
assert.match(got, /opencode@\/home\.new/, '应优先用 raw带路径与会话段');
});
test('无抄送时不出现抄送行', () => {
assert.ok(!renderMail(mail()).includes('抄送'));
assert.ok(!renderMail(mail({ cc_list: [] })).includes('抄送'));
});
test('正文优先取 body_preview缺失时退回 body', () => {
assert.match(renderMail(mail({ body_preview: '预览', body: '全文' })), /内容: 预览/);
assert.match(renderMail(mail({ body_preview: '', body: '全文' })), /内容: 全文/);
});
test('正文按 bodyLimit 截断', () => {
const got = renderMail(mail({ body_preview: 'x'.repeat(500) }), 50);
const line = got.split('\n').find(l => l.startsWith('内容: '));
assert.equal(line.length, '内容: '.length + 50);
});
test('renderMail 容错:字段全缺不崩', () => {
const got = renderMail({});
assert.match(got, /unknown/);
const got2 = renderMail(undefined);
assert.equal(typeof got2, 'string');
});
test('附件字段不是数组时忽略', () => {
const got = renderMail(mail({ attachments: 'oops', cc_list: 'oops' }));
assert.ok(!got.includes('附件:'));
assert.ok(!got.includes('抄送'));
});
// ─── 收件人与身份(只有知道自己是谁才能判定)───
test('不变量:收件人要显示出来', () => {
// 不显示的后果:被抄送方不知道主收件人是谁,无法向对方转达或汇报。
// 线上那封联调邮件要求「由收件人汇报」,而抄送方看不到收件人叫什么。
const got = renderMail(mail({ to_name: 'dsh', to_workspace: '/home/program/llmsproxy' }));
assert.match(got, /收件人: dsh@\/home\/program\/llmsproxy/);
});
test('收件人无工作目录时只显名字', () => {
const got = renderMail(mail({ to_name: 'admin', to_workspace: '' }));
assert.match(got, /收件人: admin$/m);
});
test('不传 selfName 时不出现身份行(兼容旧调用)', () => {
const got = renderMail(mail({ to_name: 'dsh' }));
assert.ok(!got.includes('你的身份'));
});
test('不变量:区分收件人与抄送方身份', () => {
// 两者职责不同。不区分的话两方都会以为自己是负责人,
// 或者都以为自己只是旁观者。
const m = mail({
to_name: 'dsh',
to_workspace: '/home/program/llmsproxy',
cc_list: [{ name: 'opencode', path: '/home', raw: 'opencode@/home.new' }],
});
assert.match(renderMail(m, 200, 'dsh'), /你的身份: 收件人/);
assert.match(renderMail(m, 200, 'opencode'), /你的身份: 抄送方/);
// 不相关的名字不编造身份
assert.ok(!renderMail(m, 200, 'someone').includes('你的身份'));
});
// ─── 可投递地址(「精准发信」的关键)───
const joint = () => mail({
mail_id: 'm-7',
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('不变量:给出每个参与方的可投递地址', () => {
// 之前模型只能从抄送行里拄一个 `opencode@/home.new`
// 而那个地址回过去只会再建一条平行会话。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /可投递地址/);
assert.match(got, /opencode@\/home\.silent-harbor抄送方/);
assert.match(got, /admin@\.silent-harbor发件人/);
});
test('不变量:可投递地址里绝不出现 .new', () => {
// 这是本轮修的根因的直接回归:`.new` 是一次性动作,
// 把它当回信地址会让双方各说各话。
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(line, '应有可投递地址行');
assert.ok(!line.includes('.new'), `地址行仍含 .new: ${line}`);
});
test('可投递地址不列自己', () => {
const got = renderMail(joint(), 200, 'dsh');
const line = got.split('\n').find(l => l.startsWith('可投递地址'));
assert.ok(!line.includes('dsh@'), `不该把自己当成收件人选项: ${line}`);
});
test('同时给出 reply_to 这条更稳的路', () => {
// 地址可能拼错reply_to 不会 —— 两条路都告诉模型。
const got = renderMail(joint(), 200, 'dsh');
assert.match(got, /reply_to=m-7/);
});
test('无会话别名时不给地址(宁可不给不可给错)', () => {
// 别名为空时拼不出「投回这条会话」的地址。给一个看着能用
// 实际指向默认会话的地址,比不给危险。
const got = renderMail(mail({
to_name: 'dsh', session_alias: '',
cc_list: [{ name: 'opencode', path: '/home' }],
}), 200, 'dsh');
assert.ok(!got.includes('可投递地址'));
});
test('renderInbox 透传 selfName', () => {
const got = renderInbox([joint()], 200, 'opencode');
assert.match(got, /你的身份: 抄送方/);
assert.match(got, /dsh@\/home\/program\/llmsproxy\.silent-harbor收件人/);
});
// ─── renderInbox ───
test('renderInbox 空收件箱给明确文案', () => {
assert.equal(renderInbox([]), '收件箱为空。');
assert.equal(renderInbox(undefined), '收件箱为空。');
assert.equal(renderInbox(null), '收件箱为空。');
});
test('renderInbox 用空行分隔多封', () => {
const got = renderInbox([mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.match(got, /邮件 ID: a[\s\S]*\n\n[\s\S]*邮件 ID: b/);
});
// ─── idsToMarkRead ───
test('不变量:只标本次列出的那些', () => {
// limit 之外的还没看过,一并标掉等于让它们凭空消失。
const ids = idsToMarkRead('unread', [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })]);
assert.deepEqual(ids, ['a', 'b']);
});
test('不变量status=all 时不标记', () => {
// 那是「回顾历史」的读法。把历史邮件标成已读会让下一轮真正的新邮件
// 混在里面认不出来。
assert.deepEqual(idsToMarkRead('all', [mail({ mail_id: 'a' })]), []);
});
test('status 省略时按默认unread标记', () => {
assert.deepEqual(idsToMarkRead(undefined, [mail({ mail_id: 'a' })]), ['a']);
});
test('idsToMarkRead 过滤掉无 id 的条目', () => {
const ids = idsToMarkRead('unread', [
mail({ mail_id: 'a' }),
mail({ mail_id: '' }),
mail({ mail_id: undefined }),
{ },
]);
assert.deepEqual(ids, ['a']);
});
test('idsToMarkRead 容错非数组', () => {
assert.deepEqual(idsToMarkRead('unread', undefined), []);
assert.deepEqual(idsToMarkRead('unread', 'oops'), []);
});
// ─── 默认值 ───
test('默认只看未读', () => {
// 默认 all 会让模型每轮重读旧邮件,把处理过的和新来的混在一起。
assert.equal(DEFAULT_INBOX_STATUS, 'unread');
});
test('默认条数是个小数字', () => {
// 收件箱一次给几十封会把上下文塞满,而模型一轮通常只处理一两封。
assert.ok(DEFAULT_INBOX_LIMIT > 0 && DEFAULT_INBOX_LIMIT <= 10);
});

View File

@ -0,0 +1,195 @@
/**
* MCP 协议层的测试。
*
* 这一层是手写的,所以它必须被穷举 —— 否则「工具没出现」「模型收不到错误」
* 这类问题只能连上 ZCode 才能发现,而那时线索要少得多。
*
* 每条断言都对应一个**真实的失败模式**,不是为覆盖率写的。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handleMessage, handleLine, RPC_ERROR } from '../lib/mcp-rpc.mjs';
const TOOLS = [
{ name: 'read_inbox', description: '读收件箱', inputSchema: { type: 'object' } },
{ name: 'send_mail', description: '发信', inputSchema: { type: 'object' } }
];
/** 造一个 ctx`call` 默认成功,可换成抛错来验失败路径。 */
const makeCtx = (impl = async () => '结果文本') => ({
tools: TOOLS,
call: impl
});
test('initialize 回显客户端给的协议版本', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26' } },
makeCtx()
);
assert.equal(out.result.protocolVersion, '2025-03-26');
assert.deepEqual(out.result.capabilities, { tools: { listChanged: false } });
assert.equal(out.result.serverInfo.name, 'agentmail');
});
test('initialize 缺参数时用默认版本兜底,而不是崩', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' }, makeCtx());
assert.ok(out.result.protocolVersion);
});
test('notifications/initialized 不回响应(回了会让后续调用错配)', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', method: 'notifications/initialized' },
makeCtx()
);
assert.equal(out, null);
});
test('任何无 id 的消息都不回响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', method: 'tools/list' }, makeCtx());
assert.equal(out, null);
});
test('tools/list 只暴露 name/description/inputSchema多带的字段会被客户端拒绝', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, makeCtx());
assert.equal(out.result.tools.length, 2);
for (const t of out.result.tools) {
assert.deepEqual(Object.keys(t).sort(), ['description', 'inputSchema', 'name']);
}
});
test('tools/call 成功时回 content 文本数组', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_inbox', arguments: {} } },
makeCtx()
);
assert.deepEqual(out.result, { content: [{ type: 'text', text: '结果文本' }] });
assert.equal(out.result.isError, undefined);
});
test('tools/call 把 arguments 原样交给工具', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = { name, args };
return 'ok';
});
await handleMessage(
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'send_mail', arguments: { to: 'admin@/tmp', subject: 's' } }
},
ctx
);
assert.deepEqual(seen, { name: 'send_mail', args: { to: 'admin@/tmp', subject: 's' } });
});
test('tools/call 缺 arguments 时当空对象,不抛错', async () => {
let seen = null;
const ctx = makeCtx(async (name, args) => {
seen = args;
return 'ok';
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'read_inbox' } },
ctx
);
assert.deepEqual(seen, {});
assert.equal(out.result.isError, undefined);
});
test('★ 工具执行失败回 result+isError不回 JSON-RPC error', async () => {
// 判据的关键:模型必须能看到失败原因。若回 JSON-RPC error
// 客户端只会显示一次协议错误,模型拿不到「为什么失败」,
// 也就无法改正opencode 上连试 6 次发不出附件就是这个后果)。
const ctx = makeCtx(async () => {
throw new Error('HTTP 409附件已随其他邮件发出');
});
const out = await handleMessage(
{ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
ctx
);
assert.equal(out.error, undefined, '不该是 JSON-RPC error');
assert.equal(out.result.isError, true);
assert.match(out.result.content[0].text, /附件已随其他邮件发出/);
});
test('★ 反向对照:成功时绝不带 isError', async () => {
// 与上一条构成对照:同样的入参、同样的方法,只翻转工具行为,
// isError 必须跟着翻转。否则「总是 isError」也会让上一条通过。
const ok = await handleMessage(
{ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx()
);
const bad = await handleMessage(
{ jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'send_mail', arguments: {} } },
makeCtx(async () => {
throw new Error('x');
})
);
assert.equal(ok.result.isError, undefined);
assert.equal(bad.result.isError, true);
});
test('tools/call 未知工具名回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'not_a_tool' } },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
assert.equal(out.result, undefined);
});
test('tools/call 缺 name 回 INVALID_PARAMS', async () => {
const out = await handleMessage(
{ jsonrpc: '2.0', id: 10, method: 'tools/call', params: {} },
makeCtx()
);
assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS);
});
test('未知方法回 METHOD_NOT_FOUND', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 11, method: 'x/y' }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.METHOD_NOT_FOUND);
});
test('ping 有响应', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 12, method: 'ping' }, makeCtx());
assert.deepEqual(out.result, {});
});
test('缺 method 回 INVALID_REQUEST', async () => {
const out = await handleMessage({ jsonrpc: '2.0', id: 13 }, makeCtx());
assert.equal(out.error.code, RPC_ERROR.INVALID_REQUEST);
});
test('id 原样回显(含 0 与字符串 id', async () => {
for (const id of [0, 'abc', 42]) {
const out = await handleMessage({ jsonrpc: '2.0', id, method: 'ping' }, makeCtx());
assert.equal(out.id, id);
}
});
// ─── handleLine分帧与解析 ───────────────────────────────────────
test('handleLine 空行不产生响应', async () => {
assert.equal(await handleLine('', makeCtx()), null);
assert.equal(await handleLine(' ', makeCtx()), null);
});
test('handleLine 非法 JSON 回带 id=null 的解析错误', async () => {
// 必须回:不回的话客户端会一直等这一条的响应。
const out = await handleLine('{not json', makeCtx());
const parsed = JSON.parse(out);
assert.equal(parsed.error.code, RPC_ERROR.PARSE);
assert.equal(parsed.id, null);
});
test('handleLine 输出是单行(换行会破坏分帧)', async () => {
const out = await handleLine(
JSON.stringify({ jsonrpc: '2.0', id: 14, method: 'tools/call', params: { name: 'read_inbox' } }),
makeCtx(async () => '多行\n文本\n在此')
);
assert.equal(out.includes('\n'), false, '响应里不能有裸换行(应被转义进 JSON 字符串)');
assert.match(JSON.parse(out).result.content[0].text, /多行\n文本/);
});

View File

@ -0,0 +1,244 @@
/**
* 工具层的测试。
*
* 用假客户端,不发真请求 —— 这里要验的是**参数处理与渲染**
* 那才是各平台容易走样的地方(真请求由端到端演练覆盖)。
*/
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/);
});