Files
MailUI4Agents/plugins/dsh-mail-bridge/test/tool-schema.test.mjs
JianFeeeee e9df62a565 fix(dsh-mail-bridge): ESM 下 defineTool 静默降级导致工具 schema 非法
现象:dsh 经 llmsproxy AUTO 走到 gozen 时 400:
  Invalid schema for function 'connect_to_server':
  schema must be a JSON Schema of 'type: "object"', got 'type: null'.

根因:插件 package.json 是 "type": "module"(ESM),而源码用裸
require.resolve / require 载入 @deepseek-ai/dsh-tools。ESM 里 require
是 undefined,require.resolve 抛 ReferenceError,被 catch { return opts; }
**静默吞掉** —— defineTool 恒等返回,工具的 parameters 以**未编译的裸映射**
注册:

    { gateway_url: {type:'string'}, key_token: {type:'string'} }   // 

而不是合法形态:

    { type:'object', properties:{ gateway_url:…, key_token:… } }    // 

后果波及全部 11 个 mail-bridge 工具。严格的上游直接 400(实测 OpenCode Go),
宽松的(Claude 系)不校验 —— 所以只在特定 AUTO 档位暴露,表现为「某个模型
突然不能用了」。

修法:
1. 用 createRequire(import.meta.url) 取得合法的 require(ESM 标准做法)。
2. 兜底也必须产出**合法** schema —— 新增 parametersToJsonSchema,在
   dsh-tools 不可用时自己编译:required:true 提升到根级 required 数组
   (JSON Schema 不允许属性自带 required),type:object 必补。
   绝不再静默把裸映射发出去 —— 那比直接报错更难查。

实测 11 个 mail-bridge 工具全部产出合法 schema,包括真机被拒的那个
connect_to_server。测试 test/tool-schema.test.mjs 覆盖:裸映射编译、
required 提升、数组 items、空 spec、被上游拒过的实际工具。
2026-09-11 22:12:01 +08:00

70 lines
3.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.

// 回归mail-bridge 的工具 schema 必须是合法 JSON Schema。
//
// 曾经这里用裸 `require(...)` 载入 @deepseek-ai/dsh-tools。插件是 ESM
// ESM 里没有 require → 抛 ReferenceError → 被 `catch { return opts; }` 静默吞掉,
// 于是 defineTool 恒等返回,工具的 parameters 以**未编译的裸映射**注册:
//
// { gateway_url: {type:'string'}, key_token: {type:'string'} }
//
// 而不是:
//
// { 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 档位暴露。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parametersToJsonSchema } from '../dist/index.js';
test('裸参数映射被编译成 type:object + properties', () => {
const got = parametersToJsonSchema({
gateway_url: { type: 'string', description: 'Gateway 地址' },
key_token: { type: 'string', description: '密钥' },
});
assert.equal(got.type, 'object', '缺 type:object 会被严格上游 400');
assert.deepEqual(Object.keys(got.properties), ['gateway_url', 'key_token']);
// 属性必须原样保留
assert.equal(got.properties.gateway_url.type, 'string');
// 没有任何 required 时不应凭空产出空数组
assert.equal('required' in got, false);
});
test('required:true 被提升到根级 required 数组,并从属性上移除', () => {
const got = parametersToJsonSchema({
to: { type: 'string', required: true, description: '收件人' },
cc: { type: 'string', description: '抄送' },
});
assert.deepEqual(got.required, ['to']);
// JSON Schema 的属性里不允许出现 required
assert.equal('required' in got.properties.to, false);
assert.equal(got.properties.to.type, 'string');
});
test('数组属性与其 items 原样保留', () => {
const got = parametersToJsonSchema({
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件' },
});
assert.deepEqual(got.properties.attachment_ids.items, { type: 'string' });
});
test('空 / 缺失 spec 产出合法的空对象 schema', () => {
for (const spec of [undefined, null, {}]) {
const got = parametersToJsonSchema(spec);
assert.equal(got.type, 'object');
assert.deepEqual(got.properties, {});
}
});
test('编译结果本身通过「必须有 type:object」的断言上游的实际要求', () => {
// 真机上被拒的那个工具
const connect = parametersToJsonSchema({
gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
});
assert.equal(connect.type, 'object');
// 模拟上游校验type 必须存在且为 object
assert.ok(connect.type === 'object', `got type: ${JSON.stringify(connect.type ?? null)}`);
});