Repository navigation
fix(core): enforce terminal user turn invariant and normalize request contents - #29612
DavidAPierce merged 9 commits into
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses critical issues in the Gemini CLI's conversation state management, specifically focusing on protocol compliance with the Gemini API. By enforcing that all outbound requests end with a valid user turn and normalizing conversation history to handle interrupted tool calls or empty turns, the changes prevent terminal sessions from becoming unresponsive. The update also improves the reliability of history rewinding and tool call handling by ensuring that synthetic and empty messages do not pollute the request payload. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
📊 PR Size: size/XL
|
There was a problem hiding this comment.
Code Review
This pull request ensures that the final request contents sent to the Gemini API satisfy the API invariant of ending with a valid user turn. It normalizes contents by appending a user turn (such as "Please continue." or missing function responses) if the history ends with a model turn or an empty user turn. Additionally, it updates stripToolCallIdPrefixes to preserve functionResponse parts and filter out empty turns, filters out tool responses from the rewind viewer interactions, and adds comprehensive test coverage. The reviewer suggested using 'generic_tool' as a trimmed fallback for missing tool names in the generated function responses to maintain consistency.
Note: Security Review did not run due to the size of the PR.
… contents
Ensure outgoing conversation contents dispatched to generateContentStream satisfy
the Gemini API contract requiring requests to end with a valid user turn containing non-empty parts.
- In GeminiChat, normalize trailing turns after prefix stripping by synthesizing paired responses for dangling tool calls and appending a continuation turn when history terminates on
a model turn or empty turn.
- In GeminiChat, verify role equality during turn deduplication to ensure identical user prompt text following a model turn is retained.
- In stripToolCallIdPrefixes, preserve nested functionResponse parts and filter turns whose parts array becomes empty after stripping.
- In useGeminiStream, avoid updating historyLengthAfterUserPromptRef during stream continuation turns to maintain correct rollback slicing.
- In RewindViewer, filter synthetic tool response messages from the rewind interaction selector.
- Add comprehensive regression tests in geminiChat.test.ts.
Closes google-gemini#29530
1210779 to
86e862d
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces normalization logic in GeminiChat to ensure that the final contents dispatched to the Gemini API always end with a valid user turn, satisfying the API invariant. It handles cases where the history ends with a model turn by appending a user turn with either synthesized tool responses or a 'Please continue.' prompt. Additionally, it updates stripToolCallIdPrefixes to preserve functionResponse parts and filter out empty turns, refines the interactions filtering in RewindViewer to exclude tool responses, and adds comprehensive unit tests to verify these behaviors. I have no feedback to provide as there are no review comments.
d3f1a4e to
2b9d1f8
Compare
kschaab
left a comment
There was a problem hiding this comment.
Thanks for working on this! I left several inline comments regarding edge cases around multi-turn history state, stripToolCallIdPrefixes role alternation, scrubPart in historyHardening.ts, and RewindViewer turn stats.
… response handling, and rewind stats - In historyHardening, preserve and recursively normalize nested functionResponse.parts to retain multimodal parts. - In GeminiChat, synthesize missing function responses when following an unclosed model functionCall turn to preserve proper tool call pairing. - In GeminiChat, populate trailing empty model turns following an unclosed tool response with an interrupted response placeholder to avoid role fusion. - In GeminiChat, synchronize agentHistory with durable synthetic messages when normalizing dangling turns. - In GeminiChat, preserve pre-stripping request contents for AfterModel hooks by removing redundant assignment. - In GeminiChat, re-coalesce consecutive contents after stripping empty parts to maintain role alternation invariants. - In RewindViewer and rewindFileOps, update synthetic tool response matching to accurately filter turns with sibling parts and track file statistics across multi-step turns. - Add comprehensive regression tests in geminiChat.test.ts and historyHardening.test.ts.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces robust history hardening and terminal user turn invariant enforcement to ensure API request contents sent to Gemini always end with a valid user turn. It handles closing unanswered tool calls and responses, filters out synthetic tool responses from rewind points in the CLI, and preserves nested parts within function responses during history scrubbing. The reviewer feedback highlights three high-severity issues: two instances where in-memory history mutations are not persisted to the chat recording service (causing memory-disk desynchronization), and one instance of duplicated logic in RewindViewer.tsx that should reuse the newly introduced isToolResponseMessage helper.
Note: Security Review did not run due to the size of the PR.
…sponse helper - In GeminiChat, persist mutated history to chatRecordingService when filling empty trailing model turns with an interrupted response placeholder. - In GeminiChat, persist mutated history to chatRecordingService when replacing empty trailing user turns with continuation text. - In RewindViewer, reuse isToolResponseMessage from rewindFileOps to avoid duplicate synthetic tool response checking logic.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request hardens history management and API request normalization to ensure compliance with Gemini API invariants, specifically enforcing that the final payload ends with a valid user turn. It introduces mechanisms to close unanswered tool calls and tool responses before recording new user messages, filters out synthetic tool response messages from rewind points and turn statistics calculations, and preserves nested parts within function responses during history scrubbing and prefix stripping. Comprehensive unit tests have been added across the CLI and core packages to validate these history integrity and rollback behaviors. I have no additional feedback to provide as no review comments were submitted.
Note: Security Review did not run due to the size of the PR.
kschaab
left a comment
There was a problem hiding this comment.
Code Review Summary
This PR targets HTTP 400 Bad Request (INVALID_ARGUMENT: "Requests ending with a model turn are not supported") errors after /rewind, stream aborts, or tool cancellation rollbacks. While preserving functionResponse.parts in scrubPart / stripToolCallIdPrefixes and checking lastTurn.content.role !== 'user' during context-management deduplication are good fixes, there are several blocking correctness bugs, state synchronization issues, and test gaps that should be addressed before merging:
- Stateful mutation inside the retryable
apiCallclosure ([PITFALL-007],[OOP-CVA-002],[PR-HYGIENE-002]): Inpackages/core/src/core/geminiChat.ts, the egress normalization block mutatesthis.agentHistoryandthis.chatRecordingServiceinsideapiCall(which is invoked repeatedly byretryWithBackoffandstreamWithRetries). This appends duplicate synthetic turns to persistent history and disk on every retry attempt, pollutesthis.agentHistorywhenapiHistoryOverrideorBeforeModelmodifiedContentsis used, pushes prefix-stripped tool call IDs intothis.agentHistory(breaking ID pairing with unstripped modelfunctionCallturns), and leaveslastContentsToUsestale forAfterModelhooks. - Overly broad
isToolResponseMessagepredicate (Correctness): Inpackages/cli/src/ui/utils/rewindFileOps.ts, usingparts.some(...)instead ofparts.every(...)classifies any user message containing both a synthesizedfunctionResponse(viacloseUnansweredToolCallsTurnor steering hints) and authored user text as a synthetic tool response—hiding real user prompts from/rewind(RewindViewer.tsx) and misattributing per-turn file edit stats (calculateTurnStats). - Production vs. test mismatch in
useGeminiStream.tsrollback anchor ([PITFALL-003],Correctness):historyLengthAfterUserPromptRef.current = geminiClient.getHistory().lengthruns beforegeminiClient.sendMessageStreamadds the user prompt togeminiClient. In production (unlike the unit test, which pre-seedsclient.setHistorybefore callingsubmitQuery), cancelling a continuation tool call rollsgeminiClientback to the previous turn's trailing model response and erases already-completed tool rounds from the current turn. - Missing unit test coverage (
[TEST-002]):packages/cli/src/ui/utils/rewindFileOps.test.tswas not updated with unit tests forisToolResponseMessageor multi-step tool turns incalculateTurnStats, and the newhadEmptyTrailingModelTurnbranch incloseUnansweredToolResponseTurnhas no test coverage.
Good PR Checkmarks Scorecard
| ID | Checkmark | Grade | Justification |
|---|---|---|---|
[CHECK-1] |
PR Scope & Pure Refactor Isolation | Bundles multiple distinct fixes across packages/cli and packages/core (#29574 tool response parts, /rewind UI filtering, stream cancellation rollback, and GeminiChat egress mutation) into a single size/l PR. |
|
[CHECK-2] |
Pragmatic DRY (Don't Repeat Yourself) | ❌ FAIL | Copy-pastes unanswered functionCall synthesis between closeUnansweredToolCallsTurn and makeApiCallAndProcessStream, repeats synthetic turn recording 3x inline, and duplicates coalesceConsecutiveRoles / hardenHistory logic. |
[CHECK-3] |
Low Cyclomatic Complexity & Linear Flow | ❌ FAIL | Embeds an 82-line, 5-level deeply nested imperative normalization block inside the apiCall closure in makeApiCallAndProcessStream ([COMPLEXITY-001..003]). |
[CHECK-4] |
OOP Class Hierarchy & CVA Fit | ❌ FAIL | Bypasses the existing history hardening pipeline (historyHardening.ts) and mutates persistent agentHistory / chatRecordingService state inside a transient network-dispatch closure ([OOP-CVA-002]). |
[CHECK-5] |
Self-Documenting Code Over Comments | Relies on inline explanatory comments instead of extracting small, single-purpose helper functions ([READABILITY-001]). |
|
[CHECK-6] |
Comprehensive Test & Branch Coverage | ❌ FAIL | rewindFileOps.test.ts has zero tests for isToolResponseMessage or multi-step tool turns in calculateTurnStats, and hadEmptyTrailingModelTurn in closeUnansweredToolResponseTurn is untested ([TEST-002]). |
…rns with dedicated history turns - core: replace the stateful normalization inside the retryable apiCall closure with a pure ensureTerminalUserTurn helper, so retries, per-call history overrides and hook-modified contents no longer write to agentHistory or the session log; AfterModel hooks now receive the normalized contents. - core: replace an empty trailing user turn once per send (outside the retry loop) and keep the session log in sync. - core: closeUnansweredToolCallsTurn now records a dedicated synthetic functionResponse turn instead of merging it into the user's prompt; extract buildInterruptedToolResponseParts and named constants. - core: add GeminiChat/GeminiClient.discardTrailingUnansweredToolCallTurn, which removes only the trailing unanswered call turn and keeps turn ids. - core: share one empty-part predicate between prefix stripping and normalization, spread functionCall/functionResponse fields when stripping prefixes, drop a dead parts check and skip coalescing role-less contents. - cli: when a whole tool batch is declined, roll back only the trailing unanswered call turn, keeping the user prompt and completed tool rounds. - cli: isToolResponseMessage now requires function responses with no user-authored text (binary siblings allowed); flatten the calculateTurnStats guard and simplify the RewindViewer filter. - tests: cover the new helpers, retries with overrides, the empty trailing model turn branch, multi-round turn stats and the declined batch rollback with a realistic sendMessageStream mock.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces robust history repair and normalization mechanisms to maintain the Gemini API's invariant that request contents must end with a valid user turn. It filters out synthetic tool responses from rewind points, rolls back unanswered trailing model function calls for cancelled batches, and ensures proper pairing and alternation of turns. Feedback on the changes suggests adding a defensive initialization check in GeminiClient.discardTrailingUnansweredToolCallTurn to prevent potential unhandled exceptions if the chat session is not yet initialized.
Note: Security Review did not run due to the size of the PR.
…en chat is not initialized GeminiClient.discardTrailingUnansweredToolCallTurn now checks isInitialized() before calling getChat(), so the CLI cancellation flow cannot hit 'Chat not initialized'. Adds unit tests for the uninitialized, removed and no-op paths.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces robust history repair and normalization mechanisms to ensure that outbound payloads to the Gemini API always end with a valid user turn carrying content, maintaining API invariants. It adds a utility function isToolResponseMessage to identify synthetic tool responses, allowing the CLI to exclude them from rewind points and turn statistics. Additionally, it replaces the previous history rollback logic with a precise discardTrailingUnansweredToolCallTurn method, which discards only unanswered model function calls while preserving completed tool rounds and the originating user prompt. Extensive unit tests have been added across the CLI and core packages to validate these history hardening, turn normalization, and rewind behaviors. There are no review comments to address.
Note: Security Review did not run due to the size of the PR.
Summary
This PR ensures that conversation histories dispatched to the Gemini API (
generateContentStream) always satisfy the protocol invariant requiring requests to terminate with a valid user turn containing non-empty content parts.Previously, operations such as
/rewind, stream aborts, and tool cancellation rollbacks could leave the in-memory conversation history ending on a model turn or an empty turn. Because the upstream API rejects payloads ending with a model turn with an unrecoverableHTTP 400 Bad Request (INVALID_ARGUMENT: "Requests ending with a model turn are not supported"), interactive terminal sessions became unresponsive until reset. This change enforces history state integrity upstream and provides robust egress normalization before network dispatch.Details
The fix implements a targeted two-layer approach:
1. Upstream State Machine Integrity (
packages/cli)useGeminiStream.ts: Wrapped thehistoryLengthAfterUserPromptRef.currentassignment withif (geminiClient && !options?.isContinuation). This ensures that multi-turn tool loops do not overwrite the rollback index with intermediate model-turn lengths, allowing cancelled or interrupted operations to cleanly roll back to the user prompt.RewindViewer.tsx: Filtered out synthetic tool response messages (functionResponse) from the selectable interaction list inRewindViewer, preventing history rewind points from landing on unclosed tool calls or empty turn entries.2. Network Boundary Invariant Guard & Normalization (
packages/core)geminiChat.ts(Turn Deduplication): AddedlastTurn.content.role !== 'user'tosendMessageStreamand context management history recording. This ensures user prompts with text matching a preceding model response (e.g., answering "yes") are properly recorded.geminiChat.ts(Tool Response Preservation): InstripToolCallIdPrefixes, preserved the nestedpartsfield onfunctionResponseitems (addressing bug(core): 400 Bad Request "Requests ending with a model turn are not supported" when reading image files via ReadFile tool #29574) and filtered out turns whose parts array becomes empty after stripping whitespace or empty text parts.geminiChat.ts(Terminal User Turn Guard): Immediately followingstripToolCallIdPrefixes, inspected the terminal turn:functionCallparts, synthesizes pairedfunctionResponseentries ("Response was lost or interrupted.") to maintain tool call/response pairing.{ role: 'user', parts: [{ text: 'Please continue.' }] }) whenever the request contents terminate on a model turn or empty turn.debugLogger.warnfor observability and keepslastContentsToUsesynchronized.Related Issues
Fixes #29530
Related to #29574
How to Validate
Automated Test Suites:
Expected result: All test suites pass with 0 errors and 0 warnings.
Interactive Stream Cancellation & Continuation Validation:
gemini.read_many_files).Ctrl+CorEsc).Expected result: Stream proceeds without HTTP 400 error.
Interactive Rewind Validation:
/rewindin a session containing multi-step tool calls.Expected result: Session restores cleanly and model responds as expected.
Pre-Merge Checklist