fix: keep Gemini thinking streams active (#76080) (thanks @zhangguiping-xydt)

This commit is contained in:
Ayaan Zaidi
2026-05-02 19:16:06 +05:30
parent ea3416d8b5
commit a55b2af7a5
3 changed files with 26 additions and 44 deletions

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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") {