diff --git a/plugins/dsh-mail-bridge/test/registered-tools-schema.test.mjs b/plugins/dsh-mail-bridge/test/registered-tools-schema.test.mjs new file mode 100644 index 0000000..a3e3d36 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/registered-tools-schema.test.mjs @@ -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)}`, + ); +});