{ "message": "fix: stop renaming file_path to path implicitly, and remove compression tests" }

This commit is contained in:
小海
2026-03-11 09:55:15 +08:00
parent 74f1a632a9
commit ecf4fa82ee
4 changed files with 22 additions and 433 deletions

View File

@@ -25,10 +25,9 @@ const SMART_SINGLE_QUOTES = new Set([
export function normalizeToolArguments(args: Record<string, unknown>): Record<string, unknown> {
if (!args || typeof args !== 'object') return args;
if ('file_path' in args && !('path' in args)) {
args.path = args.file_path;
delete args.file_path;
}
// Removed legacy mapping that forcefully converted 'file_path' to 'path'.
// Claude Code 2.1.71 tools like 'Read' legitimately require 'file_path' as per their schema,
// and this legacy mapping causes infinite loop failures.
return args;
}

View File

@@ -1,186 +0,0 @@
/**
* 快速测试:上下文压缩 + tolerantParse 增强
*/
// ==================== 1. tolerantParse 测试 ====================
// 内联一个简化版 tolerantParse 进行测试
function tolerantParse(jsonStr: string): any {
try { return JSON.parse(jsonStr); } catch {}
let inString = false, escaped = false, fixed = '';
const bracketStack: string[] = [];
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 {} }
// 第四层:正则兜底
try {
const toolMatch = jsonStr.match(/"(?:tool|name)"\s*:\s*"([^"]+)"/);
if (toolMatch) {
const toolName = toolMatch[1];
const paramsMatch = jsonStr.match(/"(?:parameters|arguments|input)"\s*:\s*(\{[\s\S]*)/);
let params: Record<string, unknown> = {};
if (paramsMatch) {
const paramsStr = paramsMatch[1];
let depth = 0, end = -1, pInString = false, pEscaped = false;
for (let i = 0; i < paramsStr.length; i++) {
const c = paramsStr[i];
if (c === '\\' && !pEscaped) { pEscaped = true; continue; }
if (c === '"' && !pEscaped) { pInString = !pInString; }
if (!pInString) {
if (c === '{') depth++;
if (c === '}') { depth--; if (depth === 0) { end = i; break; } }
}
pEscaped = false;
}
if (end > 0) {
const rawParams = paramsStr.substring(0, end + 1);
try { params = JSON.parse(rawParams); } catch {
const fieldRegex = /"([^"]+)"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
let fm;
while ((fm = fieldRegex.exec(rawParams)) !== null) {
params[fm[1]] = fm[2].replace(/\\n/g, '\n').replace(/\\t/g, '\t');
}
}
}
}
return { tool: toolName, parameters: params };
}
} catch {}
throw _e2;
}
}
// ==================== 2. compressMessage 测试 ====================
const COMPRESS_CONTENT_MAX = 200;
function compressMessage(role: string, text: string): string {
if (text.length <= COMPRESS_CONTENT_MAX) return text;
if (role === 'user') {
const actionMatch = text.match(/^Action output:\n([\s\S]*?)(?:\n\nBased on the output above|$)/);
if (actionMatch) {
const output = actionMatch[1];
const firstLine = output.split('\n')[0]?.trim() || '';
const lineCount = output.split('\n').length;
return `Action output: [${output.length} chars, ${lineCount} lines] ${firstLine.substring(0, 80)}...`;
}
const errorMatch = text.match(/^The action encountered an error:\n([\s\S]*?)(?:\n\nBased on the output above|$)/);
if (errorMatch) {
return `Action error: ${errorMatch[1].substring(0, 150)}...`;
}
return text.substring(0, COMPRESS_CONTENT_MAX) + `... [${text.length} chars total]`;
}
if (role === 'assistant') {
const toolBlocks = text.match(/```json action\s*\n([\s\S]*?)```/g);
if (toolBlocks && toolBlocks.length > 0) {
const summaries: string[] = [];
for (const block of toolBlocks) {
try {
const jsonMatch = block.match(/```json action\s*\n([\s\S]*?)```/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[1]);
const toolName = parsed.tool || parsed.name || 'unknown';
const paramKeys = parsed.parameters ? Object.keys(parsed.parameters) : [];
summaries.push(`[Called ${toolName}(${paramKeys.join(', ')})]`);
}
} catch { summaries.push('[Called action]'); }
}
const cleanText = text.replace(/```json action\s*\n[\s\S]*?```/g, '').trim();
const briefText = cleanText.length > 100 ? cleanText.substring(0, 100) + '...' : cleanText;
return (briefText ? briefText + '\n' : '') + summaries.join('\n');
}
return text.substring(0, COMPRESS_CONTENT_MAX) + `... [${text.length} chars]`;
}
return text.substring(0, COMPRESS_CONTENT_MAX) + '...';
}
// ==================== 运行测试 ====================
let passed = 0, failed = 0;
function assert(name: string, condition: boolean, detail?: string) {
if (condition) { passed++; console.log(`${name}`); }
else { failed++; console.log(`${name}${detail ? ': ' + detail : ''}`); }
}
console.log('\n=== tolerantParse 测试 ===');
// 正常 JSON
const t1 = tolerantParse('{"tool":"read_file","parameters":{"file_path":"src/index.ts"}}');
assert('正常 JSON', t1.tool === 'read_file' && t1.parameters.file_path === 'src/index.ts');
// 带裸换行符
const t2 = tolerantParse('{"tool":"write_file","parameters":{"content":"line1\nline2"}}');
assert('裸换行修复', t2.tool === 'write_file');
// 截断 JSON未闭合
const t3 = tolerantParse('{"tool":"bash","parameters":{"command":"ls -la');
assert('截断兜底', t3.tool === 'bash');
// 含未转义引号的代码内容(最重要的场景)
const badJson = `{
"tool": "write_file",
"parameters": {
"file_path": "test.ts",
"content": "const x = "hello"; console.log(x);"
}
}`;
const t4 = tolerantParse(badJson);
assert('未转义引号 - 提取 tool 名', t4.tool === 'write_file');
assert('未转义引号 - 提取参数', Object.keys(t4.parameters).length > 0, `keys=${JSON.stringify(Object.keys(t4.parameters))}`);
// 尾部逗号
const t5 = tolerantParse('{"tool":"list_dir","parameters":{"path":"./",},}');
assert('尾部逗号修复', t5.tool === 'list_dir');
console.log('\n=== compressMessage 测试 ===');
// 短消息不压缩
assert('短消息保留', compressMessage('user', 'hello world') === 'hello world');
// 长工具结果压缩
const longResult = 'Action output:\n' + 'x'.repeat(5000) + '\n\nBased on the output above, continue...';
const c1 = compressMessage('user', longResult);
assert('工具结果压缩', c1.length < 200, `压缩到 ${c1.length} chars`);
assert('工具结果保留信息', c1.includes('5000 chars') && c1.includes('Action output'));
// 错误结果压缩
const errorResult = 'The action encountered an error:\nPermission denied: cannot access /root/secret\n\nBased on the output above, continue...';
const c2 = compressMessage('user', errorResult.padEnd(300, ' detail'));
assert('错误结果压缩', c2.startsWith('Action error:'));
// 助手消息(工具调用)压缩
const assistantMsg = `Let me check the file structure first.\n\n\`\`\`json action\n{"tool":"read_file","parameters":{"file_path":"src/index.ts"}}\n\`\`\`\n\nAnd then more text here to pad the message beyond the threshold limit. ${'x'.repeat(200)}`;
const c3 = compressMessage('assistant', assistantMsg);
assert('助手消息压缩保留工具名', c3.includes('[Called read_file(file_path)]'), c3);
assert('助手消息压缩', c3.length < assistantMsg.length, `${c3.length} < ${assistantMsg.length}`);
// 普通长用户消息
const longUser = 'Please help me with '.padEnd(500, 'this task ');
const c4 = compressMessage('user', longUser);
assert('普通用户消息截短', c4.length < 300 && c4.includes('chars total'));
console.log(`\n=== 结果: ${passed} 通过, ${failed} 失败 ===\n`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -1,221 +0,0 @@
/**
* 集成测试:模拟长对话,验证上下文压缩流程
*
* 测试场景30+ 条消息的工具模式对话,验证:
* 1. 压缩触发条件(消息数 > 30 或字符数 > 60000
* 2. few-shot 头部不被压缩
* 3. 最近 6 条消息保持原文
* 4. 中间老消息被压缩
* 5. 消息数量不变(压缩不丢弃)
* 6. 总字符数显著减少
*/
// 模拟 CursorMessage 类型
interface CursorMessage {
parts: { type: string; text?: string }[];
id: string;
role: string;
}
// 从 converter.ts 复制常量
const FEWSHOT_COUNT = 2;
const MAX_CURSOR_MESSAGES = 30;
const MAX_CONTEXT_CHARS = 60000;
const KEEP_RECENT_MESSAGES = 6;
const COMPRESS_CONTENT_MAX = 200;
// 从 converter.ts 复制 compressMessage
function compressMessage(role: string, text: string): string {
if (text.length <= COMPRESS_CONTENT_MAX) return text;
if (role === 'user') {
const actionMatch = text.match(/^Action output:\n([\s\S]*?)(?:\n\nBased on the output above|$)/);
if (actionMatch) {
const output = actionMatch[1];
const firstLine = output.split('\n')[0]?.trim() || '';
const lineCount = output.split('\n').length;
return `Action output: [${output.length} chars, ${lineCount} lines] ${firstLine.substring(0, 80)}...`;
}
const errorMatch = text.match(/^The action encountered an error:\n([\s\S]*?)(?:\n\nBased on the output above|$)/);
if (errorMatch) {
return `Action error: ${errorMatch[1].substring(0, 150)}...`;
}
return text.substring(0, COMPRESS_CONTENT_MAX) + `... [${text.length} chars total]`;
}
if (role === 'assistant') {
const toolBlocks = text.match(/```json action\s*\n([\s\S]*?)```/g);
if (toolBlocks && toolBlocks.length > 0) {
const summaries: string[] = [];
for (const block of toolBlocks) {
try {
const jsonMatch = block.match(/```json action\s*\n([\s\S]*?)```/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[1]);
const toolName = parsed.tool || parsed.name || 'unknown';
const paramKeys = parsed.parameters ? Object.keys(parsed.parameters) : [];
summaries.push(`[Called ${toolName}(${paramKeys.join(', ')})]`);
}
} catch { summaries.push('[Called action]'); }
}
const cleanText = text.replace(/```json action\s*\n[\s\S]*?```/g, '').trim();
const briefText = cleanText.length > 100 ? cleanText.substring(0, 100) + '...' : cleanText;
return (briefText ? briefText + '\n' : '') + summaries.join('\n');
}
return text.substring(0, COMPRESS_CONTENT_MAX) + `... [${text.length} chars]`;
}
return text.substring(0, COMPRESS_CONTENT_MAX) + '...';
}
// 模拟 converter.ts 中的压缩流程
function applyCompression(messages: CursorMessage[], hasTools: boolean): { compressedCount: number; charsBefore: number; charsAfter: number } {
let compressedCount = 0;
const charsBefore = messages.reduce((s, m) => s + m.parts.reduce((a, p) => a + (p.text?.length ?? 0), 0), 0);
if (hasTools && messages.length > FEWSHOT_COUNT) {
if (charsBefore > MAX_CONTEXT_CHARS || messages.length > MAX_CURSOR_MESSAGES) {
const keepRecentCount = Math.min(KEEP_RECENT_MESSAGES, messages.length - FEWSHOT_COUNT);
const compressBoundary = messages.length - keepRecentCount;
for (let i = FEWSHOT_COUNT; i < compressBoundary; i++) {
const original = messages[i].parts.map(p => p.text ?? '').join('');
const compressed = compressMessage(messages[i].role, original);
if (compressed.length < original.length) {
messages[i] = { ...messages[i], parts: [{ type: 'text', text: compressed }] };
compressedCount++;
}
}
}
}
const charsAfter = messages.reduce((s, m) => s + m.parts.reduce((a, p) => a + (p.text?.length ?? 0), 0), 0);
return { compressedCount, charsBefore, charsAfter };
}
// ==================== 构造测试数据 ====================
function buildLongConversation(turnCount: number): CursorMessage[] {
const messages: CursorMessage[] = [];
// few-shot 头部 (2 条)
messages.push({
parts: [{ type: 'text', text: 'You are a coding assistant. Use tools with ```json action``` format...' }],
id: 'fs1', role: 'user'
});
messages.push({
parts: [{ type: 'text', text: 'Understood. I\'ll use the structured format for actions.' }],
id: 'fs2', role: 'assistant'
});
// 模拟 N 轮工具交互
for (let i = 0; i < turnCount; i++) {
// 用户请求或工具结果
if (i === 0) {
messages.push({
parts: [{ type: 'text', text: `Please read the file src/module${i}.ts and analyze its structure.\n\nRespond with the appropriate action using the structured format.` }],
id: `u${i}`, role: 'user'
});
} else {
// 工具结果:模拟真实大小的文件内容
const fileContent = `import { something } from './utils';\n\nexport class Module${i} {\n` +
Array.from({ length: 50 }, (_, j) => ` public method${j}(): void { /* implementation line ${j} */ }`).join('\n') +
`\n}\n`;
messages.push({
parts: [{ type: 'text', text: `Action output:\n${fileContent}\n\nBased on the output above, continue with the next appropriate action using the structured format.` }],
id: `u${i}`, role: 'user'
});
}
// 助手的工具调用
messages.push({
parts: [{
type: 'text',
text: `Let me examine the structure of module${i}.\n\n\`\`\`json action\n{"tool": "read_file", "parameters": {"file_path": "src/module${i}.ts"}}\n\`\`\``
}],
id: `a${i}`, role: 'assistant'
});
}
return messages;
}
// ==================== 运行测试 ====================
let passed = 0, failed = 0;
function assert(name: string, condition: boolean, detail?: string) {
if (condition) { passed++; console.log(`${name}`); }
else { failed++; console.log(`${name}${detail ? ': ' + detail : ''}`); }
}
console.log('\n=== 场景 1短对话不触发压缩===');
{
const msgs = buildLongConversation(3); // 2 few-shot + 6 实际 = 8 条
const originalCount = msgs.length;
const { compressedCount, charsBefore, charsAfter } = applyCompression(msgs, true);
assert('短对话不压缩', compressedCount === 0, `compressed=${compressedCount}`);
assert('消息数不变', msgs.length === originalCount);
assert('字符数不变', charsBefore === charsAfter);
console.log(` 📊 ${msgs.length} 条消息, ${charsBefore} chars`);
}
console.log('\n=== 场景 2长对话触发按条数压缩===');
{
const msgs = buildLongConversation(20); // 2 + 40 = 42 条消息
const originalCount = msgs.length;
const { compressedCount, charsBefore, charsAfter } = applyCompression(msgs, true);
assert('压缩触发', compressedCount > 0, `compressed=${compressedCount}`);
assert('消息数不变(压缩不丢弃)', msgs.length === originalCount, `${msgs.length} vs ${originalCount}`);
assert('总字符减少', charsAfter < charsBefore, `${charsAfter} < ${charsBefore}`);
assert('压缩率 >50%', charsAfter < charsBefore * 0.5, `ratio=${(charsAfter / charsBefore * 100).toFixed(1)}%`);
console.log(` 📊 ${msgs.length} 条, ${charsBefore}${charsAfter} chars (${(100 - charsAfter / charsBefore * 100).toFixed(1)}% 减少)`);
// 验证 few-shot 头部不被压缩
assert('few-shot[0] 保持原文', msgs[0].parts[0].text!.includes('You are a coding assistant'));
assert('few-shot[1] 保持原文', msgs[1].parts[0].text!.includes('Understood'));
// 验证最近 6 条保持完整
const lastSix = msgs.slice(-6);
for (const m of lastSix) {
const text = m.parts[0].text!;
const isOriginal = text.includes('Action output:\n') || text.includes('```json action');
assert(`最近消息保持原文 (role=${m.role}, ${text.length} chars)`, isOriginal || text.length <= COMPRESS_CONTENT_MAX);
}
// 验证中间消息被压缩
const midMsg = msgs[4]; // 第 5 条消息(应在压缩区)
assert('中间消息已压缩', midMsg.parts[0].text!.length < 300, `len=${midMsg.parts[0].text!.length}`);
}
console.log('\n=== 场景 3非工具模式不压缩===');
{
const msgs = buildLongConversation(20);
const { compressedCount } = applyCompression(msgs, false);
assert('非工具模式不压缩', compressedCount === 0);
}
console.log('\n=== 场景 4大字符数但少消息按字符数触发===');
{
const msgs: CursorMessage[] = [
{ parts: [{ type: 'text', text: 'few-shot user' }], id: 'fs1', role: 'user' },
{ parts: [{ type: 'text', text: 'few-shot assistant' }], id: 'fs2', role: 'assistant' },
];
// 10 轮,但每条都很大
for (let i = 0; i < 10; i++) {
msgs.push({
parts: [{ type: 'text', text: `Action output:\n${'x'.repeat(8000)}\n\nBased on the output above, continue with the next appropriate action using the structured format.` }],
id: `u${i}`, role: 'user'
});
msgs.push({
parts: [{ type: 'text', text: `Analysis done.\n\n\`\`\`json action\n{"tool": "write_file", "parameters": {"file_path": "out${i}.ts", "content": "${'y'.repeat(3000)}"}}\n\`\`\`` }],
id: `a${i}`, role: 'assistant'
});
}
const originalChars = msgs.reduce((s, m) => s + m.parts.reduce((a, p) => a + (p.text?.length ?? 0), 0), 0);
assert('超过字符阈值', originalChars > MAX_CONTEXT_CHARS, `${originalChars} > ${MAX_CONTEXT_CHARS}`);
const { compressedCount, charsBefore, charsAfter } = applyCompression(msgs, true);
assert('按字符数触发压缩', compressedCount > 0);
assert('字符数大幅减少', charsAfter < charsBefore * 0.5, `${charsAfter} < ${charsBefore * 0.5}`);
console.log(` 📊 ${msgs.length} 条, ${charsBefore}${charsAfter} chars (${(100 - charsAfter / charsBefore * 100).toFixed(1)}% 减少)`);
}
console.log(`\n=== 总结果: ${passed} 通过, ${failed} 失败 ===\n`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -17,10 +17,7 @@ const SMART_SINGLE_QUOTES = new Set([
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;
}
// Removed legacy file_path to path conversion
return args;
}
@@ -69,19 +66,19 @@ function assertEqual(a, b, msg) {
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [1] normalizeToolArguments — 字段名映射\n');
test('file_pathpath 映射', () => {
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 不应被修改');
assertEqual(result.file_path, 'src/index.ts', '应保留原始 file_path');
assert(!('path' in result), '不应自动生成 path');
assertEqual(result.content, 'hello');
});
test('已有 path 字段时不覆盖', () => {
test('同时存在时保持不变', () => {
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 应保留');
assertEqual(result.path, 'new.ts');
assert('file_path' in result);
});
test('无 file_path 时不影响', () => {
@@ -145,17 +142,17 @@ test('代码中的智能引号修复', () => {
// ════════════════════════════════════════════════════════════════════
console.log('\n📦 [3] fixToolCallArguments — 综合修复\n');
test('Read 工具: file_path path', () => {
test('Read 工具: file_path 保持 file_path', () => {
const args = { file_path: 'src/main.ts' };
const result = fixToolCallArguments('Read', args);
assertEqual(result.path, 'src/main.ts');
assert(!('file_path' in result));
assertEqual(result.file_path, 'src/main.ts');
assert(!('path' in result));
});
test('Write 工具: file_path + content 完整修复', () => {
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.file_path, 'test.ts');
assertEqual(result.content, 'console.log("hello")');
});
@@ -217,7 +214,7 @@ function parseToolCallsWithFix(responseText) {
return { toolCalls, cleanText: cleanText.trim() };
}
test('解析含 file_path 的工具调用 → 自动修复为 path', () => {
test('解析含 file_path 的工具调用 → 保持为 file_path', () => {
const text = `I'll read the file now.
\`\`\`json action
@@ -231,11 +228,11 @@ test('解析含 file_path 的工具调用 → 自动修复为 path', () => {
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 应被删除');
assertEqual(toolCalls[0].arguments.file_path, 'src/index.ts');
assert(!('path' in toolCalls[0].arguments), '不应生成 path');
});
test('多个工具调用全部修复', () => {
test('多个工具调用不再强转', () => {
const text = `\`\`\`json action
{"tool":"Read","parameters":{"file_path":"a.ts"}}
\`\`\`
@@ -245,8 +242,8 @@ test('多个工具调用全部修复', () => {
\`\`\``;
const { toolCalls } = parseToolCallsWithFix(text);
assertEqual(toolCalls.length, 2);
assertEqual(toolCalls[0].arguments.path, 'a.ts');
assertEqual(toolCalls[1].arguments.path, 'b.ts');
assertEqual(toolCalls[0].arguments.file_path, 'a.ts');
assertEqual(toolCalls[1].arguments.file_path, 'b.ts');
assertEqual(toolCalls[1].arguments.content, 'hello');
});