fix: v0.6.3 修复截断工具调用被静默丢弃导致空回复终止会话 — SSE 流截断转模型自愈
【根因(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 成功
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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<Uint8Array> {
|
||||
// 尾换行必须补 — 解析器 lines.pop() 把最后一个无换行行留在缓冲不处理
|
||||
const payload = lines.join('\n') + '\n';
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sseData(json: Record<string, unknown>): 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<Uint8Array>) {
|
||||
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": "<!DO (未闭合字符串)
|
||||
const truncatedArgs = '{"file_path": "a.html", "content": "<!DO';
|
||||
const events = await collect(
|
||||
makeStream([toolCallDelta(0, 'write_file', truncatedArgs), 'data: [DONE]']),
|
||||
);
|
||||
|
||||
const toolCallComplete = events.find(
|
||||
(e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
);
|
||||
expect(toolCallComplete).toBeDefined();
|
||||
expect(toolCallComplete!.toolCall!.name).toBe('write_file');
|
||||
const args = toolCallComplete!.toolCall!.args as Record<string, unknown>;
|
||||
// 关键契约:错误说明回传模型(触发重试/分块),而非静默丢弃
|
||||
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": "<h1>ok</h1>"}'),
|
||||
'data: [DONE]',
|
||||
]),
|
||||
);
|
||||
const toolCallComplete = events.find(
|
||||
(e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
);
|
||||
expect(toolCallComplete!.toolCall!.args).toEqual({
|
||||
file_path: 'a.html',
|
||||
content: '<h1>ok</h1>',
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user