【根因(main.log 实证)】 2026-08-22 20:31:22 / 20:32:31 两次 + 20:37:01 最终终止,完整因果链: 模型写大文件(22KB HTML,write_file)时输出 token 达上限 (finish_reason=length),流正常收尾但 tool_call 的 arguments JSON 半截 ("Unterminated string in JSON at position 21890/22686")。 缺陷链: 1. SSE 解析器对 parse 失败的 tool call 静默丢弃(log.warn 后 continue — #25 时代为防单个坏 JSON 丢弃全部而引入) 2. 该轮模型输出全部是这一个 tool call → 丢弃后引擎看到"零工具调用 + 零文本"→ 误判为模型已完成 → COMPLETED + 空回复(OutputValidator 报 "Output is empty" 仅 warn 不阻断) 3. 用户感知:AI 干了 16 轮 10.5 分钟后会话无声停止、没有最终回复 20:31/20:32 两次截断后模型自行重试(日志可见继续 EXECUTING), 但 20:37 最后一轮再次截断且无重试机会 → 空回复终止。 【修复(sse-stream.ts — DeepSeek/Agnes/MiMo 三家共享)】 - flushToolCallBuffer: parse 失败的 tool call 不再丢弃 — 转为携带 _truncatedArguments + _truncatedReason(明确告知模型"参数因输出长度 限制被截断,请分块重试、勿复用原参数")的 TOOL_CALL_COMPLETE。 工具执行将因参数缺失失败,错误结果回传模型 → 模型感知截断后分块 写入(ReAct 自愈路径)。无限循环由引擎死循环检测器兜底 - 流断开兜底: read() done 但从未收到 [DONE](连接中断)时补 flush + DONE — 原实现缓冲整体丢失且引擎收尾路径行为未定义 - finish_reason=length 显式 warn 日志(含缓冲字节数)— 归因能力 - 空 argsBuffer 的 tool call(无参工具)显式产出 args={}(原实现走 JSON.parse('') 会进 catch,行为巧合正确但语义混乱) 【测试】 +5 用例(sse-truncation.test.ts): 截断转错误说明 / 无 [DONE] 兜底 / 完整 JSON 回归 / 空 args 回归 / finish_reason=tool_calls 提前 flush 更新 1 旧用例: "损坏 JSON 跳过" → "损坏 JSON 转截断错误 tool call" (行为变更的契约级断言) 【验证】 lint 0/0;typecheck 双工程 0 错误;test:electron 264/264(+5); electron-vite build 成功
243 lines
9.3 KiB
TypeScript
243 lines
9.3 KiB
TypeScript
/**
|
||
* SSE 流式解析器单元测试(v0.4.1 测试补齐)
|
||
* 覆盖:TEXT_DELTA / REASONING_DELTA / TOOL_CALL 增量拼接 / USAGE /
|
||
* [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
import { parseSSEStream, parseOpenAICompatibleResponse } from '../shared/sse-stream';
|
||
import { MetonaStreamEventType } from '../../types';
|
||
|
||
/** 构造 SSE 测试流 */
|
||
function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||
const encoder = new TextEncoder();
|
||
return new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
for (const c of chunks) controller.enqueue(encoder.encode(c));
|
||
controller.close();
|
||
},
|
||
});
|
||
}
|
||
|
||
async function collect(
|
||
stream: ReadableStream<Uint8Array>,
|
||
): Promise<Array<{ type: string; [k: string]: unknown }>> {
|
||
const events: Array<{ type: string; [k: string]: unknown }> = [];
|
||
for await (const ev of parseSSEStream(stream, 'req_test', 'sess_test', 1)) {
|
||
events.push(ev as unknown as { type: string; [k: string]: unknown });
|
||
}
|
||
return events;
|
||
}
|
||
|
||
describe('parseSSEStream — 文本与推理增量', () => {
|
||
it('TEXT_DELTA 事件按序产出', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"content":",世界"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||
expect(deltas).toHaveLength(2);
|
||
expect(deltas[0].delta).toBe('你好');
|
||
expect(deltas[1].delta).toBe(',世界');
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('REASONING_DELTA(Thinking 模式)事件产出', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"reasoning_content":"让我想想"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
|
||
expect(reasoning?.delta).toBe('让我想想');
|
||
});
|
||
|
||
it('同一个 chunk 中 content 和 reasoning_content 同时产出', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"content":"答","reasoning_content":"思考"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
|
||
expect(events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA)).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('parseSSEStream — 工具调用增量拼接', () => {
|
||
it('分段 arguments 拼接为完整 JSON 并在 finish_reason=tool_calls 时 flush', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\\"pa"}}]}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\\": \\"a.ts\\"}"}}]}}]}\n\n',
|
||
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(completes).toHaveLength(1);
|
||
const tc = completes[0].toolCall as { name: string; args: Record<string, unknown> };
|
||
expect(tc.name).toBe('read_file');
|
||
expect(tc.args).toEqual({ path: 'a.ts' });
|
||
});
|
||
|
||
it('多个工具调用按 index 分别拼接', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"tool_a","arguments":"{}"}}]}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"tool_b","arguments":"{}"}}]}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(completes).toHaveLength(2);
|
||
const names = completes.map((e) => (e.toolCall as { name: string }).name);
|
||
expect(names).toEqual(['tool_a', 'tool_b']);
|
||
});
|
||
|
||
it('损坏的 args JSON 转为截断错误 tool call 回传模型(不丢弃、不中断流)', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"bad_tool","arguments":"{invalid json"}}]}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"content":"后续文本"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
// v0.6.3 契约: 坏 JSON 的工具调用不再静默丢弃(丢弃会让引擎误判 COMPLETED
|
||
// 空回复终止会话)— 转为携带 _truncatedArguments 错误说明的 tool call,
|
||
// 工具执行失败后错误结果回传模型触发自愈
|
||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(completes).toHaveLength(1);
|
||
const tc = completes[0].toolCall as { name: string; args: Record<string, unknown> };
|
||
expect(tc.name).toBe('bad_tool');
|
||
expect(tc.args._truncatedArguments).toBe(true);
|
||
// 流继续处理后续事件
|
||
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
});
|
||
|
||
describe('parseSSEStream — USAGE 与容错', () => {
|
||
it('USAGE 事件解析 DeepSeek 缓存字段', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
|
||
const usage = usageEvent?.usage as Record<string, number>;
|
||
expect(usage.inputTokens).toBe(100);
|
||
expect(usage.outputTokens).toBe(50);
|
||
expect(usage.totalTokens).toBe(150);
|
||
expect(usage.cacheHitTokens).toBe(80);
|
||
expect(usage.cacheMissTokens).toBe(20);
|
||
});
|
||
|
||
it('坏 JSON 行不中断后续事件(记录警告后继续)', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {broken json\n\n',
|
||
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('跨 chunk 分割的 SSE 行正确拼接', async () => {
|
||
// "data: {...}\n\n" 被切到两个网络 chunk 中
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"cont',
|
||
'ent":"拼接成功"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||
expect(delta?.delta).toBe('拼接成功');
|
||
});
|
||
|
||
it('空行与非 data 行被忽略', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
': comment line\n\n',
|
||
'\n',
|
||
'data: {"choices":[{"delta":{"content":"x"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('parseOpenAICompatibleResponse — 非流式响应', () => {
|
||
it('解析普通文本响应', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }],
|
||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||
});
|
||
expect(result.content).toBe('hello');
|
||
expect(result.finishReason).toBe('stop');
|
||
expect(result.usage.inputTokens).toBe(10);
|
||
expect(result.toolCalls).toBeUndefined();
|
||
});
|
||
|
||
it('解析 reasoning_content(Thinking 模式)', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [
|
||
{ message: { content: 'answer', reasoning_content: 'thinking...' }, finish_reason: 'stop' },
|
||
],
|
||
usage: {},
|
||
});
|
||
expect(result.reasoningContent).toBe('thinking...');
|
||
});
|
||
|
||
it('解析 tool_calls(字符串 arguments 反序列化)', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [
|
||
{
|
||
message: {
|
||
content: null,
|
||
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{"cmd":"ls"}' } }],
|
||
},
|
||
finish_reason: 'tool_calls',
|
||
},
|
||
],
|
||
usage: {},
|
||
});
|
||
expect(result.toolCalls).toHaveLength(1);
|
||
expect(result.toolCalls![0].name).toBe('run');
|
||
expect(result.toolCalls![0].args).toEqual({ cmd: 'ls' });
|
||
});
|
||
|
||
it('损坏的 tool_calls arguments 降级为空对象', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [
|
||
{
|
||
message: {
|
||
content: null,
|
||
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{bad' } }],
|
||
},
|
||
finish_reason: 'tool_calls',
|
||
},
|
||
],
|
||
usage: {},
|
||
});
|
||
expect(result.toolCalls![0].args).toEqual({});
|
||
});
|
||
|
||
it('mapOpenAIFinishReason 覆盖 MiMo repetition_truncation', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [{ message: { content: 'x' }, finish_reason: 'repetition_truncation' }],
|
||
usage: {},
|
||
});
|
||
expect(result.finishReason).toBe('stop');
|
||
});
|
||
});
|