From a55b2af7a5d332f754f7cab129a22a238856be47 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 2 May 2026 19:16:06 +0530 Subject: [PATCH] fix: keep Gemini thinking streams active (#76080) (thanks @zhangguiping-xydt) --- CHANGELOG.md | 1 + extensions/google/transport-stream.test.ts | 18 +++++++- extensions/google/transport-stream.ts | 51 ++++------------------ 3 files changed, 26 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f10972267..8cf1383a2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -277,6 +277,7 @@ Docs: https://docs.openclaw.ai - Providers/Google: fix Gemini 2.5 Flash-Lite `reasoning: "minimal"` rejections by raising its thinking-budget floor to 512 while preserving the existing Gemini 2.5 Pro and Flash minimal presets. (#70629) Thanks @ericberic. - Agents/status: resolve `session_status(sessionKey="current")` for sparse channel-plugin sessions after literal current lookups miss, so Scope, Slack, Discord, and other plugin-driven agents avoid retrying through `Unknown sessionKey: current`. Fixes #74141. (#72306) Thanks @bittoby. - Cron: retry recurring wake-now main-session jobs through temporary heartbeat busy skips before recording success, so queued cron events no longer appear as ok ghost runs while the main lane is still busy. Fixes #75964. (#76083) Thanks @kshetrajna12 and @xuruiray. +- Providers/Google: keep Gemini thinking-signature-only stream chunks active during reasoning, so Gemini 3.1 Pro Preview replies no longer hit idle timeouts before visible text. Fixes #76071. (#76080) Thanks @marcoschierhorn and @zhangguiping-xydt. ## 2026.4.30 diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index d6333e090b6..e8f1d3199bd 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -768,7 +768,7 @@ describe("google transport stream", () => { }); }); - it("emits a thinking_signature event for thoughtSignature-only parts to keep the stream active", async () => { + it("emits thinking activity for thoughtSignature-only parts to keep the stream active", async () => { guardedFetchMock.mockResolvedValueOnce( buildSseResponse([ { @@ -810,12 +810,28 @@ describe("google transport stream", () => { { reasoning: "high" }, ), ); + const events = []; + for await (const event of stream) { + events.push(event); + } const result = await stream.result(); expect(result.content).toEqual([ { type: "thinking", thinking: "draft", thinkingSignature: "sig_2" }, { type: "text", text: "answer" }, ]); + expect(events.map((event) => event.type)).toEqual([ + "start", + "thinking_start", + "thinking_delta", + "thinking_delta", + "thinking_end", + "text_start", + "text_delta", + "text_end", + "done", + ]); + expect(events[3]).toMatchObject({ type: "thinking_delta", delta: "" }); }); it("starts a thinking block for thoughtSignature-only parts that arrive before any text", async () => { diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index 3fa7b325853..bd2874ec97d 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -797,8 +797,11 @@ function createGoogleTransportStreamFn(kind: GoogleTransportApi): StreamFn { const candidate = chunk.candidates?.[0]; if (candidate?.content?.parts) { for (const part of candidate.content.parts) { - if (typeof part.text === "string") { - const isThinking = part.thought === true; + const hasThoughtSignature = + typeof part.thoughtSignature === "string" && part.thoughtSignature.length > 0; + const hasText = typeof part.text === "string"; + if (hasText || (hasThoughtSignature && !part.functionCall)) { + const isThinking = part.thought === true || !hasText; const currentBlock = output.content[currentBlockIndex]; if ( currentBlockIndex < 0 || @@ -829,7 +832,8 @@ function createGoogleTransportStreamFn(kind: GoogleTransportApi): StreamFn { } const activeBlock = output.content[currentBlockIndex]; if (activeBlock?.type === "thinking") { - activeBlock.thinking += part.text; + const delta = hasText ? part.text : ""; + activeBlock.thinking += delta; activeBlock.thinkingSignature = retainThoughtSignature( activeBlock.thinkingSignature, part.thoughtSignature, @@ -837,7 +841,7 @@ function createGoogleTransportStreamFn(kind: GoogleTransportApi): StreamFn { stream.push({ type: "thinking_delta", contentIndex: currentBlockIndex, - delta: part.text, + delta, partial: output as never, }); } else if (activeBlock?.type === "text") { @@ -894,45 +898,6 @@ function createGoogleTransportStreamFn(kind: GoogleTransportApi): StreamFn { partial: output as never, }); } - // Gemini 3+ models can emit thoughtSignature-only parts during the - // thinking phase before user-visible text arrives. Emit a stream event - // so that idle-timeout wrappers detect model activity and don't kill - // the stream prematurely. - if ( - typeof part.thoughtSignature === "string" && - part.thoughtSignature.length > 0 && - typeof part.text !== "string" && - !part.functionCall - ) { - if ( - currentBlockIndex < 0 || - output.content[currentBlockIndex]?.type !== "thinking" - ) { - if (currentBlockIndex >= 0) { - pushTextBlockEnd(stream, output, currentBlockIndex); - } - output.content.push({ type: "thinking", thinking: "" }); - currentBlockIndex = output.content.length - 1; - stream.push({ - type: "thinking_start", - contentIndex: currentBlockIndex, - partial: output as never, - }); - } - const activeBlock = output.content[currentBlockIndex]; - if (activeBlock?.type === "thinking") { - activeBlock.thinkingSignature = retainThoughtSignature( - activeBlock.thinkingSignature, - part.thoughtSignature, - ); - } - stream.push({ - type: "thinking_signature", - contentIndex: currentBlockIndex, - signature: part.thoughtSignature, - partial: output as never, - }); - } } } if (typeof candidate?.finishReason === "string") {