Files
MailUI4Agents/plugins/dsh-mail-bridge/test/registered-tools-schema.test.mjs
JianFeeeee f11415834a 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。
2026-09-11 22:28:47 +08:00

92 lines
4.1 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.

// 回归:插件**实际注册**的 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)}`,
);
});