/** * SSE 流截断处理测试(v0.6.3 会话停止修复) * * 背景(main.log 2026-08-22 20:31/20:32 实锤):模型写大文件(22KB HTML)时 * 输出 token 达上限(finish_reason=length),流正常收尾但 tool_call arguments * JSON 半截。原实现静默丢弃解析失败的工具调用 → 引擎看到"零工具调用+零文本" * → 误判 COMPLETED + 空回复 → 会话无声停止。 * * 契约: * 1. 截断 args → TOOL_CALL_COMPLETE 携带 _truncatedArguments 错误说明(转模型自愈) * 2. 流断开(无 [DONE])→ 仍产出 flush + DONE(引擎不 hang、缓冲不丢) * 3. 完整 JSON / 空 args → 行为不变 */ import { describe, it, expect, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { parseSSEStream } from '../shared/sse-stream'; import { MetonaStreamEventType } from '../../types'; const encoder = new TextEncoder(); function makeStream(lines: string[]): ReadableStream { // 尾换行必须补 — 解析器 lines.pop() 把最后一个无换行行留在缓冲不处理 const payload = lines.join('\n') + '\n'; return new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(payload)); controller.close(); }, }); } function sseData(json: Record): string { return `data: ${JSON.stringify(json)}`; } function toolCallDelta(index: number, name: string, argsDelta: string): string { return sseData({ choices: [ { delta: { tool_calls: [{ index, function: { name, arguments: argsDelta } }], }, }, ], }); } async function collect(stream: ReadableStream) { const events = []; for await (const ev of parseSSEStream(stream, 'r_test', 's_test', 1)) { events.push(ev); } return events; } describe('parseSSEStream — 截断 tool call 处理(v0.6.3)', () => { it('截断的 args JSON → TOOL_CALL_COMPLETE 携带 _truncatedArguments 错误说明(不丢弃)', async () => { // 半截 JSON:{"file_path": "a.html", "content": " e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE, ); expect(toolCallComplete).toBeDefined(); expect(toolCallComplete!.toolCall!.name).toBe('write_file'); const args = toolCallComplete!.toolCall!.args as Record; // 关键契约:错误说明回传模型(触发重试/分块),而非静默丢弃 expect(args._truncatedArguments).toBe(true); expect(String(args._truncatedReason)).toContain('truncated'); // 流正常收尾(引擎不会误判 COMPLETED — 有工具调用待执行) expect(events.some((e) => e.type === MetonaStreamEventType.DONE)).toBe(true); }); it('流断开(无 [DONE])→ 补 flush + DONE(缓冲不丢失、引擎不 hang)', async () => { const events = await collect( makeStream([ toolCallDelta(0, 'read_file', '{"file_path": "a.ts"}'), // 无 [DONE],流直接结束 ]), ); // 半路 flush 未发生(无 finish_reason),靠断开兜底产出 const toolCallComplete = events.find( (e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE, ); expect(toolCallComplete).toBeDefined(); expect(toolCallComplete!.toolCall!.args).toEqual({ file_path: 'a.ts' }); // 兜底 DONE(引擎收尾路径依赖) expect(events.some((e) => e.type === MetonaStreamEventType.DONE)).toBe(true); }); it('完整 JSON args 正常解析(回归)', async () => { const events = await collect( makeStream([ toolCallDelta(0, 'write_file', '{"file_path": "a.html"'), toolCallDelta(0, 'write_file', ', "content": "

ok

"}'), 'data: [DONE]', ]), ); const toolCallComplete = events.find( (e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE, ); expect(toolCallComplete!.toolCall!.args).toEqual({ file_path: 'a.html', content: '

ok

', }); }); it('空 arguments 的工具调用产出 args={}(回归)', async () => { const events = await collect( makeStream([ // 模型只发 name 不发 arguments sseData({ choices: [{ delta: { tool_calls: [{ index: 0, function: { name: 'think' } }] } }], }), 'data: [DONE]', ]), ); const toolCallComplete = events.find( (e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE, ); expect(toolCallComplete).toBeDefined(); expect(toolCallComplete!.toolCall!.name).toBe('think'); expect(toolCallComplete!.toolCall!.args).toEqual({}); }); it('finish_reason=tool_calls 提前 flush(回归)', async () => { const events = await collect( makeStream([ toolCallDelta(0, 'read_file', '{"file_path": "x.ts"}'), sseData({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }), 'data: [DONE]', ]), ); const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE); // finish_reason flush 一次 + [DONE] flush(缓冲已 clear,不重复) expect(completes).toHaveLength(1); }); });