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:
@ -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
|
||||||
|
|||||||
72
plugins/dsh-mail-bridge/lib/attachment-ids.js
Normal file
72
plugins/dsh-mail-bridge/lib/attachment-ids.js
Normal 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];
|
||||||
|
}
|
||||||
@ -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';
|
||||||
// 只用 isApproval:DSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。
|
// 只用 isApproval:DSH 没有 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);
|
||||||
|
|||||||
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);
|
||||||
@ -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 || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
72
plugins/opencode-mail-bridge/lib/attachment-ids.js
Normal file
72
plugins/opencode-mail-bridge/lib/attachment-ids.js
Normal 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];
|
||||||
|
}
|
||||||
117
plugins/opencode-mail-bridge/test/attachment-ids.test.mjs
Normal file
117
plugins/opencode-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);
|
||||||
72
plugins/pi-mail-bridge/lib/attachment-ids.js
Normal file
72
plugins/pi-mail-bridge/lib/attachment-ids.js
Normal 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];
|
||||||
|
}
|
||||||
@ -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):模型的自主发信要计配额,
|
||||||
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。
|
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。
|
||||||
|
|||||||
117
plugins/pi-mail-bridge/test/attachment-ids.test.mjs
Normal file
117
plugins/pi-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