mirror of
https://github.com/7836246/cursor2api.git
synced 2026-05-07 22:27:15 +08:00
核心修复: - 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 快捷命令
307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
/**
|
||
* 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);
|