diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index ee55d83..f294d44 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -4,39 +4,109 @@ # 一侧改了另一侧没改,几个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 # 标了已读、在 DSH 那边没标,而两处代码看起来都"对"。 # -# 三方比对以 opencode 为基准逐个对比,而不是两两对比:后者在三方都不同时 +# 比对一律以 opencode 为基准逐个对比,而不是两两对比:后者在三方都不同时 # 会打出三条互相矛盾的差异,读的人无从判断谁是对的。 +# +# # 为什么每个 peer 的清单不同 +# +# 四个插件共用的是**协议无关**的那几个模块(地址解析、收件箱渲染、附件 id 归一…), +# 但各自用到的子集不同: +# +# - pi / dsh / opencode 是邮件驱动的**桥**,需要会话快照、中继去重、SSE 客户端等; +# - zcode-mail-bridge 目前是 **MCP 工具服务器**(模型侧的工具面), +# 不订阅 SSE、不管会话生命周期,所以只需要那 5 个协议无关模块。 +# +# 用「对等清单」会逼它拷一份用不到的 relay/session 模块进来 —— 那些文件 +# 永远不会被执行,却要跟着一起同步,反而让「哪些是真的共用」变得看不清。 +# 于是改成显式列举:**声明共用就必须逐字节相同**,声明之外的模块不检查 +# (因为根本没拷过来,结构上就不可能分叉)。 set -euo pipefail BASE=plugins/opencode-mail-bridge -PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge) +PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge plugins/zcode-mail-bridge) + +ALL_LIBS="relay-dedup relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question attachment-ids" +ALL_TESTS="relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question attachment-ids" + +# MCP 工具服务器用到的协议无关子集(实现与测试都同步)。 +MCP_LIBS="addressing inbox-format bounded discovery attachment-ids" +MCP_TESTS="addressing inbox-format bounded discovery attachment-ids" + +# ── 判据:用 cmp,不用 diff ───────────────────────────────────────── +# +# 本机 PATH 上的 `diff` **不是 GNU diff**,而是鸿蒙 SDK 工具链里的 +# /opt/huawei/harmonyos/ohos-sdk/linux/toolchains/diff。它不认 `-q`, +# 却对**内容不同的文件照样返回 0** —— 于是 `if ! diff -q ... >/dev/null` +# 恒为假,整支脚本变成永真输出(本检查器曾长期如此,实测才发现)。 +# cmp 的语义明确:相同 0、不同 1。 +same() { cmp -s "$1" "$2"; } + +# 判据本身必须先被验证。否则「比较永远说相同」与「真的都相同」 +# 在结果上完全一样,而这正是上面那个坑的形状。 +selfcheck() { + local a b + a=$(mktemp) && b=$(mktemp) + printf 'x\n' >"$a" + printf 'y\n' >"$b" + if same "$a" "$b"; then + echo "检查器自检失败:比较函数无法发现差异(别用 PATH 上的 diff)" >&2 + exit 1 + fi + printf 'y\n' >"$a" + if ! same "$a" "$b"; then + echo "检查器自检失败:相同内容的两个文件被判为不同" >&2 + exit 1 + fi + rm -f "$a" "$b" +} +selfcheck + +libs_for() { + case "$1" in + *zcode-mail-bridge) echo "$MCP_LIBS" ;; + *) echo "$ALL_LIBS" ;; + esac +} + +tests_for() { + case "$1" in + *zcode-mail-bridge) echo "$MCP_TESTS" ;; + *) echo "$ALL_TESTS" ;; + esac +} + +# 差异明细也走 cmp,不借 diff。 +show_diff() { + cmp "$1" "$2" 2>&1 | head -3 +} + fail=0 for peer in "${PEERS[@]}"; do - for f in relay-dedup relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question attachment-ids; do + for f in $(libs_for "$peer"); do if [[ ! -f "$peer/lib/$f.js" ]]; then echo "共用模块缺失:$peer/lib/$f.js" >&2 fail=1 continue fi - if ! diff -q "$BASE/lib/$f.js" "$peer/lib/$f.js" >/dev/null 2>&1; then + if ! same "$BASE/lib/$f.js" "$peer/lib/$f.js"; then echo "共用模块已分叉:lib/$f.js($BASE vs $peer)" >&2 - diff "$BASE/lib/$f.js" "$peer/lib/$f.js" | head -20 >&2 + show_diff "$BASE/lib/$f.js" "$peer/lib/$f.js" >&2 fail=1 fi done # 测试同样要同源:共用模块的行为约定写在测试里, # 只同步实现不同步测试,等于允许一侧偷偷放宽约定。 - for f in relay-policy relay-key permission-mode bounded inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants adopt sse-client user-question attachment-ids; do + for f in $(tests_for "$peer"); do if [[ ! -f "$peer/test/$f.test.mjs" ]]; then echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 fail=1 continue fi - if ! diff -q "$BASE/test/$f.test.mjs" "$peer/test/$f.test.mjs" >/dev/null 2>&1; then + if ! same "$BASE/test/$f.test.mjs" "$peer/test/$f.test.mjs"; then echo "共用测试已分叉:test/$f.test.mjs($BASE vs $peer)" >&2 fail=1 fi done done -[[ $fail -eq 0 ]] && echo " 共用模块三方同源(opencode / dsh / pi)" || exit 1 +[[ $fail -eq 0 ]] && echo " 共用模块四方同源(opencode 基准 vs dsh / pi / zcode)(判据已自检)" || exit 1 diff --git a/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json new file mode 100644 index 0000000..4309082 --- /dev/null +++ b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json @@ -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" + } + } + } +} diff --git a/plugins/zcode-mail-bridge/lib/addressing.js b/plugins/zcode-mail-bridge/lib/addressing.js new file mode 100644 index 0000000..f80ea11 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/addressing.js @@ -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; +} diff --git a/plugins/zcode-mail-bridge/lib/attachment-ids.js b/plugins/zcode-mail-bridge/lib/attachment-ids.js new file mode 100644 index 0000000..541c28b --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/attachment-ids.js @@ -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]; +} diff --git a/plugins/zcode-mail-bridge/lib/bounded.js b/plugins/zcode-mail-bridge/lib/bounded.js new file mode 100644 index 0000000..c5dcd61 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/bounded.js @@ -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} */ + 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} */ + 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](); + } +} diff --git a/plugins/zcode-mail-bridge/lib/discovery.js b/plugins/zcode-mail-bridge/lib/discovery.js new file mode 100644 index 0000000..7b81d57 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/discovery.js @@ -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'); +} diff --git a/plugins/zcode-mail-bridge/lib/gateway.mjs b/plugins/zcode-mail-bridge/lib/gateway.mjs new file mode 100644 index 0000000..86baaab --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/gateway.mjs @@ -0,0 +1,128 @@ +/** + * AgentMail 网关的 HTTP 客户端。 + * + * 与 pi / dsh / opencode 三个桥的同名模块**同一套请求头与端点** + * (`Authorization: Bearer ` + `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; +} diff --git a/plugins/zcode-mail-bridge/lib/inbox-format.js b/plugins/zcode-mail-bridge/lib/inbox-format.js new file mode 100644 index 0000000..8f2f54b --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/inbox-format.js @@ -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; diff --git a/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs b/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs new file mode 100644 index 0000000..1495b6c --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs @@ -0,0 +1,139 @@ +/** + * MCP(Model 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}} ctx + * @returns {Promise} 要写回的消息;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); +} diff --git a/plugins/zcode-mail-bridge/lib/tools.mjs b/plugins/zcode-mail-bridge/lib/tools.mjs new file mode 100644 index 0000000..e16d4fb --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/tools.mjs @@ -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])); +} diff --git a/plugins/zcode-mail-bridge/mcp/server.mjs b/plugins/zcode-mail-bridge/mcp/server.mjs new file mode 100644 index 0000000..ff67a31 --- /dev/null +++ b/plugins/zcode-mail-bridge/mcp/server.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * AgentMail 的 ZCode MCP 服务器入口。 + * + * ZCode 按插件清单里的 `mcpServers` 启动本文件: + * + * node __zcode-plugin-host /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_name(userConfig 没填、环境变量没继承)—— + // 用网关的默认值兜底会让它以别人的身份发信,所以宁可留空并在调用时报错。 + 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); + }); +} diff --git a/plugins/zcode-mail-bridge/package.json b/plugins/zcode-mail-bridge/package.json new file mode 100644 index 0000000..95025e8 --- /dev/null +++ b/plugins/zcode-mail-bridge/package.json @@ -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" + } +} diff --git a/plugins/zcode-mail-bridge/test/addressing.test.mjs b/plugins/zcode-mail-bridge/test/addressing.test.mjs new file mode 100644 index 0000000..279f80d --- /dev/null +++ b/plugins/zcode-mail-bridge/test/addressing.test.mjs @@ -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, ''); +}); diff --git a/plugins/zcode-mail-bridge/test/attachment-ids.test.mjs b/plugins/zcode-mail-bridge/test/attachment-ids.test.mjs new file mode 100644 index 0000000..4f4d5f4 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/attachment-ids.test.mjs @@ -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); diff --git a/plugins/zcode-mail-bridge/test/bounded.test.mjs b/plugins/zcode-mail-bridge/test/bounded.test.mjs new file mode 100644 index 0000000..ce3dfa6 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/bounded.test.mjs @@ -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); +}); diff --git a/plugins/zcode-mail-bridge/test/discovery.test.mjs b/plugins/zcode-mail-bridge/test/discovery.test.mjs new file mode 100644 index 0000000..3e3c75e --- /dev/null +++ b/plugins/zcode-mail-bridge/test/discovery.test.mjs @@ -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({}), /没有可见的邮件/); +}); diff --git a/plugins/zcode-mail-bridge/test/inbox-format.test.mjs b/plugins/zcode-mail-bridge/test/inbox-format.test.mjs new file mode 100644 index 0000000..c496a68 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/inbox-format.test.mjs @@ -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); +}); diff --git a/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs b/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs new file mode 100644 index 0000000..62906d8 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs @@ -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文本/); +}); diff --git a/plugins/zcode-mail-bridge/test/tools.test.mjs b/plugins/zcode-mail-bridge/test/tools.test.mjs new file mode 100644 index 0000000..d6b2e6e --- /dev/null +++ b/plugins/zcode-mail-bridge/test/tools.test.mjs @@ -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_ids(opencode 上连试 6 次失败的形状)', async () => { + const c = fakeClient(); + await toolsOf(c).get('send_mail').run({ + to: 'admin@/tmp', + subject: 's', + body: 'b', + attachment_ids: '["10e73e9f-1"]' // ← 模型实际会这么写 + }); + const sent = c.calls.find(x => x.path === '/mail/send'); + assert.deepEqual(sent.body.attachment_ids, ['10e73e9f-1']); +}); + +test('send_mail 也接受数组、单 id、逗号分隔', async () => { + for (const [input, want] of [ + [['a', 'b'], ['a', 'b']], + ['a', ['a']], + ['a, b', ['a', 'b']], + ['a b', ['a', 'b']] + ]) { + const c = fakeClient(); + await toolsOf(c).get('send_mail').run({ + to: 'x@/p', + subject: 's', + body: 'b', + attachment_ids: input + }); + assert.deepEqual(c.calls.find(x => x.path === '/mail/send').body.attachment_ids, want); + } +}); + +test('★ 没有附件时不带 attachment_ids 字段(带空数组会被服务端当「要挂附件」)', async () => { + for (const input of [undefined, null, '', [], ['', null]]) { + const c = fakeClient(); + await toolsOf(c).get('send_mail').run({ + to: 'x@/p', + subject: 's', + body: 'b', + attachment_ids: input + }); + const body = c.calls.find(x => x.path === '/mail/send').body; + assert.equal('attachment_ids' in body, false, `输入 ${JSON.stringify(input)} 时不该带`); + } +}); + +test('send_mail 缺必填字段时明确报错(且不发请求)', async () => { + const t = toolsOf(fakeClient()); + for (const args of [{}, { to: 'x@/p' }, { to: 'x@/p', subject: 's' }]) { + await assert.rejects(() => t.get('send_mail').run(args), /缺少必填字段/); + } +}); + +test('send_mail 只透传有值的可选字段', async () => { + const c = fakeClient(); + await toolsOf(c).get('send_mail').run({ + to: 'x@/p', + subject: 's', + body: 'b', + cc: '', + reply_to: 'm1', + session_alias: '', + max_rounds: 5 + }); + const body = c.calls.find(x => x.path === '/mail/send').body; + assert.deepEqual(Object.keys(body).sort(), ['body', 'max_rounds', 'reply_to', 'subject', 'to']); +}); + +// ─── read_inbox ─────────────────────────────────────────────────── +test('read_inbox 只把本次列出来的未读标为已读', async () => { + const c = fakeClient({ + responses: { + '/mail/inbox': { + mails: [ + { mail_id: 'm1', subject: '一', body: 'x', from: 'pi' }, + { mail_id: 'm2', subject: '二', body: 'y', from: 'dsh' } + ] + } + } + }); + const out = await toolsOf(c).get('read_inbox').run({}); + assert.match(out, /一/); + const mark = c.calls.find(x => x.path === '/mail/read'); + assert.ok(mark, '应该标记已读'); + assert.deepEqual(mark.body.mail_ids, ['m1', 'm2']); +}); + +test('read_inbox 空收件箱给出可读文本', async () => { + const c = fakeClient({ responses: { '/mail/inbox': { mails: [] } } }); + assert.match(await toolsOf(c).get('read_inbox').run({}), /收件箱为空/); +}); + +test('★ 标记已读失败不影响读取结果', async () => { + // 正文已经拿到了,代价只是下次重复看到 —— 比丢掉这次读取轻得多。 + const c = fakeClient({ responses: { '/mail/inbox': { mails: [{ mail_id: 'm1', subject: '一', body: 'x' }] } } }); + c.post = async () => { + throw new Error('500'); + }; + const out = await toolsOf(c).get('read_inbox').run({}); + assert.match(out, /一/); +}); + +// ─── 配置缺失 ───────────────────────────────────────────────────── +test('★ 未配置密钥时每次调用都明确报错(而不是收到 401 再猜)', async () => { + const c = fakeClient({ config: ['AGENTMAIL_AGENT_KEY'] }); + const t = toolsOf(c); + await assert.rejects( + () => t.get('read_inbox').run({}), + /未配置完成.*AGENTMAIL_AGENT_KEY/ + ); + // 关键:真的一次请求都没发出去 + assert.equal(c.calls.length, 0); +}); + +test('每个工具都受配置校验保护(漏一个就会发出匿名请求)', async () => { + const c = fakeClient({ config: ['AGENTMAIL_AGENT_NAME'] }); + const t = toolsOf(c); + const argsByName = { + read_inbox: {}, + read_mail: { mail_id: 'm' }, + read_thread: { mail_id: 'm' }, + send_mail: { to: 'x@/p', subject: 's', body: 'b' }, + forward_mail: { mail_id: 'm', to: 'x@/p' }, + upload_attachment: { file_path: '/tmp/x' }, + download_attachment: { attachment_id: 'a', save_path: '/tmp/y' }, + suggest_address: {}, + list_contacts: {}, + connect_to_server: {}, + session_participants: { session_id: 's' } + }; + for (const [name, tool] of t) { + if (name === 'connect_to_server') { + // 它是唯一**刻意**绕过 guard 的工具:配置缺失时它负责说清楚缺什么 + // (见 lib/tools.mjs 里的注释),所以要断言另一种行为。 + const out = await tool.run({}); + assert.match(out, /未配置完成|已连接/, name); + continue; + } + await assert.rejects(() => tool.run(argsByName[name]), /未配置完成/, name); + } + assert.equal(c.calls.length, 0, '任何工具都不该在缺配置时发出请求'); +}); + +// ─── 附件 ───────────────────────────────────────────────────────── +test('upload_attachment 提示必须把 id 带进 send_mail 才发得出去', async () => { + // 用真文件:这里要连真实路径一起验(读文件 → multipart 上传), + // 把 uploadLocalFile 抹掉就测不到「路径写错」这种最常见的失败。 + const dir = await mkdtemp(join(tmpdir(), 'zc-upload-')); + const filePath = join(dir, 'a.txt'); + await writeFile(filePath, 'hello'); + const out = await toolsOf(fakeClient()).get('upload_attachment').run({ file_path: filePath }); + assert.match(out, /att-1/); + assert.match(out, /attachment_ids/); +}); + +test('download_attachment 报告落盘路径与大小', async () => { + const out = await toolsOf(fakeClient()) + .get('download_attachment') + .run({ attachment_id: 'a1', save_path: '/tmp/out.bin' }); + assert.match(out, /\/tmp\/out\.bin/); +});