Repository navigation
fix(ai): keep Gemini 3+ requests valid under a trailing user turn (Vertex 400s after tool results) - #4337
fix(ai): keep Gemini 3+ requests valid under a trailing user turn (Vertex 400s after tool results)#4337sleegme wants to merge 2 commits into
Conversation
…d) into mainline
|
Thank you for this, @sleegme: the two 400s are exactly the pair we were chasing in #3633, and the live one-variable measurements in the PR body are what made this reviewable. Our review found one thing to change before we merge, plus two notes. Please change: in a multi-step tool loop, only the last assistant step counts as the final model turn.
Either of these closes it:
Notes, not blocking:
One more thing, so CI doesn't mislead you: the red |
Summary
Fixes #3633 (the senpi-side twin of omo#9747). Two Vertex Gemini 3+/4 (incl.
gemini-4-argon) request-shape 400s that break long-running sessions on the turn after tool results:400 INVALID_ARGUMENT "Requests ending with a model turn are not supported."400 INVALID_ARGUMENT "Function call is missing a thought_signature in functionCall parts. Function call default_api:<tool> at position N."Root cause (both live-measured one variable per request in #3633, plus the real transcript in omo#9747):
convertMessagesfolds a user message that follows tool results into the same user turn after its functionResponse parts:user[functionResponse, text]. Vertex's Gemini 3+ parser rejects exactly that part order (misreporting it as a request ending with a model turn);[text, functionResponse]and[functionResponse]return 200. Trailing user text after tool results is routine in real sessions: steering notes, recalled-memory notices, and the detached/cancelled<system-reminder>notifications of senpi-codemode eval cells (the omo#9747 transcript).isCurrentTurnboundary (last user message carrying content) past the assistant step that produced the tool results, so the unsigned-toolCall text-replay defense from fix(ai): replay unsigned Gemini 3 tool-call steps as text (Vertex 400) #3055 stops firing for it. Gemini 3+ still strict-validates the request's final model turn, so its unsignedfunctionCallis sent structured and 400s. This is also the mid-session model-switch case: after switching to Argon, prior-model steps carry no signatures at all.Fix (minimal; no signature forgery, no copying signatures across parts):
functionResponsepart at the end of its turn: text/inlineData merged after tool results is ordered ahead of the function responses. Gemini 2 and earlier keep byte-identical output.findFinalModelTurnStart: the assistant run immediately before the trailing user/tool-result run), not only for steps after the last user message carrying content. Earlier turns stay structured unsigned (accepted by the live endpoint per Gemini 3+/4 on Vertex: a user message after tool results 400s (model turn / missing thought_signature) #3633), and parallel calls that legitimately carry the signature on the first part only stay structured.Evidence
Before (new expectations red on unpatched source, each for the intended reason):
test/google-shared-role-alternation.test.ts: folded order[functionResponse, text]where[text, functionResponse]is required — the plain follow-up prompt case and the omo#9747 detached-eval reminder with two consecutive results ([fr, fr, text]).test/google-shared-gemini3-unsigned-tool-call.test.ts: an unsignedfunctionCallpart present in the final model turn under a trailing user message (plain trailing note; and a mid-session switch togemini-4-argonafter two foreign-model tool rounds), plus the signed-parallel-call ordering case.After:
packages/aisuite: 4060 passed | 900 skipped (4960), 0 failed. (A first run had 2 failures inmodels-entry.test.tscaused by the missingdist/build artifact in the fresh worktree —ERR_MODULE_NOT_FOUND dist/models.js; afterbun run buildthe file passes 2/2.)packages/coding-agentgoogle/argon suites: 29 passed (vertex-model-garden-extension,prompt-presets-gemini-4-argon).bun run check: green (oxfmt, oxlint + ratchet, pinned-deps/ts-imports/install-lock, browser-smoke, entry graphs). NeededNODE_OPTIONS=--max-old-space-size=6144: the monorepo-widetsc --noEmitexceeds Node's default 2GB heap on this 16GB machine.common.mjs --self-check10/10,cli-smoke.mjs --self-test8/8,mock-loop.mjs --self-test47/48 — the one failure (billing-swap) fails identically on pristine origin/main (verified on a clean a854e7a worktree), pre-existing and unrelated to this change.git diff --checkclean.Not reproduced live
Per task scope (no live calls), the two 400s were not re-fired against Vertex from this machine. The asserted request shapes are exactly the ones measured live in #3633's one-variable table and omo#9747's transcript; the fix emits the measured-200 shapes (
[text, functionResponse], functionResponse-only, or text-replayed unsigned steps) instead of the measured-400 ones.Counter-scenarios checked
gemini-2.5-pro) keeps the legacy[functionResponse, text]order — gate pinned by test.Merge danger
Door: two-way. Blast radius: Google adapters (
google-generative-ai,google-vertex), Gemini 3+ requests only. Gemini 2 and non-Gemini models keep byte-identical output; earlier-turn structured replay and first-part-signed parallel calls unchanged.