Files
MailUI4Agents/plugins/opencode-mail-bridge/test/attachment-ids.test.mjs
JianFeeeee b374ce1f20 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 失败。
2026-09-12 11:20:13 +08:00

118 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);