From b6e2a8bd25c63dc68ae002cfea4eaecfc8ca780f Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Sat, 22 Aug 2026 20:44:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.6.3=20=E4=BF=AE=E5=A4=8D=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E8=A2=AB=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E4=B8=A2=E5=BC=83=E5=AF=BC=E8=87=B4=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E7=BB=88=E6=AD=A2=E4=BC=9A=E8=AF=9D=20=E2=80=94=20SSE?= =?UTF-8?q?=20=E6=B5=81=E6=88=AA=E6=96=AD=E8=BD=AC=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=87=AA=E6=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【根因(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 成功 --- README.md | 4 +- .../adapters/__tests__/sse-stream.test.ts | 14 +- .../adapters/__tests__/sse-truncation.test.ts | 147 +++++++++++++++++ .../harness/adapters/shared/sse-stream.ts | 151 ++++++++++++++---- package.json | 2 +- 5 files changed, 283 insertions(+), 35 deletions(-) create mode 100644 electron/harness/adapters/__tests__/sse-truncation.test.ts diff --git a/README.md b/README.md index cf9313b..3522e95 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron 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 # 测试监听模式 # ─── 构建 ───────────────────────────────── diff --git a/electron/harness/adapters/__tests__/sse-stream.test.ts b/electron/harness/adapters/__tests__/sse-stream.test.ts index f979099..39f8368 100644 --- a/electron/harness/adapters/__tests__/sse-stream.test.ts +++ b/electron/harness/adapters/__tests__/sse-stream.test.ts @@ -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 }; + 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); diff --git a/electron/harness/adapters/__tests__/sse-truncation.test.ts b/electron/harness/adapters/__tests__/sse-truncation.test.ts new file mode 100644 index 0000000..bbe6cff --- /dev/null +++ b/electron/harness/adapters/__tests__/sse-truncation.test.ts @@ -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 { + // 尾换行必须补 — 解析器 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); + }); +}); diff --git a/electron/harness/adapters/shared/sse-stream.ts b/electron/harness/adapters/shared/sse-stream.ts index 49c69b9..34272b4 100644 --- a/electron/harness/adapters/shared/sse-stream.ts +++ b/electron/harness/adapters/shared/sse-stream.ts @@ -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 { 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(); @@ -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, -): { +export function parseOpenAICompatibleResponse(data: Record): { content: string; reasoningContent?: string; - toolCalls?: Array<{ id: string; name: string; args: Record; iteration: number; timestamp: number }>; + toolCalls?: Array<{ + id: string; + name: string; + args: Record; + 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)?.reasoning_tokens as number | undefined, + reasoningTokens: (usage?.completion_tokens_details as Record) + ?.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 | undefined)?.cached_tokens as number | undefined, + cacheHitTokens: + (usage?.prompt_cache_hit_tokens as number | undefined) ?? + ((usage?.prompt_tokens_details as Record | 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'; } } diff --git a/package.json b/package.json index 078f7ca..e4e2b62 100644 --- a/package.json +++ b/package.json @@ -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",