fix: v0.6.3 修复截断工具调用被静默丢弃导致空回复终止会话 — SSE 流截断转模型自愈
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m41s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 10m7s

【根因(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:
2026-08-22 20:44:39 +08:00
parent a7090214b1
commit b6e2a8bd25
5 changed files with 283 additions and 35 deletions
+2 -2
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.6.2-blue?style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/version-0.6.3-blue?style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
@@ -871,7 +871,7 @@ npm run format # Prettier 格式化
# ─── 测试 ─────────────────────────────────
npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过)
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 259 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 264 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
npm run test:watch # 测试监听模式
# ─── 构建 ─────────────────────────────────
@@ -99,7 +99,7 @@ describe('parseSSEStream — 工具调用增量拼接', () => {
expect(names).toEqual(['tool_a', 'tool_b']);
});
it('损坏的 args JSON 跳过该工具调用且不中断流', async () => {
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',
@@ -107,10 +107,14 @@ describe('parseSSEStream — 工具调用增量拼接', () => {
'data: [DONE]\n\n',
]),
);
// 坏 JSON 的工具调用被跳过(不产出 TOOL_CALL_COMPLETE
expect(events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE)).toHaveLength(
0,
);
// 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);
@@ -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);
});
});
+124 -27
View File
@@ -17,10 +17,18 @@ import { MetonaStreamEventType } from '../../types';
* L-4 修复: 提取 flushToolCallBuffer 辅助函数,消除 [DONE] 分支和 finish_reason='tool_calls' 分支的重复代码
*
* 遍历工具调用缓冲区,对每个缓冲的工具调用:
* 1. JSON.parse argsBuffer(失败则跳过)
* 1. JSON.parse argsBuffer
* 2. yield 一个 TOOL_CALL_COMPLETE 事件
* 3. 清空缓冲区
*
* v0.6.3 会话停止修复: argsBuffer 解析失败(流截断致 JSON 半截 — 典型场景:
* 模型写大文件时输出 token 达上限 finish_reason=length)时,不再静默丢弃该
* 工具调用。丢弃会让引擎看到"零工具调用 + 零文本"→ 误判为模型已完成 →
* COMPLETED + 空回复 → 会话无声停止(main.log 20:31/20:32 两次实锤)。
* 现转为 yield 一个携带截断错误说明的 tool call:工具执行将因参数缺失失败,
* 错误结果回传模型 → 模型感知截断后重试/分块写入(ReAct 自愈路径)。
* 无限循环由引擎死循环检测器兜底。
*
* @param toolCallsBuffer - 工具调用缓冲区(index → { name, argsBuffer }
* @param requestId - 请求 ID
* @param sessionId - 会话 ID
@@ -36,7 +44,56 @@ function* flushToolCallBuffer(
seqRef: { seq: number },
): Generator<MetonaStreamEvent> {
for (const [, buf] of toolCallsBuffer) {
try {
if (buf.argsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: JSON.parse(buf.argsBuffer),
iteration,
timestamp: Date.now(),
},
};
} catch (err) {
// v0.6.3: 截断的工具调用转显式错误参数(不丢弃)— 工具执行失败后
// 错误结果回传模型,触发重试/分块写入,替代"静默丢弃→空回复终止会话"
const rawTail = buf.argsBuffer.slice(-120);
log.warn(
`[SSE] Tool call args truncated (unparseable JSON, ${(err as Error).message}). ` +
`Forwarding as error to model for self-healing. Tail: ...${rawTail}`,
);
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: {
_truncatedArguments: true,
_truncatedReason:
'The streamed arguments JSON was truncated before completion ' +
'(likely max_tokens output limit reached while generating this tool call). ' +
'The original arguments are lost. Please retry with smaller output ' +
'(e.g. write the file in smaller chunks) — do NOT reuse the previous oversized arguments.',
},
iteration,
timestamp: Date.now(),
},
};
}
} else {
// 空 argsBuffer:模型发了空 arguments(合法 — 无参工具)
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
@@ -47,19 +104,11 @@ function* flushToolCallBuffer(
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
args: {},
iteration,
timestamp: Date.now(),
},
};
} catch (err) {
// #25 修复: 不再静默丢弃 JSON 解析失败的工具调用
// 审查修复: 不再 yield ERROR 事件,因为 Engine 收到 ERROR 会 throw 中断整个请求,
// 导致一个好的工具调用 JSON 解析失败就丢弃所有工具调用。
// 改为 log.warn 记录后 continue 跳过这条坏的工具调用,继续处理 buffer 中剩余的。
const rawPreview = buf.argsBuffer?.slice(0, 200) ?? '';
log.warn(`[SSE] Tool call JSON parse failed: ${(err as Error).message}`, rawPreview);
continue;
}
}
toolCallsBuffer.clear();
@@ -84,6 +133,8 @@ export async function* parseSSEStream(
const decoder = new TextDecoder();
const seqRef = { seq: 0 };
let buffer = '';
// v0.6.3: 是否收到过 [DONE](流断开兜底用)
let sawDone = false;
// 工具调用缓冲区:index → { name, argsBuffer }
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
@@ -103,6 +154,7 @@ export async function* parseSSEStream(
// 流结束
if (data === '[DONE]') {
sawDone = true;
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
@@ -183,8 +235,9 @@ export async function* parseSSEStream(
reasoningTokens: chunk.usage.completion_tokens_details?.reasoning_tokens,
// DeepSeek: prompt_cache_hit_tokens / prompt_cache_miss_tokens
// MiMo: prompt_tokens_details.cached_tokens
cacheHitTokens: chunk.usage.prompt_cache_hit_tokens
?? chunk.usage.prompt_tokens_details?.cached_tokens,
cacheHitTokens:
chunk.usage.prompt_cache_hit_tokens ??
chunk.usage.prompt_tokens_details?.cached_tokens,
cacheMissTokens: chunk.usage.prompt_cache_miss_tokens,
};
@@ -205,23 +258,57 @@ export async function* parseSSEStream(
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
}
// v0.6.3 归因: 输出 token 上限截断(长工具参数/长文本的常见根因)显式落日志
if (finishReason === 'length') {
log.warn(
`[SSE] finish_reason=length — output truncated by max_tokens limit ` +
`(accumulated argsBuffer: ${[...toolCallsBuffer.values()].reduce((n, b) => n + b.argsBuffer.length, 0)} chars, ` +
`model may retry with smaller output)`,
);
}
} catch (parseErr) {
// P2-8 修复: 不再静默跳过,记录 warning 便于排查 SSE 数据损坏
log.warn(`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`, line.slice(0, 200));
log.warn(
`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`,
line.slice(0, 200),
);
}
}
}
// v0.6.3 流断开兜底: read() done 但从未收到 [DONE](连接中断/服务端异常收尾)。
// 原实现直接结束 generator —— 工具缓冲不 flush、DONE 事件缺失(引擎侧等待
// 流收尾的路径行为未定义,且缓冲的工具调用整体丢失)。补 flush + DONE
// 截断的参数由 flushToolCallBuffer 转为错误结果回传模型自愈。
if (!sawDone) {
log.warn(
'[SSE] Stream ended without [DONE] marker — flushing buffers (connection likely dropped)',
);
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
yield {
type: MetonaStreamEventType.DONE,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
};
}
}
/**
* 解析 OpenAI 兼容的非流式 JSON 响应 → MetonaResponse
*/
export function parseOpenAICompatibleResponse(
data: Record<string, unknown>,
): {
export function parseOpenAICompatibleResponse(data: Record<string, unknown>): {
content: string;
reasoningContent?: string;
toolCalls?: Array<{ id: string; name: string; args: Record<string, unknown>; iteration: number; timestamp: number }>;
toolCalls?: Array<{
id: string;
name: string;
args: Record<string, unknown>;
iteration: number;
timestamp: number;
}>;
finishReason: string;
usage: MetonaTokenUsage;
} {
@@ -259,10 +346,14 @@ export function parseOpenAICompatibleResponse(
inputTokens: (usage?.prompt_tokens as number) ?? 0,
outputTokens: (usage?.completion_tokens as number) ?? 0,
totalTokens: (usage?.total_tokens as number) ?? 0,
reasoningTokens: (usage?.completion_tokens_details as Record<string, unknown>)?.reasoning_tokens as number | undefined,
reasoningTokens: (usage?.completion_tokens_details as Record<string, unknown>)
?.reasoning_tokens as number | undefined,
// DeepSeek: prompt_cache_hit_tokens / MiMo: prompt_tokens_details.cached_tokens
cacheHitTokens: (usage?.prompt_cache_hit_tokens as number | undefined)
?? (usage?.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens as number | undefined,
cacheHitTokens:
(usage?.prompt_cache_hit_tokens as number | undefined) ??
((usage?.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens as
| number
| undefined),
cacheMissTokens: usage?.prompt_cache_miss_tokens as number | undefined,
},
};
@@ -270,12 +361,18 @@ export function parseOpenAICompatibleResponse(
function mapOpenAIFinishReason(reason: string): string {
switch (reason) {
case 'stop': return 'stop';
case 'length': return 'length';
case 'tool_calls': return 'tool_calls';
case 'content_filter': return 'content_filter';
case 'stop':
return 'stop';
case 'length':
return 'length';
case 'tool_calls':
return 'tool_calls';
case 'content_filter':
return 'content_filter';
// MiMo 特有:检测到复读截断
case 'repetition_truncation': return 'stop';
default: return 'stop';
case 'repetition_truncation':
return 'stop';
default:
return 'stop';
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.6.2",
"version": "0.6.3",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",