Skip to content

Commit a55b2af

Browse files
committed
fix: keep Gemini thinking streams active (#76080) (thanks @zhangguiping-xydt)
1 parent ea3416d commit a55b2af

3 files changed

Lines changed: 26 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ Docs: https://docs.openclaw.ai
277277
- 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.
278278
- 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.
279279
- 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.
280+
- 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.
280281

281282
## 2026.4.30
282283

extensions/google/transport-stream.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ describe("google transport stream", () => {
768768
});
769769
});
770770

771-
it("emits a thinking_signature event for thoughtSignature-only parts to keep the stream active", async () => {
771+
it("emits thinking activity for thoughtSignature-only parts to keep the stream active", async () => {
772772
guardedFetchMock.mockResolvedValueOnce(
773773
buildSseResponse([
774774
{
@@ -810,12 +810,28 @@ describe("google transport stream", () => {
810810
{ reasoning: "high" },
811811
),
812812
);
813+
const events = [];
814+
for await (const event of stream) {
815+
events.push(event);
816+
}
813817
const result = await stream.result();
814818

815819
expect(result.content).toEqual([
816820
{ type: "thinking", thinking: "draft", thinkingSignature: "sig_2" },
817821
{ type: "text", text: "answer" },
818822
]);
823+
expect(events.map((event) => event.type)).toEqual([
824+
"start",
825+
"thinking_start",
826+
"thinking_delta",
827+
"thinking_delta",
828+
"thinking_end",
829+
"text_start",
830+
"text_delta",
831+
"text_end",
832+
"done",
833+
]);
834+
expect(events[3]).toMatchObject({ type: "thinking_delta", delta: "" });
819835
});
820836

821837
it("starts a thinking block for thoughtSignature-only parts that arrive before any text", async () => {

extensions/google/transport-stream.ts

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -797,8 +797,11 @@ function createGoogleTransportStreamFn(kind: GoogleTransportApi): StreamFn {
797797
const candidate = chunk.candidates?.[0];
798798
if (candidate?.content?.parts) {
799799
for (const part of candidate.content.parts) {
800-
if (typeof part.text === "string") {
801-
const isThinking = part.thought === true;
800+
const hasThoughtSignature =
801+
typeof part.thoughtSignature === "string" && part.thoughtSignature.length > 0;
802+
const hasText = typeof part.text === "string";
803+
if (hasText || (hasThoughtSignature && !part.functionCall)) {
804+
const isThinking = part.thought === true || !hasText;
802805
const currentBlock = output.content[currentBlockIndex];
803806
if (
804807
currentBlockIndex < 0 ||
@@ -829,15 +832,16 @@ function createGoogleTransportStreamFn(kind: GoogleTransportApi): StreamFn {
829832
}
830833
const activeBlock = output.content[currentBlockIndex];
831834
if (activeBlock?.type === "thinking") {
832-
activeBlock.thinking += part.text;
835+
const delta = hasText ? part.text : "";
836+
activeBlock.thinking += delta;
833837
activeBlock.thinkingSignature = retainThoughtSignature(
834838
activeBlock.thinkingSignature,
835839
part.thoughtSignature,
836840
);
837841
stream.push({
838842
type: "thinking_delta",
839843
contentIndex: currentBlockIndex,
840-
delta: part.text,
844+
delta,
841845
partial: output as never,
842846
});
843847
} else if (activeBlock?.type === "text") {
@@ -894,45 +898,6 @@ function createGoogleTransportStreamFn(kind: GoogleTransportApi): StreamFn {
894898
partial: output as never,
895899
});
896900
}
897-
// Gemini 3+ models can emit thoughtSignature-only parts during the
898-
// thinking phase before user-visible text arrives. Emit a stream event
899-
// so that idle-timeout wrappers detect model activity and don't kill
900-
// the stream prematurely.
901-
if (
902-
typeof part.thoughtSignature === "string" &&
903-
part.thoughtSignature.length > 0 &&
904-
typeof part.text !== "string" &&
905-
!part.functionCall
906-
) {
907-
if (
908-
currentBlockIndex < 0 ||
909-
output.content[currentBlockIndex]?.type !== "thinking"
910-
) {
911-
if (currentBlockIndex >= 0) {
912-
pushTextBlockEnd(stream, output, currentBlockIndex);
913-
}
914-
output.content.push({ type: "thinking", thinking: "" });
915-
currentBlockIndex = output.content.length - 1;
916-
stream.push({
917-
type: "thinking_start",
918-
contentIndex: currentBlockIndex,
919-
partial: output as never,
920-
});
921-
}
922-
const activeBlock = output.content[currentBlockIndex];
923-
if (activeBlock?.type === "thinking") {
924-
activeBlock.thinkingSignature = retainThoughtSignature(
925-
activeBlock.thinkingSignature,
926-
part.thoughtSignature,
927-
);
928-
}
929-
stream.push({
930-
type: "thinking_signature",
931-
contentIndex: currentBlockIndex,
932-
signature: part.thoughtSignature,
933-
partial: output as never,
934-
});
935-
}
936901
}
937902
}
938903
if (typeof candidate?.finishReason === "string") {

0 commit comments

Comments
 (0)