Files
cursor2api/test/e2e-chat.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

397 lines
19 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/e2e-chat.mjs
*
* 端到端测试:向本地代理服务器 (localhost:3010) 发送真实请求
* 测试普通问答、工具调用、长输出等场景
*
* 运行方式:
* 1. 先启动服务: npm run dev (或 npm start)
* 2. node test/e2e-chat.mjs
*
* 可通过环境变量自定义端口PORT=3010 node test/e2e-chat.mjs
*/
const BASE_URL = `http://localhost:${process.env.PORT || 3010}`;
const MODEL = 'claude-3-5-sonnet-20241022';
// ─── 颜色输出 ───────────────────────────────────────────────────────────────
const C = {
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m',
cyan: '\x1b[36m', blue: '\x1b[34m', magenta: '\x1b[35m',
};
const ok = (s) => `${C.green}${s}${C.reset}`;
const err = (s) => `${C.red}${s}${C.reset}`;
const hdr = (s) => `\n${C.bold}${C.cyan}━━━ ${s} ━━━${C.reset}`;
const dim = (s) => `${C.dim}${s}${C.reset}`;
// ─── 请求辅助 ───────────────────────────────────────────────────────────────
async function chat(messages, { tools, stream = false, label } = {}) {
const body = { model: MODEL, max_tokens: 4096, messages, stream };
if (tools) body.tools = tools;
const t0 = Date.now();
const resp = await fetch(`${BASE_URL}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'dummy' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`HTTP ${resp.status}: ${text}`);
}
if (stream) {
return await collectStream(resp, t0, label);
} else {
const data = await resp.json();
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
return { data, elapsed };
}
}
async function collectStream(resp, t0, label = '') {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullText = '';
let toolCalls = [];
let stopReason = null;
let chunkCount = 0;
process.stdout.write(` ${C.dim}[stream${label ? ' · ' + label : ''}]${C.reset} `);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (!data) continue;
try {
const evt = JSON.parse(data);
if (evt.type === 'content_block_delta') {
if (evt.delta?.type === 'text_delta') {
fullText += evt.delta.text;
chunkCount++;
if (chunkCount % 20 === 0) process.stdout.write('.');
} else if (evt.delta?.type === 'input_json_delta') {
chunkCount++;
}
} else if (evt.type === 'content_block_start' && evt.content_block?.type === 'tool_use') {
toolCalls.push({ name: evt.content_block.name, id: evt.content_block.id, arguments: {} });
} else if (evt.type === 'message_delta') {
stopReason = evt.delta?.stop_reason;
}
} catch { /* ignore */ }
}
}
process.stdout.write('\n');
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
return { fullText, toolCalls, stopReason, elapsed, chunkCount };
}
// ─── 测试登记 ───────────────────────────────────────────────────────────────
let passed = 0, failed = 0;
const results = [];
async function test(name, fn) {
process.stdout.write(` ${C.blue}${C.reset} ${name} ... `);
const t0 = Date.now();
try {
const info = await fn();
const ms = Date.now() - t0;
console.log(ok(`通过`) + dim(` (${(ms/1000).toFixed(1)}s)`));
if (info) console.log(dim(`${info}`));
passed++;
results.push({ name, ok: true });
} catch (e) {
const ms = Date.now() - t0;
console.log(err(`失败`) + dim(` (${(ms/1000).toFixed(1)}s)`));
console.log(` ${C.red}${e.message}${C.reset}`);
failed++;
results.push({ name, ok: false, error: e.message });
}
}
// ════════════════════════════════════════════════════════════════════
// 检测服务器是否在线
// ════════════════════════════════════════════════════════════════════
async function checkServer() {
try {
const r = await fetch(`${BASE_URL}/v1/models`, { headers: { 'x-api-key': 'dummy' } });
return r.ok;
} catch {
return false;
}
}
// ════════════════════════════════════════════════════════════════════
// 主测试
// ════════════════════════════════════════════════════════════════════
console.log(`\n${C.bold}${C.magenta} Cursor2API E2E 测试套件${C.reset}`);
console.log(dim(` 服务器: ${BASE_URL} | 模型: ${MODEL}`));
const online = await checkServer();
if (!online) {
console.log(`\n${C.red} ⚠ 服务器未运行,请先执行 npm run dev 或 npm start${C.reset}\n`);
process.exit(1);
}
console.log(ok(`服务器在线`));
// ─────────────────────────────────────────────────────────────────
// A. 基础问答(非流式)
// ─────────────────────────────────────────────────────────────────
console.log(hdr('A. 基础问答(非流式)'));
await test('简单中文问答', async () => {
const { data, elapsed } = await chat([
{ role: 'user', content: '用一句话解释什么是递归。' }
]);
if (!data.content?.[0]?.text) throw new Error('响应无文本内容');
if (data.stop_reason !== 'end_turn') throw new Error(`stop_reason 应为 end_turn实际: ${data.stop_reason}`);
return `"${data.content[0].text.substring(0, 60)}..." (${elapsed}s)`;
});
await test('英文问答', async () => {
const { data } = await chat([
{ role: 'user', content: 'What is the difference between async/await and Promises in JavaScript? Be concise.' }
]);
if (!data.content?.[0]?.text) throw new Error('响应无文本内容');
return data.content[0].text.substring(0, 60) + '...';
});
await test('多轮对话', async () => {
const { data } = await chat([
{ role: 'user', content: 'My name is TestBot. Remember it.' },
{ role: 'assistant', content: 'Got it! I will remember your name is TestBot.' },
{ role: 'user', content: 'What is my name?' },
]);
const text = data.content?.[0]?.text || '';
if (!text.toLowerCase().includes('testbot')) throw new Error(`响应未包含 TestBot: "${text.substring(0, 100)}"`);
return text.substring(0, 60) + '...';
});
await test('代码生成', async () => {
const { data } = await chat([
{ role: 'user', content: 'Write a JavaScript function that reverses a string. Return only the code, no explanation.' }
]);
const text = data.content?.[0]?.text || '';
if (!text.includes('function') && !text.includes('=>')) throw new Error('响应似乎不含代码');
return '包含代码块: ' + (text.includes('```') ? '是' : '否inline');
});
// ─────────────────────────────────────────────────────────────────
// B. 基础问答(流式)
// ─────────────────────────────────────────────────────────────────
console.log(hdr('B. 基础问答(流式)'));
await test('流式简单问答', async () => {
const { fullText, stopReason, elapsed, chunkCount } = await chat(
[{ role: 'user', content: '请列出5种常见的排序算法并简单说明时间复杂度。' }],
{ stream: true }
);
if (!fullText) throw new Error('流式响应文本为空');
if (stopReason !== 'end_turn') throw new Error(`stop_reason=${stopReason}`);
return `${fullText.length} 字符 / ${chunkCount} chunks (${elapsed}s)`;
});
await test('流式长输出(测试空闲超时修复)', async () => {
const { fullText, elapsed, chunkCount } = await chat(
[{ role: 'user', content: '请用中文详细介绍快速排序算法:包括原理、实现思路、时间复杂度分析、最优/最差情况、以及完整的 TypeScript 代码实现。内容要详细至少500字。' }],
{ stream: true, label: '长输出' }
);
if (!fullText || fullText.length < 200) throw new Error(`输出太短: ${fullText.length} 字符`);
return `${fullText.length} 字符 / ${chunkCount} chunks (${elapsed}s)`;
});
// ─────────────────────────────────────────────────────────────────
// C. 工具调用(非流式)
// ─────────────────────────────────────────────────────────────────
console.log(hdr('C. 工具调用(非流式)'));
const READ_TOOL = {
name: 'Read',
description: 'Read the contents of a file at the given path.',
input_schema: {
type: 'object',
properties: { file_path: { type: 'string', description: 'Absolute path of the file to read.' } },
required: ['file_path'],
},
};
const WRITE_TOOL = {
name: 'Write',
description: 'Write content to a file at the given path.',
input_schema: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Absolute path to write to.' },
content: { type: 'string', description: 'Text content to write.' },
},
required: ['file_path', 'content'],
},
};
const BASH_TOOL = {
name: 'Bash',
description: 'Execute a bash command in the terminal.',
input_schema: {
type: 'object',
properties: { command: { type: 'string', description: 'The command to execute.' } },
required: ['command'],
},
};
await test('单工具调用 — Read file', async () => {
const { data, elapsed } = await chat(
[{ role: 'user', content: 'Please read the file at /project/src/index.ts' }],
{ tools: [READ_TOOL] }
);
const toolBlocks = data.content?.filter(b => b.type === 'tool_use') || [];
if (toolBlocks.length === 0) throw new Error(`未检测到工具调用。响应: ${JSON.stringify(data.content).substring(0, 200)}`);
const tc = toolBlocks[0];
if (tc.name !== 'Read') throw new Error(`工具名应为 Read实际: ${tc.name}`);
return `工具=${tc.name} file_path=${tc.input?.file_path} (${elapsed}s)`;
});
await test('单工具调用 — Bash command', async () => {
const { data, elapsed } = await chat(
[{ role: 'user', content: 'Run "ls -la" to list the current directory.' }],
{ tools: [BASH_TOOL] }
);
const toolBlocks = data.content?.filter(b => b.type === 'tool_use') || [];
if (toolBlocks.length === 0) throw new Error(`未检测到工具调用。响应: ${JSON.stringify(data.content).substring(0, 200)}`);
const tc = toolBlocks[0];
return `工具=${tc.name} command="${tc.input?.command}" (${elapsed}s)`;
});
await test('工具调用 — stop_reason = tool_use', async () => {
const { data } = await chat(
[{ role: 'user', content: 'Read the file /src/main.ts' }],
{ tools: [READ_TOOL] }
);
if (data.stop_reason !== 'tool_use') {
throw new Error(`stop_reason 应为 tool_use实际为 ${data.stop_reason}`);
}
return `stop_reason=${data.stop_reason}`;
});
await test('工具调用后追加 tool_result 的多轮对话', async () => {
// 先触发工具调用
const { data: d1 } = await chat(
[{ role: 'user', content: 'Read the config file at /app/config.json' }],
{ tools: [READ_TOOL] }
);
const toolBlock = d1.content?.find(b => b.type === 'tool_use');
if (!toolBlock) throw new Error('第一轮未返回工具调用');
// 构造 tool_result 并继续对话
const { data: d2, elapsed } = await chat([
{ role: 'user', content: 'Read the config file at /app/config.json' },
{ role: 'assistant', content: d1.content },
{
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolBlock.id,
content: '{"port":3010,"model":"claude-sonnet-4.6","timeout":120}',
}]
}
], { tools: [READ_TOOL] });
const text = d2.content?.find(b => b.type === 'text')?.text || '';
if (!text) throw new Error('tool_result 后未返回文本');
return `tool_result 后回复: "${text.substring(0, 60)}..." (${elapsed}s)`;
});
// ─────────────────────────────────────────────────────────────────
// D. 工具调用(流式)
// ─────────────────────────────────────────────────────────────────
console.log(hdr('D. 工具调用(流式)'));
await test('流式工具调用 — Read', async () => {
const { toolCalls, stopReason, elapsed } = await chat(
[{ role: 'user', content: 'Please read /project/README.md' }],
{ tools: [READ_TOOL], stream: true, label: '工具' }
);
if (toolCalls.length === 0) throw new Error('流式模式未检测到工具调用');
if (stopReason !== 'tool_use') throw new Error(`stop_reason 应为 tool_use实际: ${stopReason}`);
return `工具=${toolCalls[0].name} (${elapsed}s)`;
});
await test('流式工具调用 — Write file测试长 content 截断修复)', async () => {
const { toolCalls, elapsed } = await chat(
[{ role: 'user', content: 'Write a new file at /tmp/hello.ts with content: a TypeScript class called HelloWorld with a greet() method that returns "Hello, World!". Include full class definition with constructor and method.' }],
{ tools: [WRITE_TOOL], stream: true, label: 'Write长内容' }
);
if (toolCalls.length === 0) throw new Error('未检测到工具调用');
const tc = toolCalls[0];
return `工具=${tc.name} file_path=${tc.arguments?.file_path} (${elapsed}s)`;
});
await test('多工具并行调用Read + Bash', async () => {
const { data } = await chat(
[{ role: 'user', content: 'I need to check the directory listing and read the package.json file. Please do both.' }],
{ tools: [READ_TOOL, BASH_TOOL] }
);
const toolBlocks = data.content?.filter(b => b.type === 'tool_use') || [];
console.log(dim(`${toolBlocks.length} 个工具调用: ${toolBlocks.map(t => t.name).join(', ')}`));
// 不强制必须是2个模型可能选择串行有至少1个就行
if (toolBlocks.length === 0) throw new Error('未检测到任何工具调用');
return `${toolBlocks.length} 个工具: ${toolBlocks.map(t => `${t.name}(${JSON.stringify(t.input).substring(0,30)})`).join(' | ')}`;
});
// ─────────────────────────────────────────────────────────────────
// E. 边界 / 防御场景
// ─────────────────────────────────────────────────────────────────
console.log(hdr('E. 边界 / 防御场景'));
await test('身份问题(不泄露 Cursor', async () => {
const { data } = await chat([
{ role: 'user', content: 'Who are you?' }
]);
const text = data.content?.[0]?.text || '';
if (text.toLowerCase().includes('cursor') && !text.toLowerCase().includes('cursor ide')) {
throw new Error(`可能泄露 Cursor 身份: "${text.substring(0, 150)}"`);
}
return `回复: "${text.substring(0, 80)}..."`;
});
await test('/v1/models 接口', async () => {
const r = await fetch(`${BASE_URL}/v1/models`, { headers: { 'x-api-key': 'dummy' } });
const data = await r.json();
if (!data.data || data.data.length === 0) throw new Error('models 列表为空');
return `模型: ${data.data.map(m => m.id).join(', ')}`;
});
await test('/v1/messages/count_tokens 接口', async () => {
const r = await fetch(`${BASE_URL}/v1/messages/count_tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'dummy' },
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'Hello world' }] }),
});
const data = await r.json();
if (typeof data.input_tokens !== 'number') throw new Error(`input_tokens 不是数字: ${JSON.stringify(data)}`);
return `input_tokens=${data.input_tokens}`;
});
// ════════════════════════════════════════════════════════════════════
// 汇总
// ════════════════════════════════════════════════════════════════════
const total = passed + failed;
console.log(`\n${'═'.repeat(60)}`);
console.log(`${C.bold} 结果: ${C.green}${passed} 通过${C.reset}${C.bold} / ${failed > 0 ? C.red : ''}${failed} 失败${C.reset}${C.bold} / ${total} 总计${C.reset}`);
console.log('═'.repeat(60) + '\n');
if (failed > 0) {
console.log(`${C.red}失败的测试:${C.reset}`);
results.filter(r => !r.ok).forEach(r => console.log(` - ${r.name}: ${r.error}`));
console.log();
process.exit(1);
}