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:
@ -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)}`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user