From 974619d285964f1a3412f7aadd0e44eb0809af2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=C3=A1=C5=A1=20Jan=C4=8Da=C5=99=C3=ADk?= Date: Wed, 7 Jan 2026 18:30:35 +0100 Subject: [PATCH] fix(google): repair Cloud Code Assist tool-call ordering (#406) --- CHANGELOG.md | 1 + src/agents/pi-embedded-helpers.test.ts | 25 ++++++++++++++++ src/agents/pi-embedded-helpers.ts | 30 +++++++++++++++++++ src/agents/pi-embedded-runner.ts | 41 ++++++++++++++++++++++++-- 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad350a4fa..892d4aa06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,7 @@ - Control UI: show a reading indicator bubble while the assistant is responding. - Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). +- Google: recover from corrupted transcripts that start with an assistant tool call to avoid Cloud Code Assist 400 ordering errors. Thanks @jonasjancarik for PR #421. (#406) - Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. - Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274. - Control UI: add Chat focus mode toggle to collapse header + sidebar. diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index 69a93430a..36ed1146b 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -1,10 +1,12 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; import { buildBootstrapContextFiles, formatAssistantErrorText, isContextOverflowError, + sanitizeGoogleTurnOrdering, } from "./pi-embedded-helpers.js"; import { DEFAULT_AGENTS_FILENAME, @@ -83,3 +85,26 @@ describe("formatAssistantErrorText", () => { expect(formatAssistantErrorText(msg)).toContain("Context overflow"); }); }); + +describe("sanitizeGoogleTurnOrdering", () => { + it("prepends a synthetic user turn when history starts with assistant", () => { + const input = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_1", name: "bash", arguments: {} }, + ], + }, + ] satisfies AgentMessage[]; + + const out = sanitizeGoogleTurnOrdering(input); + expect(out[0]?.role).toBe("user"); + expect(out[1]?.role).toBe("assistant"); + }); + + it("is a no-op when history starts with user", () => { + const input = [{ role: "user", content: "hi" }] satisfies AgentMessage[]; + const out = sanitizeGoogleTurnOrdering(input); + expect(out).toBe(input); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 0b0eaec19..baafe7ef6 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -104,6 +104,36 @@ export async function sanitizeSessionMessagesImages( return out; } +const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)"; + +export function sanitizeGoogleTurnOrdering( + messages: AgentMessage[], +): AgentMessage[] { + const first = messages[0] as + | { role?: unknown; content?: unknown } + | undefined; + const role = first?.role; + const content = first?.content; + if ( + role === "user" && + typeof content === "string" && + content.trim() === GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT + ) { + return messages; + } + if (role !== "assistant") return messages; + + // Cloud Code Assist rejects histories that begin with a model turn (tool call or text). + // Prepend a tiny synthetic user turn so the rest of the transcript can be used. + const bootstrap: AgentMessage = { + role: "user", + content: GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT, + timestamp: Date.now(), + } as AgentMessage; + + return [bootstrap, ...messages]; +} + export function buildBootstrapContextFiles( files: WorkspaceBootstrapFile[], ): EmbeddedContextFile[] { diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index e5a75a473..cf7d79112 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -63,6 +63,7 @@ import { isRateLimitAssistantError, isRateLimitErrorMessage, pickFallbackThinkingLevel, + sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, } from "./pi-embedded-helpers.js"; import { @@ -699,10 +700,27 @@ export async function compactEmbeddedPiSession(params: { })); try { - const prior = await sanitizeSessionMessagesImages( + const sanitizedImages = await sanitizeSessionMessagesImages( session.messages, "session:history", ); + const needsGoogleBootstrap = + (model.api === "google-gemini-cli" || + model.api === "google-generative-ai") && + sanitizedImages[0] && + typeof sanitizedImages[0] === "object" && + "role" in sanitizedImages[0] && + sanitizedImages[0].role === "assistant"; + const prior = + model.api === "google-gemini-cli" || + model.api === "google-generative-ai" + ? sanitizeGoogleTurnOrdering(sanitizedImages) + : sanitizedImages; + if (needsGoogleBootstrap) { + log.warn( + `google turn ordering fixup: prepended user bootstrap (sessionId=${params.sessionId})`, + ); + } if (prior.length > 0) { session.agent.replaceMessages(prior); } @@ -1026,8 +1044,25 @@ export async function runEmbeddedPiAgent(params: { session.messages, "session:history", ); - if (prior.length > 0) { - session.agent.replaceMessages(prior); + const needsGoogleBootstrap = + (model.api === "google-gemini-cli" || + model.api === "google-generative-ai") && + prior[0] && + typeof prior[0] === "object" && + "role" in prior[0] && + prior[0].role === "assistant"; + const sanitizedPrior = + model.api === "google-gemini-cli" || + model.api === "google-generative-ai" + ? sanitizeGoogleTurnOrdering(prior) + : prior; + if (needsGoogleBootstrap) { + log.warn( + `google turn ordering fixup: prepended user bootstrap (sessionId=${params.sessionId})`, + ); + } + if (sanitizedPrior.length > 0) { + session.agent.replaceMessages(sanitizedPrior); } } catch (err) { session.dispose();