feat: v2.5.1 - 上下文智能压缩 + 截断检测 + tolerantParse 增强

🗜️ 智能压缩
- 长对话老消息压缩而非丢弃,保留因果链语义
- 工具结果压缩为摘要,助手消息保留工具名
- 压缩率 70-80%,解决 Cursor 上下文溢出问题

⚠️ 截断检测
- 代码块/XML 未闭合时返回 stop_reason=max_tokens
- Claude Code 自动继续,无需手动点击"继续"

🔧 tolerantParse
- 新增正则兜底层,处理未转义双引号的 JSON
- 解决 position 5384 等长参数解析崩溃

🛡️ 拒绝 fallback 优化
- 工具模式下返回极短引导文本
This commit is contained in:
小海
2026-03-10 17:29:49 +08:00
parent f12ca30893
commit 5f0f9b7936
7 changed files with 828 additions and 7 deletions

35
CHANGELOG.md Normal file
View File

@@ -0,0 +1,35 @@
# Changelog
## v2.5.1 (2026-03-10)
### 🗜️ 上下文智能压缩
解决 Claude Code 频繁出现"继续"按钮的核心问题。
- **智能压缩替代裁剪**:当对话消息超过 30 条或总字符超过 60K 时,自动压缩老消息而非丢弃
- 工具结果 `Action output: <30KB 文件内容>``Action output: [30000 chars, 247 lines] import ...`
- 助手工具调用 → `[Called read_file(file_path)]`(保留工具名和参数名)
- 保留因果链语义,减少 70-80% 字符量
- **保留区策略**few-shot 头部 2 条 + 最近 6 条消息始终保持完整原文
### ⚠️ 截断检测
- **自动检测被截断的响应**代码块未闭合、XML 标签未闭合时,返回 `stop_reason: "max_tokens"` 让 Claude Code 自动继续,无需手动点击"继续"
- 同时应用于流式和非流式响应
### 🔧 tolerantParse 增强
- **新增第四层正则兜底**:当模型生成的 JSON 工具调用包含未转义双引号(如代码内容参数)导致标准解析和控制字符修复均失败时,使用正则提取 `tool` 名称和 `parameters` 字段
- 解决 `SyntaxError: Expected ',' or '}'` at position 5384 等长参数解析崩溃问题
### 🛡️ 拒绝 Fallback 优化
- 工具模式下拒绝时返回极短文本 `"Let me proceed with the task."`,避免 Claude Code 误判为任务完成
---
## v2.5.0 (2026-03-10)
- OpenAI Responses API (`/v1/responses`) 支持 Cursor IDE Agent 模式
- 跨协议防御对齐Anthropic + OpenAI handler 共享拒绝检测和重试逻辑)
- 统一图片预处理管道OCR/Vision API

View File

@@ -1,6 +1,6 @@
{
"name": "cursor2api",
"version": "2.5.0",
"version": "2.5.1",
"description": "Proxy Cursor docs AI to Anthropic Messages API for Claude Code",
"type": "module",
"scripts": {

View File

@@ -248,6 +248,33 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
}
}
// ★ 智能压缩:工具模式下,总字符数超标时压缩老消息(而非丢弃)
// 保留完整的因果链(做了什么→得了什么),但大幅减少 token 占用
if (hasTools && messages.length > FEWSHOT_COUNT) {
const charsBefore = messages.reduce((s, m) => s + m.parts.reduce((a, p) => a + (p.text?.length ?? 0), 0), 0);
if (charsBefore > MAX_CONTEXT_CHARS || messages.length > MAX_CURSOR_MESSAGES) {
// 保留最近 KEEP_RECENT 条消息原文,之前的消息做压缩
const keepRecentCount = Math.min(KEEP_RECENT_MESSAGES, messages.length - FEWSHOT_COUNT);
const compressBoundary = messages.length - keepRecentCount;
let compressedCount = 0;
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);
if (compressedCount > 0) {
console.log(`[Converter] 🗜️ 上下文压缩: ${charsBefore}${charsAfter} chars (压缩 ${compressedCount} 条, 保留最近 ${keepRecentCount} 条原文)`);
}
}
}
// 诊断日志:记录发给 Cursor docs AI 的消息摘要
let totalChars = 0;
for (let i = 0; i < messages.length; i++) {
@@ -269,6 +296,74 @@ export async function convertToCursorRequest(req: AnthropicRequest): Promise<Cur
// 最大工具结果长度(超过则截断,防止上下文溢出)
const MAX_TOOL_RESULT_LENGTH = 30000;
// ==================== 上下文压缩配置 ====================
const FEWSHOT_COUNT = 2; // few-shot 消息数(头部固定保留)
const MAX_CURSOR_MESSAGES = 30; // 触发压缩的消息条数阈值
const MAX_CONTEXT_CHARS = 60000; // 触发压缩的总字符数阈值(约 15K tokens
const KEEP_RECENT_MESSAGES = 6; // 保留最近 N 条消息为原文不压缩
const COMPRESS_CONTENT_MAX = 200; // 压缩后单条消息最大字符数
/**
* 智能压缩单条消息内容
* 保留因果链的语义信息,但大幅减少字符数
*/
function compressMessage(role: string, text: string): string {
// 短消息不压缩
if (text.length <= COMPRESS_CONTENT_MAX) return text;
if (role === 'user') {
// 用户消息(通常是工具结果)
// 检测 "Action output:" 模式 — 工具执行结果
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)}...`;
}
// 检测 "The action encountered an error:" 模式
const errorMatch = text.match(/^The action encountered an error:\n([\s\S]*?)(?:\n\nBased on the output above|$)/);
if (errorMatch) {
const errorText = errorMatch[1].substring(0, 150);
return `Action error: ${errorText}...`;
}
// 普通用户消息:保留前 200 字
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';
// 只保留参数的 key去掉大 value
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) + '...';
}
/**
* 检查消息是否包含 tool_result 块
*/
@@ -472,7 +567,53 @@ function tolerantParse(jsonStr: string): any {
return JSON.parse(fixed.substring(0, lastBrace + 1));
} catch { /* ignore */ }
}
// 全部修复手段失败,重新抛出原始错误
// 第四次尝试:正则提取 tool + parameters处理值中有未转义引号的情况
// 适用于模型生成的代码块参数包含未转义双引号
try {
const toolMatch = jsonStr.match(/"(?:tool|name)"\s*:\s*"([^"]+)"/);
if (toolMatch) {
const toolName = toolMatch[1];
// 尝试提取 parameters 对象
const paramsMatch = jsonStr.match(/"(?:parameters|arguments|input)"\s*:\s*(\{[\s\S]*)/);
let params: Record<string, unknown> = {};
if (paramsMatch) {
const paramsStr = paramsMatch[1];
// 逐字符找到 parameters 对象的闭合 }
let depth = 0;
let end = -1;
let pInString = false;
let 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');
}
}
}
}
console.log(`[Converter] tolerantParse 正则兜底成功: tool=${toolName}, params=${Object.keys(params).length} fields`);
return { tool: toolName, parameters: params };
}
} catch { /* ignore */ }
// 全部修复手段失败,重新抛出
throw _e2;
}
}

View File

@@ -400,6 +400,30 @@ export async function handleMessages(req: Request, res: Response): Promise<void>
}
}
// ==================== 截断检测 ====================
/**
* 检测响应是否被 Cursor 上下文窗口截断
* 截断症状:响应以句中断句结束,没有完整的句号/block 结束标志
* 这是导致 Claude Code 频繁出现"继续"的根本原因
*/
export function isTruncated(text: string): boolean {
if (!text || text.trim().length === 0) return false;
const trimmed = text.trimEnd();
// 代码块未闭合
const codeBlockOpen = (trimmed.match(/```/g) || []).length % 2 !== 0;
if (codeBlockOpen) return true;
// XML/HTML 标签未闭合 (Cursor 有时在中途截断)
const openTags = (trimmed.match(/^<[a-zA-Z]/gm) || []).length;
const closeTags = (trimmed.match(/^<\/[a-zA-Z]/gm) || []).length;
if (openTags > closeTags + 1) return true;
// 以逗号、分号、冒号、开括号结尾(明显未完成)
if (/[,;:\[{(]\s*$/.test(trimmed)) return true;
// 短响应且以小写字母结尾(句子被截断的强烈信号)
if (trimmed.length < 500 && /[a-z]$/.test(trimmed)) return false; // 短响应不判断
return false;
}
// ==================== 重试辅助 ====================
export const MAX_REFUSAL_RETRIES = 2;
@@ -513,8 +537,10 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
fullResponse = CLAUDE_IDENTITY_RESPONSE;
}
} else {
console.log(`[Handler] 工具模式拒绝且无工具调用,引导模型输出`);
fullResponse = 'I understand the request. Let me analyze the information and proceed with the appropriate action.';
// 工具模式拒绝:不返回纯文本(会让 Claude Code 误认为任务完成)
// 返回一个合理的纯文本,让它以 end_turn 结束Claude Code 会根据上下文继续
console.log(`[Handler] 工具模式下拒绝且无工具调用,返回简短引导文本`);
fullResponse = 'Let me proceed with the task.';
}
}
@@ -528,7 +554,12 @@ async function handleStream(res: Response, cursorReq: CursorChatRequest, body: A
}
// 流完成后,处理完整响应
let stopReason = 'end_turn';
// ★ 截断检测:代码块/XML 未闭合时,返回 max_tokens 让 Claude Code 自动继续
// 避免用户每次都要手动点击"继续"
let stopReason = (hasTools && isTruncated(fullResponse)) ? 'max_tokens' : 'end_turn';
if (stopReason === 'max_tokens') {
console.log(`[Handler] ⚠️ 检测到截断响应 (${fullResponse.length} chars),设置 stop_reason=max_tokens`);
}
if (hasTools) {
let { toolCalls, cleanText } = parseToolCalls(fullResponse);
@@ -733,7 +764,11 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
}
const contentBlocks: AnthropicContentBlock[] = [];
let stopReason = 'end_turn';
// ★ 截断检测:代码块/XML 未闭合时,返回 max_tokens 让 Claude Code 自动继续
let stopReason = (hasTools && isTruncated(fullText)) ? 'max_tokens' : 'end_turn';
if (stopReason === 'max_tokens') {
console.log(`[Handler] ⚠️ 非流式检测到截断响应 (${fullText.length} chars),设置 stop_reason=max_tokens`);
}
if (hasTools) {
let { toolCalls, cleanText } = parseToolCalls(fullText);
@@ -762,7 +797,7 @@ async function handleNonStream(res: Response, cursorReq: CursorChatRequest, body
let textToSend = fullText;
if (isRefusal(fullText)) {
console.log(`[Handler] Supressed pure text refusal (non-stream): ${fullText.substring(0, 100)}...`);
textToSend = 'I understand the request. Let me proceed with the appropriate action. Could you clarify what specific task you would like me to perform?';
textToSend = 'Let me proceed with the task.';
}
contentBlocks.push({ type: 'text', text: textToSend });
}

186
test/compression-test.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* 快速测试:上下文压缩 + 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);

203
test/e2e-test.ts Normal file
View File

@@ -0,0 +1,203 @@
/**
* 端到端测试:向真实 Cursor2API 服务发送请求
*
* 测试场景:
* 1. 简单请求能正常返回
* 2. 带工具的多轮长对话触发压缩
* 3. 验证 stop_reason 正确
*/
const API_URL = 'http://localhost:3010/v1/messages';
interface TestResult {
name: string;
passed: boolean;
detail: string;
}
const results: TestResult[] = [];
function assert(name: string, condition: boolean, detail = '') {
results.push({ name, passed: condition, detail });
console.log(condition ? `${name}` : `${name}: ${detail}`);
}
// 构造一个模拟 Claude Code 的长对话请求(带很多轮工具交互历史)
function buildLongToolRequest(turnCount: number) {
const messages: any[] = [];
// 模拟多轮工具交互历史
for (let i = 0; i < turnCount; i++) {
if (i === 0) {
// 第一轮:用户发起请求
messages.push({
role: 'user',
content: 'Help me analyze the project structure. Read the main entry file first.'
});
} else {
// 工具结果
messages.push({
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: `tool_${i}`,
content: `File content of module${i}.ts:\n` +
`import { something } from './utils';\n\n` +
`export class Module${i} {\n` +
Array.from({length: 30}, (_, j) => ` method${j}() { return ${j}; }`).join('\n') +
`\n}\n`
}
]
});
}
// 助手的工具调用
messages.push({
role: 'assistant',
content: [
{ type: 'text', text: `Let me check module${i + 1}.` },
{
type: 'tool_use',
id: `tool_${i + 1}`,
name: 'Read',
input: { file_path: `src/module${i + 1}.ts` }
}
]
});
}
// 最后一轮工具结果
messages.push({
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: `tool_${turnCount}`,
content: 'File not found: src/module' + turnCount + '.ts'
}
]
});
return {
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
stream: false,
system: 'You are a helpful coding assistant.',
tools: [
{
name: 'Read',
description: 'Read a file from disk',
input_schema: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file' }
},
required: ['file_path']
}
},
{
name: 'Bash',
description: 'Execute a shell command',
input_schema: {
type: 'object',
properties: {
command: { type: 'string', description: 'The command to execute' }
},
required: ['command']
}
}
],
messages
};
}
async function runTests() {
console.log('\n=== 测试 1基本请求 ===');
try {
const resp = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'test' },
body: JSON.stringify({
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
stream: false,
messages: [{ role: 'user', content: 'Say "hello" in one word.' }]
})
});
assert('服务器响应', resp.ok, `status=${resp.status}`);
const data = await resp.json();
assert('返回 message 类型', data.type === 'message', `type=${data.type}`);
assert('stop_reason 是 end_turn', data.stop_reason === 'end_turn', `stop_reason=${data.stop_reason}`);
assert('有 content', data.content?.length > 0, `content=${JSON.stringify(data.content)}`);
console.log(` 📝 响应: ${data.content?.[0]?.text?.substring(0, 100)}`);
} catch (e: any) {
assert('基本请求', false, e.message);
}
console.log('\n=== 测试 2长对话工具请求触发压缩===');
try {
const longReq = buildLongToolRequest(18); // 18 轮 → 37 条消息
console.log(` 📊 发送 ${longReq.messages.length} 条消息...`);
const resp = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'test' },
body: JSON.stringify(longReq)
});
assert('长对话服务器响应', resp.ok, `status=${resp.status}`);
const data = await resp.json();
assert('长对话返回 message', data.type === 'message', `type=${data.type}`);
assert('长对话有 content', data.content?.length > 0);
// 检查 stop_reason
const validStops = ['end_turn', 'tool_use', 'max_tokens'];
assert('stop_reason 合法', validStops.includes(data.stop_reason), `stop_reason=${data.stop_reason}`);
console.log(` 📝 stop_reason: ${data.stop_reason}`);
console.log(` 📝 content blocks: ${data.content?.length}`);
if (data.content?.[0]?.text) {
console.log(` 📝 响应片段: ${data.content[0].text.substring(0, 150)}...`);
}
} catch (e: any) {
assert('长对话请求', false, e.message);
}
console.log('\n=== 测试 3流式请求 ===');
try {
const resp = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'test' },
body: JSON.stringify({
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
stream: true,
messages: [{ role: 'user', content: 'Say "world" in one word.' }]
})
});
assert('流式响应 200', resp.ok, `status=${resp.status}`);
assert('Content-Type 是 SSE', resp.headers.get('content-type')?.includes('text/event-stream') ?? false);
const body = await resp.text();
const events = body.split('\n').filter(l => l.startsWith('event:'));
assert('有 SSE 事件', events.length > 0, `events=${events.length}`);
assert('包含 message_start', body.includes('message_start'));
assert('包含 message_stop', body.includes('message_stop'));
// 检查 stop_reason
const deltaMatch = body.match(/"stop_reason"\s*:\s*"([^"]+)"/);
if (deltaMatch) {
assert('流式 stop_reason 合法', ['end_turn', 'tool_use', 'max_tokens'].includes(deltaMatch[1]), `stop_reason=${deltaMatch[1]}`);
}
console.log(` 📝 SSE 事件数: ${events.length}`);
} catch (e: any) {
assert('流式请求', false, e.message);
}
// 总结
const passed = results.filter(r => r.passed).length;
const failed = results.filter(r => !r.passed).length;
console.log(`\n=== 端到端结果: ${passed} 通过, ${failed} 失败 ===\n`);
}
runTests().catch(console.error);

View File

@@ -0,0 +1,221 @@
/**
* 集成测试:模拟长对话,验证上下文压缩流程
*
* 测试场景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);