Files
cursor2api/test/unit-tool-fixer.mjs
小海 f12ca30893 feat(v2.5.0): Cursor IDE 完整适配 + 工具参数自动修复 + 增量流式优化
🖥️ Cursor IDE 适配:
- 新增 /v1/responses 端点(Responses API → Chat Completions 自动转换)
- 兼容 Cursor 扁平工具格式 { name, input_schema }
- 扩展 /v1/models 模型列表(claude-sonnet-4-5/4/3.5)
- 连续同角色消息自动合并(mergeConsecutiveRoles)
- content 数组中 tool_use/tool_result 块直接透传

🔧 工具参数自动修复 (tool-fixer.ts):
- normalizeToolArguments: file_path → path 字段名映射
- replaceSmartQuotes: 中文/法文智能引号替换
- repairExactMatchToolArguments: 模糊匹配修复
- extractToolResultNatural: 自然语言 tool_result 转换

🚀 流式增量优化:
- input_json_delta / tool_calls 按 128 字节分块
- 拒绝重试扩展到工具模式
- 极短响应自动重试

🧪 新增 44 个单元测试 (tool-fixer + openai-compat)
2026-03-10 16:27:19 +08:00

270 lines
11 KiB
JavaScript
Raw Permalink 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.
/**
* test/unit-tool-fixer.mjs
*
* 单元测试tool-fixer 的各功能
* 运行方式node test/unit-tool-fixer.mjs
*/
// ─── 内联实现(与 src/tool-fixer.ts 保持同步,避免依赖 dist──────────────
const SMART_DOUBLE_QUOTES = new Set([
'\u00ab', '\u201c', '\u201d', '\u275e',
'\u201f', '\u201e', '\u275d', '\u00bb',
]);
const SMART_SINGLE_QUOTES = new Set([
'\u2018', '\u2019', '\u201a', '\u201b',
]);
function normalizeToolArguments(args) {
if (!args || typeof args !== 'object') return args;
if ('file_path' in args && !('path' in args)) {
args.path = args.file_path;
delete args.file_path;
}
return args;
}
function replaceSmartQuotes(text) {
const chars = [...text];
return chars.map(ch => {
if (SMART_DOUBLE_QUOTES.has(ch)) return '"';
if (SMART_SINGLE_QUOTES.has(ch)) return "'";
return ch;
}).join('');
}
function fixToolCallArguments(toolName, args) {
args = normalizeToolArguments(args);
// repairExactMatchToolArguments is skipped in unit test (needs file system)
return args;
}
// ─── 测试框架 ──────────────────────────────────────────────────────────
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (e) {
console.error(`${name}`);
console.error(` ${e.message}`);
failed++;
}
}
function assert(condition, msg) {
if (!condition) throw new Error(msg || 'Assertion failed');
}
function assertEqual(a, b, msg) {
const as = JSON.stringify(a), bs = JSON.stringify(b);
if (as !== bs) throw new Error(msg || `Expected ${bs}, got ${as}`);
}
// ════════════════════════════════════════════════════════════════════
// 1. normalizeToolArguments — 字段名映射
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [1] normalizeToolArguments — 字段名映射\n');
test('file_path → path 映射', () => {
const args = { file_path: 'src/index.ts', content: 'hello' };
const result = normalizeToolArguments(args);
assertEqual(result.path, 'src/index.ts');
assert(!('file_path' in result), 'file_path 应被删除');
assertEqual(result.content, 'hello', 'content 不应被修改');
});
test('已有 path 字段时不覆盖', () => {
const args = { file_path: 'old.ts', path: 'new.ts' };
const result = normalizeToolArguments(args);
assertEqual(result.path, 'new.ts', '应保留原始 path');
assert('file_path' in result, 'file_path 应保留');
});
test('无 file_path 时不影响', () => {
const args = { path: 'foo.ts', content: 'bar' };
const result = normalizeToolArguments(args);
assertEqual(result.path, 'foo.ts');
assertEqual(result.content, 'bar');
});
test('null/undefined 输入安全', () => {
assertEqual(normalizeToolArguments(null), null);
assertEqual(normalizeToolArguments(undefined), undefined);
});
test('空对象', () => {
const result = normalizeToolArguments({});
assertEqual(result, {});
});
// ════════════════════════════════════════════════════════════════════
// 2. replaceSmartQuotes — 智能引号替换
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [2] replaceSmartQuotes — 智能引号替换\n');
test('中文双引号 → 普通双引号', () => {
const input = '\u201c你好\u201d';
assertEqual(replaceSmartQuotes(input), '"你好"');
});
test('中文单引号 → 普通单引号', () => {
const input = '\u2018hello\u2019';
assertEqual(replaceSmartQuotes(input), "'hello'");
});
test('混合引号替换', () => {
const input = '\u201cHello\u201d and \u2018World\u2019';
assertEqual(replaceSmartQuotes(input), '"Hello" and \'World\'');
});
test('无智能引号时原样返回', () => {
const input = '"normal" and \'single\'';
assertEqual(replaceSmartQuotes(input), input);
});
test('空字符串', () => {
assertEqual(replaceSmartQuotes(''), '');
});
test('法文引号 « »', () => {
const input = '\u00abBonjour\u00bb';
assertEqual(replaceSmartQuotes(input), '"Bonjour"');
});
test('代码中的智能引号修复', () => {
const input = 'const name = \u201cClaude\u201d;';
assertEqual(replaceSmartQuotes(input), 'const name = "Claude";');
});
// ════════════════════════════════════════════════════════════════════
// 3. fixToolCallArguments — 综合修复
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [3] fixToolCallArguments — 综合修复\n');
test('Read 工具: file_path → path', () => {
const args = { file_path: 'src/main.ts' };
const result = fixToolCallArguments('Read', args);
assertEqual(result.path, 'src/main.ts');
assert(!('file_path' in result));
});
test('Write 工具: file_path + content 完整修复', () => {
const args = { file_path: 'test.ts', content: 'console.log("hello")' };
const result = fixToolCallArguments('Write', args);
assertEqual(result.path, 'test.ts');
assertEqual(result.content, 'console.log("hello")');
});
test('Bash 工具: 无映射需要', () => {
const args = { command: 'ls -la' };
const result = fixToolCallArguments('Bash', args);
assertEqual(result.command, 'ls -la');
});
test('非对象参数安全处理', () => {
assertEqual(fixToolCallArguments('Read', null), null);
assertEqual(fixToolCallArguments('Read', undefined), undefined);
});
// ════════════════════════════════════════════════════════════════════
// 4. parseToolCalls with fixToolCallArguments — 集成测试
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [4] parseToolCalls + fixToolCallArguments 集成\n');
function tolerantParse(jsonStr) {
try { return JSON.parse(jsonStr); } catch { /* pass */ }
let inString = false, escaped = false, fixed = '';
const bracketStack = [];
for (let i = 0; i < jsonStr.length; i++) {
const char = jsonStr[i];
if (char === '\\' && !escaped) { escaped = true; fixed += char; }
else if (char === '"' && !escaped) { inString = !inString; fixed += char; escaped = false; }
else { if (inString) { if (char === '\n') fixed += '\\n'; else if (char === '\r') fixed += '\\r'; else if (char === '\t') fixed += '\\t'; else fixed += char; } else { if (char === '{' || char === '[') bracketStack.push(char === '{' ? '}' : ']'); else if (char === '}' || char === ']') { if (bracketStack.length > 0) bracketStack.pop(); } fixed += char; } escaped = false; }
}
if (inString) fixed += '"';
while (bracketStack.length > 0) fixed += bracketStack.pop();
fixed = fixed.replace(/,\s*([}\]])/g, '$1');
try { return JSON.parse(fixed); } catch (_e2) {
const lastBrace = fixed.lastIndexOf('}');
if (lastBrace > 0) { try { return JSON.parse(fixed.substring(0, lastBrace + 1)); } catch { } }
throw _e2;
}
}
function parseToolCallsWithFix(responseText) {
const toolCalls = [];
let cleanText = responseText;
const fullBlockRegex = /```json(?:\s+action)?\s*([\s\S]*?)\s*```/g;
let match;
while ((match = fullBlockRegex.exec(responseText)) !== null) {
let isToolCall = false;
try {
const parsed = tolerantParse(match[1]);
if (parsed.tool || parsed.name) {
const name = parsed.tool || parsed.name;
let args = parsed.parameters || parsed.arguments || parsed.input || {};
args = fixToolCallArguments(name, args);
toolCalls.push({ name, arguments: args });
isToolCall = true;
}
} catch (e) { /* skip */ }
if (isToolCall) cleanText = cleanText.replace(match[0], '');
}
return { toolCalls, cleanText: cleanText.trim() };
}
test('解析含 file_path 的工具调用 → 自动修复为 path', () => {
const text = `I'll read the file now.
\`\`\`json action
{
"tool": "Read",
"parameters": {
"file_path": "src/index.ts"
}
}
\`\`\``;
const { toolCalls } = parseToolCallsWithFix(text);
assertEqual(toolCalls.length, 1);
assertEqual(toolCalls[0].name, 'Read');
assertEqual(toolCalls[0].arguments.path, 'src/index.ts');
assert(!('file_path' in toolCalls[0].arguments), 'file_path 应被删除');
});
test('多个工具调用全部修复', () => {
const text = `\`\`\`json action
{"tool":"Read","parameters":{"file_path":"a.ts"}}
\`\`\`
\`\`\`json action
{"tool":"Write","parameters":{"file_path":"b.ts","content":"hello"}}
\`\`\``;
const { toolCalls } = parseToolCallsWithFix(text);
assertEqual(toolCalls.length, 2);
assertEqual(toolCalls[0].arguments.path, 'a.ts');
assertEqual(toolCalls[1].arguments.path, 'b.ts');
assertEqual(toolCalls[1].arguments.content, 'hello');
});
test('无需修复的工具调用保持不变', () => {
const text = `\`\`\`json action
{"tool":"Bash","parameters":{"command":"npm run build"}}
\`\`\``;
const { toolCalls } = parseToolCallsWithFix(text);
assertEqual(toolCalls.length, 1);
assertEqual(toolCalls[0].arguments.command, 'npm run build');
});
// ════════════════════════════════════════════════════════════════════
// 汇总
// ════════════════════════════════════════════════════════════════════
console.log('\n' + '═'.repeat(55));
console.log(` 结果: ${passed} 通过 / ${failed} 失败 / ${passed + failed} 总计`);
console.log('═'.repeat(55) + '\n');
if (failed > 0) process.exit(1);