Files
cursor2api/test/unit-tolerant-parse.mjs
小海 c072795528 feat: 修复流式中断 + JSON 解析 + tool_choice 强制工具调用
核心修复:
- cursor-client.ts: 固定总超时 → 空闲超时,防止长输出被截断 (#12)
- converter.ts: tolerantParse 三级修复策略,处理截断 JSON (#13)
- types.ts: 新增 AnthropicToolChoice 类型,补齐 tool_choice 字段
- converter.ts: buildToolInstructions 支持 tool_choice,注入 MANDATORY 约束
- handler.ts: tool_choice=any 时检测无工具调用 → 自动追加强制消息重试

测试覆盖:
- test/unit-tolerant-parse.mjs: 18 个单元测试(tolerantParse/parseToolCalls)
- test/e2e-chat.mjs: 16 个 E2E 测试(基础问答、工具调用、流式、边界防御)
- test/e2e-agentic.mjs: 7 个 Agentic 压测(完整 Claude Code 工具链模拟)
- package.json: 新增 test:unit / test:e2e / test:agentic 快捷命令
2026-03-10 15:11:51 +08:00

307 lines
13 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-tolerant-parse.mjs
*
* 单元测试tolerantParse 和 parseToolCalls 的各种边界场景
* 运行方式node test/unit-tolerant-parse.mjs
*
* 无需服务器,完全离线运行。
*/
// ─── 从 dist/ 中直接引入已编译的 converter需要先 npm run build──────────
// 如果没有 dist也可以把 tolerantParse 的实现复制到此处做测试
// ─── 内联 tolerantParse与 src/converter.ts 保持同步)──────────────────────
function tolerantParse(jsonStr) {
try {
return JSON.parse(jsonStr);
} catch (_e1) { /* pass */ }
let inString = false;
let escaped = false;
let 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 { /* ignore */ }
}
throw _e2;
}
}
// ─── 内联 parseToolCalls与 src/converter.ts 保持同步)────────────────────
function parseToolCalls(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) {
toolCalls.push({
name: parsed.tool || parsed.name,
arguments: parsed.parameters || parsed.arguments || parsed.input || {}
});
isToolCall = true;
}
} catch (e) {
console.error(` ⚠ tolerantParse 失败:`, e.message);
}
if (isToolCall) cleanText = cleanText.replace(match[0], '');
}
return { toolCalls, cleanText: cleanText.trim() };
}
// ─── 测试框架(极简)────────────────────────────────────────────────────────
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. tolerantParse — 正常 JSON
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [1] tolerantParse — 正常 JSON\n');
test('标准 JSON 对象', () => {
const r = tolerantParse('{"tool":"Read","parameters":{"path":"/foo"}}');
assertEqual(r.tool, 'Read');
assertEqual(r.parameters.path, '/foo');
});
test('带换行缩进的 JSON', () => {
const r = tolerantParse(`{
"tool": "Write",
"parameters": {
"file_path": "src/index.ts",
"content": "hello world"
}
}`);
assertEqual(r.tool, 'Write');
});
test('空对象', () => {
const r = tolerantParse('{}');
assertEqual(r, {});
});
// ════════════════════════════════════════════════════════════════════
// 2. tolerantParse — 字符串内含裸换行(流式输出常见场景)
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [2] tolerantParse — 字符串内含裸换行\n');
test('value 中含裸 \\n', () => {
// 模拟content 字段值里有多行文本,但 JSON 没有转义换行
const raw = '{"tool":"Write","parameters":{"content":"line1\nline2\nline3"}}';
const r = tolerantParse(raw);
assert(r.parameters.content.includes('\n') || r.parameters.content.includes('\\n'),
'content 应包含换行信息');
});
test('value 中含裸 \\t', () => {
const raw = '{"tool":"Bash","parameters":{"command":"echo\there"}}';
const r = tolerantParse(raw);
assert(r.parameters.command !== undefined);
});
// ════════════════════════════════════════════════════════════════════
// 3. tolerantParse — 截断 JSON核心修复场景
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [3] tolerantParse — 截断 JSON未闭合字符串 / 括号)\n');
test('字符串在值中间截断', () => {
// 模拟:网络中断,"content" 字段值只传了一半
const truncated = '{"tool":"Write","parameters":{"content":"# Accrual Backfill Start Date Implementation Plan\\n\\n> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.\\n\\n**Goal:** Add an optional `backfillStartDate` parameter to the company-level accrual recalculate feature, allowing admins to specify a';
const r = tolerantParse(truncated);
// 能解析出来就行content 可能被截断但 tool 字段存在
assertEqual(r.tool, 'Write');
assert(r.parameters !== undefined);
});
test('只缺少最后的 }}', () => {
const truncated = '{"tool":"Read","parameters":{"file_path":"/Users/rain/project/src/index.ts"';
const r = tolerantParse(truncated);
assertEqual(r.tool, 'Read');
});
test('只缺少最后的 }', () => {
const truncated = '{"name":"Bash","input":{"command":"ls -la"}';
const r = tolerantParse(truncated);
assertEqual(r.name, 'Bash');
});
test('嵌套对象截断', () => {
const truncated = '{"tool":"Write","parameters":{"path":"a.ts","content":"export function foo() {\n return 42;\n}';
const r = tolerantParse(truncated);
assertEqual(r.tool, 'Write');
});
test('带尾部逗号', () => {
const withComma = '{"tool":"Read","parameters":{"path":"/foo",},}';
const r = tolerantParse(withComma);
assertEqual(r.tool, 'Read');
});
test('模拟 issue #13 原始错误 — position 813 截断', () => {
// 模拟一个约813字节的 content 字段在字符串中间截断
const longContent = 'A'.repeat(700);
const truncated = `{"tool":"Write","parameters":{"file_path":"/docs/plan.md","content":"${longContent}`;
const r = tolerantParse(truncated);
assertEqual(r.tool, 'Write');
// content 字段值可能被截断,但整体 JSON 应当能解析
assert(typeof r.parameters.content === 'string', 'content 应为字符串');
});
// ════════════════════════════════════════════════════════════════════
// 4. parseToolCalls — 完整 ```json action 块
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [4] parseToolCalls — 完整代码块\n');
test('单个工具调用块 (tool 字段)', () => {
const text = `I'll read the file now.
\`\`\`json action
{
"tool": "Read",
"parameters": {
"file_path": "src/index.ts"
}
}
\`\`\``;
const { toolCalls, cleanText } = parseToolCalls(text);
assertEqual(toolCalls.length, 1);
assertEqual(toolCalls[0].name, 'Read');
assertEqual(toolCalls[0].arguments.file_path, 'src/index.ts');
assert(!cleanText.includes('```'), '代码块应被移除');
});
test('单个工具调用块 (name 字段)', () => {
const text = `\`\`\`json action
{"name":"Bash","input":{"command":"npm run build"}}
\`\`\``;
const { toolCalls } = parseToolCalls(text);
assertEqual(toolCalls.length, 1);
assertEqual(toolCalls[0].name, 'Bash');
assertEqual(toolCalls[0].arguments.command, 'npm run build');
});
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 } = parseToolCalls(text);
assertEqual(toolCalls.length, 2);
assertEqual(toolCalls[0].name, 'Read');
assertEqual(toolCalls[1].name, 'Write');
});
test('工具调用前有解释文本', () => {
const text = `Let me first read the existing file to understand the structure.
\`\`\`json action
{"tool":"Read","parameters":{"file_path":"src/handler.ts"}}
\`\`\``;
const { toolCalls, cleanText } = parseToolCalls(text);
assertEqual(toolCalls.length, 1);
assert(cleanText.includes('Let me first read'), '解释文本应保留');
});
test('不含工具调用的纯文本', () => {
const text = 'Here is the answer: 42. No tool calls needed.';
const { toolCalls, cleanText } = parseToolCalls(text);
assertEqual(toolCalls.length, 0);
assertEqual(cleanText, text);
});
test('json 块但不是 tool call普通 json', () => {
const text = `Here is an example:
\`\`\`json
{"key":"value","count":42}
\`\`\``;
const { toolCalls } = parseToolCalls(text);
assertEqual(toolCalls.length, 0, '无 tool/name 字段的 JSON 不应被识别为工具调用');
});
// ════════════════════════════════════════════════════════════════════
// 5. 截断场景下的 parseToolCalls
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [5] parseToolCalls — 截断场景\n');
test('代码块内容被流中断block 完整但 JSON 截断)', () => {
// 完整的 ``` 包裹,但 JSON 内容被截断
const text = `\`\`\`json action
{"tool":"Write","parameters":{"file_path":"/docs/plan.md","content":"# Plan\n\nThis is a very long document that got cut at position 813 in the strea
\`\`\``;
const { toolCalls } = parseToolCalls(text);
// 应当能解析出工具调用(即使 content 被截断)
assertEqual(toolCalls.length, 1);
assertEqual(toolCalls[0].name, 'Write');
console.log(` → 解析出的 content 前30字符: "${String(toolCalls[0].arguments.content).substring(0, 30)}..."`);
});
// ════════════════════════════════════════════════════════════════════
// 汇总
// ════════════════════════════════════════════════════════════════════
console.log('\n' + '═'.repeat(55));
console.log(` 结果: ${passed} 通过 / ${failed} 失败 / ${passed + failed} 总计`);
console.log('═'.repeat(55) + '\n');
if (failed > 0) process.exit(1);