From b374ce1f2038d234121a0b2febf37a4245a0c8aa Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 11:20:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20=E9=99=84=E4=BB=B6=20id=20?= =?UTF-8?q?=E5=BD=92=E4=B8=80=20=E2=80=94=E2=80=94=20=E4=BF=AE=20opencode?= =?UTF-8?q?=E3=80=8C=E5=81=9A=E5=AE=8C=E5=85=A8=E9=83=A8=E6=B4=BB=E5=8D=B4?= =?UTF-8?q?=E5=8F=91=E4=B8=8D=E5=87=BA=E9=99=84=E4=BB=B6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 现象(全功能演练抓到,根因来自 opencode 自己的内部记录) opencode 把四个步骤全做完了(2× download_attachment、read 读到内容、 upload_attachment 成功),却在最后一步卡死:`send_mail` 连续 **6 次**失败, 然后放弃整个任务,自述为「attachment_ids 参数有框架级序列化 bug」。 真实形状(从 opencode 的 part 表里取出的原始输入): input.attachment_ids = "[\"10e73e9f-c2a9-4226-bdb5-34ef1b340eb8\"]" ← 字符串 error: 字段 "attachment_ids" 类型不对:期望 string 数组,收到 string 模型把数组写成了 **JSON 字符串**,桥原样转发,服务端的严格解码器按契约拒收。 # 修在哪一层 **不在服务端放宽。** 那个「严格」是刻意的,挡的是字段名拼错、结构写错这类真 错误 —— 松开之后真 bug 会被静默接受(同一封邮件少几个附件,HTTP 仍是 200)。 **在桥这一层收。** 桥是适配器:模型侧的形状天生不可靠,而适配器的职责就是把 不可靠的输入归一成契约要求的形状。对模型宽容、对服务端严格 —— 这与 homeagent 那个 Go 插件里的 `stringList` 是同一个判断(那边注释写着「也接受 单个字符串……拒绝它只会换来一次重试,而意图毫无歧义」)。 接受的形状:数组 / JSON 数组字符串 / 单个 id / 逗号或空白分隔 / 混进 null 与数字时丢掉坏的保留好的。空串与 null 一并丢掉,与服务端 `parseAttachmentIDs` 保持一致。 # 三桥同源 新增共用模块 `lib/attachment-ids.js` + 同名测试,已加入 `deploy/check-shared-libs.sh` 的两个清单(实现与测试都必须逐字节相同 —— 只同步实现不同步测试,等于允许一侧偷偷放宽约定)。 三份 md5 一致,检查脚本通过。 # 测试 `test/attachment-ids.test.mjs` 17 条,三桥各一份。含**反向对照**:把 JSON 字符串 分支去掉后必须变红(实测 3 条失败)—— 否则这条判据就是空转,事故会复发。 全量:pi 401 / dsh 361 / opencode 312,0 失败。 --- deploy/check-shared-libs.sh | 4 +- plugins/dsh-mail-bridge/lib/attachment-ids.js | 72 +++++++++++ plugins/dsh-mail-bridge/src/index.ts | 3 +- .../test/attachment-ids.test.mjs | 117 ++++++++++++++++++ plugins/opencode-mail-bridge/index.js | 3 +- .../lib/attachment-ids.js | 72 +++++++++++ .../test/attachment-ids.test.mjs | 117 ++++++++++++++++++ plugins/pi-mail-bridge/lib/attachment-ids.js | 72 +++++++++++ plugins/pi-mail-bridge/src/tools.mjs | 3 +- .../test/attachment-ids.test.mjs | 117 ++++++++++++++++++ 10 files changed, 575 insertions(+), 5 deletions(-) create mode 100644 plugins/dsh-mail-bridge/lib/attachment-ids.js create mode 100644 plugins/dsh-mail-bridge/test/attachment-ids.test.mjs create mode 100644 plugins/opencode-mail-bridge/lib/attachment-ids.js create mode 100644 plugins/opencode-mail-bridge/test/attachment-ids.test.mjs create mode 100644 plugins/pi-mail-bridge/lib/attachment-ids.js create mode 100644 plugins/pi-mail-bridge/test/attachment-ids.test.mjs diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index d243b6b..ee55d83 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge) 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; 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 if [[ ! -f "$peer/lib/$f.js" ]]; then echo "共用模块缺失:$peer/lib/$f.js" >&2 fail=1 @@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do 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; do + 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 if [[ ! -f "$peer/test/$f.test.mjs" ]]; then echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 fail=1 diff --git a/plugins/dsh-mail-bridge/lib/attachment-ids.js b/plugins/dsh-mail-bridge/lib/attachment-ids.js new file mode 100644 index 0000000..541c28b --- /dev/null +++ b/plugins/dsh-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/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index 02b92b5..825a83a 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -62,6 +62,7 @@ import { import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; import { createSSEClient } from '../lib/sse-client.js'; import { pickMailSession } from '../lib/mail-session-id.js'; +import { normalizeAttachmentIDs } from '../lib/attachment-ids.js'; // 只用 isApproval:DSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。 import { isApproval } from '../lib/permission-grants.js'; import { @@ -1230,7 +1231,7 @@ export function apply(ctx: any, config: PluginConfig): void { to: args.to, subject: args.subject, body, cc: args.cc || '', reply_to: args.reply_to || '', session_alias: args.session_alias || '', - attachment_ids: args.attachment_ids || [], + attachment_ids: normalizeAttachmentIDs(args.attachment_ids), from_session_id: toolCtx?.sessionID || '', }); noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to); diff --git a/plugins/dsh-mail-bridge/test/attachment-ids.test.mjs b/plugins/dsh-mail-bridge/test/attachment-ids.test.mjs new file mode 100644 index 0000000..4f4d5f4 --- /dev/null +++ b/plugins/dsh-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/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 00b2b96..534074f 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -1,4 +1,5 @@ import { z } from "zod"; +import { normalizeAttachmentIDs } from "./lib/attachment-ids.js"; import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { homedir } from "node:os"; @@ -202,7 +203,7 @@ const sendMailTool = { cc: args.cc || "", reply_to: args.reply_to || "", session_alias: args.session_alias || "", - attachment_ids: args.attachment_ids || [], + attachment_ids: normalizeAttachmentIDs(args.attachment_ids), from_session_id: context?.sessionID || "", }); diff --git a/plugins/opencode-mail-bridge/lib/attachment-ids.js b/plugins/opencode-mail-bridge/lib/attachment-ids.js new file mode 100644 index 0000000..541c28b --- /dev/null +++ b/plugins/opencode-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/opencode-mail-bridge/test/attachment-ids.test.mjs b/plugins/opencode-mail-bridge/test/attachment-ids.test.mjs new file mode 100644 index 0000000..4f4d5f4 --- /dev/null +++ b/plugins/opencode-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/pi-mail-bridge/lib/attachment-ids.js b/plugins/pi-mail-bridge/lib/attachment-ids.js new file mode 100644 index 0000000..541c28b --- /dev/null +++ b/plugins/pi-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/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index 668b5ea..3e458d8 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -28,6 +28,7 @@ import { renderThread, } from '../lib/discovery.js'; import { noteExplicitSend } from '../lib/relay-dedup.js'; +import { normalizeAttachmentIDs } from '../lib/attachment-ids.js'; import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs'; @@ -106,7 +107,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { cc: params.cc || '', reply_to: params.reply_to || '', session_alias: params.session_alias || '', - attachment_ids: params.attachment_ids || [], + attachment_ids: normalizeAttachmentIDs(params.attachment_ids), from_session_id: ctx?.sessionManager?.getSessionId?.() || '', // 这里**不带 relay**(N-5):模型的自主发信要计配额, // 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。 diff --git a/plugins/pi-mail-bridge/test/attachment-ids.test.mjs b/plugins/pi-mail-bridge/test/attachment-ids.test.mjs new file mode 100644 index 0000000..4f4d5f4 --- /dev/null +++ b/plugins/pi-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);