上一个 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。
92 lines
4.1 KiB
JavaScript
92 lines
4.1 KiB
JavaScript
// 回归:插件**实际注册**的 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)}`,
|
||
);
|
||
});
|