test(dsh-mail-bridge): 断言真实注册的工具都带 type:object

上一个 commit 只断言了编译函数的输出;那个测试对一个**运行期** bug 是无效的:
bug 的本质是 defineTool 在 ESM 下静默降级,源码看着没问题、注册出去的是裸映射。

所以这里跑**真实的 apply()**,用假 ctx 截下 ctx.tools.register 的入参逐个断言 ——
源码怎么写都不算数,注册出去的东西才算数。

要点:
- 工具注册包在 ctx.effect() 里,假 ctx 必须真的执行该回调
- effect 里会起 SSE,故放子进程跑并在拿到结果后主动退出
- 断言前先确认注册数 >= 11,避免在空集合上假通过

实测该测试能抓住修复前的形态(parameters 顶层键就是 gateway_url/key_token,
没有 type/properties),并单独覆盖被上游拒过的 connect_to_server。
This commit is contained in:
2026-09-11 22:28:47 +08:00
parent e9df62a565
commit f11415834a

View File

@ -0,0 +1,91 @@
// 回归:插件**实际注册**的 11 个 mail-bridge 工具必须带合法 JSON Schema。
//
// 为什么不能用「检查 dist 里的源码字符串」代替:这个 bug 的本质是**运行时
// 静默降级** —— 源码里看着好好的 `defineTool(...)`,因为插件是 ESM 而代码用了
// 裸 require载入失败被 catch 吞掉,于是工具以**未编译的裸映射**注册:
//
// parameters: { gateway_url: {type:'string'}, key_token: {type:'string'} }
//
// 而不是:
//
// parameters: { type:'object', properties:{ gateway_url:…, key_token:… } }
//
// 严格的上游直接 400实测 OpenCode Go
// Invalid schema for function 'connect_to_server':
// schema must be a JSON Schema of 'type: "object"', got 'type: null'.
// 宽松的上游Claude 系)不校验 → 只在特定 AUTO 档位暴露,表现为「某个模型
// 突然不能用了」。
//
// 所以这里跑**真实的 apply()**,用假 ctx 把 ctx.tools.register 的入参截下来
// 逐个断言 —— 源码怎么写都不算数,注册出去的东西才算数。
//
// 实现细节:工具注册包在 ctx.effect() 里,假 ctx 必须真的执行该回调;
// effect 里会起 SSE所以放子进程跑并在拿到结果后主动退出。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
const here = dirname(fileURLToPath(import.meta.url));
const pluginDist = join(here, '..', 'dist', 'index.js');
/** 在子进程里跑真实 apply(),回传注册到的工具名与 schema 类型。 */
function captureRegisteredTools() {
const script = `
const captured = [];
const noop = () => {};
const chain = new Proxy({}, { get: () => noop });
const ctx = {
agents: { create: async () => ({ followup: noop, on: noop }), get: () => undefined },
tools: { register: (t) => captured.push(t), unregister: noop },
get: () => undefined, on: noop, setTimeout: noop, interval: noop,
// Cordis 的 effect 会真跑回调;照做,否则注册点根本不会执行
effect: (fn) => { try { return fn(); } catch { /* 与断言无关 */ } },
logger: chain,
};
const mod = await import(${JSON.stringify(pluginDist)});
mod.apply(ctx, {
gateway: { url: 'http://127.0.0.1:8180', agentName: 'dsh', agentKey: 'x'.repeat(48), agentSecret: '' },
reply: {},
});
process.stdout.write('__TOOLS__' + JSON.stringify(
captured.map((t) => ({ name: t.name, type: t.parameters?.type ?? null, keys: Object.keys(t.parameters ?? {}) }))
));
process.exit(0);
`;
const r = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
encoding: 'utf8',
timeout: 30_000,
});
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`;
const marker = out.indexOf('__TOOLS__');
assert.ok(marker >= 0, `子进程没能回传工具列表。stdout/stderr:\n${out.slice(0, 800)}`);
return JSON.parse(out.slice(marker + '__TOOLS__'.length));
}
test('真实 apply() 注册的每个工具都带 type:"object"', () => {
const tools = captureRegisteredTools();
// 前提:确实注册到了 mail-bridge 的工具(否则下面的断言会在空集合上假通过)
assert.ok(tools.length >= 11, `期望至少 11 个工具,实际 ${tools.length}`);
const bad = tools.filter((t) => t.type !== 'object');
assert.deepEqual(
bad,
[],
`这些工具的 parameters 不是 type:"object",严格上游会 400 Invalid schema: ${JSON.stringify(bad)}`,
);
});
test('被上游实际拒过的 connect_to_server 也在其中且已合法', () => {
const tools = captureRegisteredTools();
const t = tools.find((x) => x.name === 'connect_to_server');
assert.ok(t, 'connect_to_server 必须被注册');
assert.equal(t.type, 'object', '正是这个工具触发了上游 400');
// 裸映射的特征:顶层键就是属性名,既没有 type 也没有 properties
assert.ok(
!(t.keys.includes('gateway_url') && !t.keys.includes('properties')),
`parameters 仍是裸映射形态: ${JSON.stringify(t.keys)}`,
);
});