Files
metona-ai-desktop/electron/harness/adapters/__tests__/sse-truncation.test.ts
T
thzxx b6e2a8bd25
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m41s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 10m7s
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 成功
2026-08-22 20:44:39 +08:00

148 lines
5.4 KiB
TypeScript
Raw 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.
/**
* 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);
});
});