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:
117
plugins/dsh-mail-bridge/test/attachment-ids.test.mjs
Normal file
117
plugins/dsh-mail-bridge/test/attachment-ids.test.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user