fix(plugins): 附件 id 归一 —— 修 opencode「做完全部活却发不出附件」

# 现象(全功能演练抓到,根因来自 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 失败。
This commit is contained in:
2026-09-12 11:20:13 +08:00
parent 0b5fabfd49
commit b374ce1f20
10 changed files with 575 additions and 5 deletions

View File

@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge)
fail=0 fail=0
for peer in "${PEERS[@]}"; do 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 if [[ ! -f "$peer/lib/$f.js" ]]; then
echo "共用模块缺失:$peer/lib/$f.js" >&2 echo "共用模块缺失:$peer/lib/$f.js" >&2
fail=1 fail=1
@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do
done 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 if [[ ! -f "$peer/test/$f.test.mjs" ]]; then
echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 echo "共用测试缺失:$peer/test/$f.test.mjs" >&2
fail=1 fail=1

View File

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

View File

@ -62,6 +62,7 @@ import {
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
import { createSSEClient } from '../lib/sse-client.js'; import { createSSEClient } from '../lib/sse-client.js';
import { pickMailSession } from '../lib/mail-session-id.js'; import { pickMailSession } from '../lib/mail-session-id.js';
import { normalizeAttachmentIDs } from '../lib/attachment-ids.js';
// 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。 // 只用 isApprovalDSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
import { isApproval } from '../lib/permission-grants.js'; import { isApproval } from '../lib/permission-grants.js';
import { import {
@ -1230,7 +1231,7 @@ export function apply(ctx: any, config: PluginConfig): void {
to: args.to, subject: args.subject, body, to: args.to, subject: args.subject, body,
cc: args.cc || '', reply_to: args.reply_to || '', cc: args.cc || '', reply_to: args.reply_to || '',
session_alias: args.session_alias || '', session_alias: args.session_alias || '',
attachment_ids: args.attachment_ids || [], attachment_ids: normalizeAttachmentIDs(args.attachment_ids),
from_session_id: toolCtx?.sessionID || '', from_session_id: toolCtx?.sessionID || '',
}); });
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to); noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);

View File

@ -0,0 +1,117 @@
/**
* normalizeAttachmentIDs 的判据。
*
* 每组用例都对应一种**模型真的会写出来的形状**,不是凑覆盖率。
* 尤其是第二条它是生产事故的原始形状opencode 连试 6 次、最后放弃整个任务),
* 如果哪天有人把 JSON 字符串分支删掉,这条会立刻红。
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeAttachmentIDs } from '../lib/attachment-ids.js';
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(` 通过 ${name}`);
} else {
fail++;
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
}
};
const ID = '10e73e9f-c2a9-4226-bdb5-34ef1b340eb8';
const ID2 = '5f1c2b3a-1111-2222-3333-444455556666';
// 数组:契约要求的形状
{
const got = normalizeAttachmentIDs([ID]);
check('数组原样通过', got.length === 1 && got[0] === ID, JSON.stringify(got));
const two = normalizeAttachmentIDs([ID, ID2]);
check('多元素数组保序', two.length === 2 && two[0] === ID && two[1] === ID2, JSON.stringify(two));
}
// JSON 数组字符串:**事故形状**
{
const got = normalizeAttachmentIDs(JSON.stringify([ID]));
check(
'JSON 数组字符串被解析(事故形状)',
got.length === 1 && got[0] === ID,
`得到 ${JSON.stringify(got)} —— 这一条红了就说明事故会复发`
);
const two = normalizeAttachmentIDs(JSON.stringify([ID, ID2]));
check('多元素 JSON 字符串保序', two.length === 2 && two[1] === ID2, JSON.stringify(two));
}
// 单个 id模型常见的偷懒写法拒绝它只会换来一次重试
{
const got = normalizeAttachmentIDs(ID);
check('裸单个 id 被接受', got.length === 1 && got[0] === ID, JSON.stringify(got));
check(
'裸单个 id 不会被误切uuid 含 - 但不含 , 与空白)',
got[0] === ID,
got[0]
);
}
// 逗号 / 空白分隔
{
const got = normalizeAttachmentIDs(`${ID}, ${ID2}`);
check('逗号分隔被切开', got.length === 2 && got[1] === ID2, JSON.stringify(got));
const got2 = normalizeAttachmentIDs(`${ID} ${ID2}`);
check('空白分隔被切开', got2.length === 2 && got2[1] === ID2, JSON.stringify(got2));
}
// 杂质:丢坏的留好的(三个里坏一个,不该变成一个都不发)
{
const got = normalizeAttachmentIDs([null, ID, 3, '', undefined, ID2]);
check(
'混杂 null/数字/空串时只保留合法 id',
got.length === 2 && got[0] === ID && got[1] === ID2,
JSON.stringify(got)
);
const got2 = normalizeAttachmentIDs(JSON.stringify([null, ID]));
check('JSON 字符串里的杂质同样被过滤', got2.length === 1 && got2[0] === ID, JSON.stringify(got2));
}
// 空值:不能返回 null调用方会当数组用
{
for (const v of [null, undefined, '', ' ', []]) {
const got = normalizeAttachmentIDs(v);
check(
`空值 ${JSON.stringify(v)} → 空数组(且不是 null`,
Array.isArray(got) && got.length === 0,
JSON.stringify(got)
);
}
}
// 非法输入不该抛异常:抛出去会让整个 send_mail 失败,
// 而那本来只需要「这个字段作废」
{
let threw = null;
try {
normalizeAttachmentIDs({ not: 'a list' });
normalizeAttachmentIDs(42);
normalizeAttachmentIDs('[坏 JSON');
} catch (e) {
threw = e;
}
check('非法输入不抛异常', threw === null, String(threw));
}
// 反向对照:坏 JSON 字符串不该被当成 id 原样带走
{
const got = normalizeAttachmentIDs('[坏 JSON');
check(
'坏 JSON 字符串不产生伪造的 id',
!got.includes('[坏 JSON'),
JSON.stringify(got)
);
}
console.log(`\n附件 id 归一:${pass} 通过,${fail} 失败`);
process.exit(fail === 0 ? 0 : 1);

View File

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { normalizeAttachmentIDs } from "./lib/attachment-ids.js";
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs"; import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { homedir } from "node:os"; import { homedir } from "node:os";
@ -202,7 +203,7 @@ const sendMailTool = {
cc: args.cc || "", cc: args.cc || "",
reply_to: args.reply_to || "", reply_to: args.reply_to || "",
session_alias: args.session_alias || "", session_alias: args.session_alias || "",
attachment_ids: args.attachment_ids || [], attachment_ids: normalizeAttachmentIDs(args.attachment_ids),
from_session_id: context?.sessionID || "", from_session_id: context?.sessionID || "",
}); });

View File

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

View File

@ -0,0 +1,117 @@
/**
* normalizeAttachmentIDs 的判据。
*
* 每组用例都对应一种**模型真的会写出来的形状**,不是凑覆盖率。
* 尤其是第二条它是生产事故的原始形状opencode 连试 6 次、最后放弃整个任务),
* 如果哪天有人把 JSON 字符串分支删掉,这条会立刻红。
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeAttachmentIDs } from '../lib/attachment-ids.js';
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(` 通过 ${name}`);
} else {
fail++;
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
}
};
const ID = '10e73e9f-c2a9-4226-bdb5-34ef1b340eb8';
const ID2 = '5f1c2b3a-1111-2222-3333-444455556666';
// 数组:契约要求的形状
{
const got = normalizeAttachmentIDs([ID]);
check('数组原样通过', got.length === 1 && got[0] === ID, JSON.stringify(got));
const two = normalizeAttachmentIDs([ID, ID2]);
check('多元素数组保序', two.length === 2 && two[0] === ID && two[1] === ID2, JSON.stringify(two));
}
// JSON 数组字符串:**事故形状**
{
const got = normalizeAttachmentIDs(JSON.stringify([ID]));
check(
'JSON 数组字符串被解析(事故形状)',
got.length === 1 && got[0] === ID,
`得到 ${JSON.stringify(got)} —— 这一条红了就说明事故会复发`
);
const two = normalizeAttachmentIDs(JSON.stringify([ID, ID2]));
check('多元素 JSON 字符串保序', two.length === 2 && two[1] === ID2, JSON.stringify(two));
}
// 单个 id模型常见的偷懒写法拒绝它只会换来一次重试
{
const got = normalizeAttachmentIDs(ID);
check('裸单个 id 被接受', got.length === 1 && got[0] === ID, JSON.stringify(got));
check(
'裸单个 id 不会被误切uuid 含 - 但不含 , 与空白)',
got[0] === ID,
got[0]
);
}
// 逗号 / 空白分隔
{
const got = normalizeAttachmentIDs(`${ID}, ${ID2}`);
check('逗号分隔被切开', got.length === 2 && got[1] === ID2, JSON.stringify(got));
const got2 = normalizeAttachmentIDs(`${ID} ${ID2}`);
check('空白分隔被切开', got2.length === 2 && got2[1] === ID2, JSON.stringify(got2));
}
// 杂质:丢坏的留好的(三个里坏一个,不该变成一个都不发)
{
const got = normalizeAttachmentIDs([null, ID, 3, '', undefined, ID2]);
check(
'混杂 null/数字/空串时只保留合法 id',
got.length === 2 && got[0] === ID && got[1] === ID2,
JSON.stringify(got)
);
const got2 = normalizeAttachmentIDs(JSON.stringify([null, ID]));
check('JSON 字符串里的杂质同样被过滤', got2.length === 1 && got2[0] === ID, JSON.stringify(got2));
}
// 空值:不能返回 null调用方会当数组用
{
for (const v of [null, undefined, '', ' ', []]) {
const got = normalizeAttachmentIDs(v);
check(
`空值 ${JSON.stringify(v)} → 空数组(且不是 null`,
Array.isArray(got) && got.length === 0,
JSON.stringify(got)
);
}
}
// 非法输入不该抛异常:抛出去会让整个 send_mail 失败,
// 而那本来只需要「这个字段作废」
{
let threw = null;
try {
normalizeAttachmentIDs({ not: 'a list' });
normalizeAttachmentIDs(42);
normalizeAttachmentIDs('[坏 JSON');
} catch (e) {
threw = e;
}
check('非法输入不抛异常', threw === null, String(threw));
}
// 反向对照:坏 JSON 字符串不该被当成 id 原样带走
{
const got = normalizeAttachmentIDs('[坏 JSON');
check(
'坏 JSON 字符串不产生伪造的 id',
!got.includes('[坏 JSON'),
JSON.stringify(got)
);
}
console.log(`\n附件 id 归一:${pass} 通过,${fail} 失败`);
process.exit(fail === 0 ? 0 : 1);

View File

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

View File

@ -28,6 +28,7 @@ import {
renderThread, renderThread,
} from '../lib/discovery.js'; } from '../lib/discovery.js';
import { noteExplicitSend } from '../lib/relay-dedup.js'; import { noteExplicitSend } from '../lib/relay-dedup.js';
import { normalizeAttachmentIDs } from '../lib/attachment-ids.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs'; import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs';
@ -106,7 +107,7 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) {
cc: params.cc || '', cc: params.cc || '',
reply_to: params.reply_to || '', reply_to: params.reply_to || '',
session_alias: params.session_alias || '', session_alias: params.session_alias || '',
attachment_ids: params.attachment_ids || [], attachment_ids: normalizeAttachmentIDs(params.attachment_ids),
from_session_id: ctx?.sessionManager?.getSessionId?.() || '', from_session_id: ctx?.sessionManager?.getSessionId?.() || '',
// 这里**不带 relay**N-5模型的自主发信要计配额 // 这里**不带 relay**N-5模型的自主发信要计配额
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。 // 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。

View File

@ -0,0 +1,117 @@
/**
* normalizeAttachmentIDs 的判据。
*
* 每组用例都对应一种**模型真的会写出来的形状**,不是凑覆盖率。
* 尤其是第二条它是生产事故的原始形状opencode 连试 6 次、最后放弃整个任务),
* 如果哪天有人把 JSON 字符串分支删掉,这条会立刻红。
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { normalizeAttachmentIDs } from '../lib/attachment-ids.js';
let pass = 0;
let fail = 0;
const check = (name, ok, detail = '') => {
if (ok) {
pass++;
console.log(` 通过 ${name}`);
} else {
fail++;
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
}
};
const ID = '10e73e9f-c2a9-4226-bdb5-34ef1b340eb8';
const ID2 = '5f1c2b3a-1111-2222-3333-444455556666';
// 数组:契约要求的形状
{
const got = normalizeAttachmentIDs([ID]);
check('数组原样通过', got.length === 1 && got[0] === ID, JSON.stringify(got));
const two = normalizeAttachmentIDs([ID, ID2]);
check('多元素数组保序', two.length === 2 && two[0] === ID && two[1] === ID2, JSON.stringify(two));
}
// JSON 数组字符串:**事故形状**
{
const got = normalizeAttachmentIDs(JSON.stringify([ID]));
check(
'JSON 数组字符串被解析(事故形状)',
got.length === 1 && got[0] === ID,
`得到 ${JSON.stringify(got)} —— 这一条红了就说明事故会复发`
);
const two = normalizeAttachmentIDs(JSON.stringify([ID, ID2]));
check('多元素 JSON 字符串保序', two.length === 2 && two[1] === ID2, JSON.stringify(two));
}
// 单个 id模型常见的偷懒写法拒绝它只会换来一次重试
{
const got = normalizeAttachmentIDs(ID);
check('裸单个 id 被接受', got.length === 1 && got[0] === ID, JSON.stringify(got));
check(
'裸单个 id 不会被误切uuid 含 - 但不含 , 与空白)',
got[0] === ID,
got[0]
);
}
// 逗号 / 空白分隔
{
const got = normalizeAttachmentIDs(`${ID}, ${ID2}`);
check('逗号分隔被切开', got.length === 2 && got[1] === ID2, JSON.stringify(got));
const got2 = normalizeAttachmentIDs(`${ID} ${ID2}`);
check('空白分隔被切开', got2.length === 2 && got2[1] === ID2, JSON.stringify(got2));
}
// 杂质:丢坏的留好的(三个里坏一个,不该变成一个都不发)
{
const got = normalizeAttachmentIDs([null, ID, 3, '', undefined, ID2]);
check(
'混杂 null/数字/空串时只保留合法 id',
got.length === 2 && got[0] === ID && got[1] === ID2,
JSON.stringify(got)
);
const got2 = normalizeAttachmentIDs(JSON.stringify([null, ID]));
check('JSON 字符串里的杂质同样被过滤', got2.length === 1 && got2[0] === ID, JSON.stringify(got2));
}
// 空值:不能返回 null调用方会当数组用
{
for (const v of [null, undefined, '', ' ', []]) {
const got = normalizeAttachmentIDs(v);
check(
`空值 ${JSON.stringify(v)} → 空数组(且不是 null`,
Array.isArray(got) && got.length === 0,
JSON.stringify(got)
);
}
}
// 非法输入不该抛异常:抛出去会让整个 send_mail 失败,
// 而那本来只需要「这个字段作废」
{
let threw = null;
try {
normalizeAttachmentIDs({ not: 'a list' });
normalizeAttachmentIDs(42);
normalizeAttachmentIDs('[坏 JSON');
} catch (e) {
threw = e;
}
check('非法输入不抛异常', threw === null, String(threw));
}
// 反向对照:坏 JSON 字符串不该被当成 id 原样带走
{
const got = normalizeAttachmentIDs('[坏 JSON');
check(
'坏 JSON 字符串不产生伪造的 id',
!got.includes('[坏 JSON'),
JSON.stringify(got)
);
}
console.log(`\n附件 id 归一:${pass} 通过,${fail} 失败`);
process.exit(fail === 0 ? 0 : 1);