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