Skip to content

fix(core): enforce terminal user turn invariant and normalize request contents - #29612

Merged
DavidAPierce merged 9 commits into
google-gemini:mainfrom
luisfelipe-alt:bugfix/WT-engineer_568384968
Oct 7, 2026
Merged

DavidAPierce merged 9 commits into
google-gemini:mainfrom
luisfelipe-alt:bugfix/WT-engineer_568384968

Conversation

@luisfelipe-alt

Copy link
Copy Markdown
Contributor

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 unrecoverable HTTP 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 the historyLengthAfterUserPromptRef.current assignment with if (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 in RewindViewer, 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): Added lastTurn.content.role !== 'user' to sendMessageStream and 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): In stripToolCallIdPrefixes, preserved the nested parts field on functionResponse items (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 following stripToolCallIdPrefixes, inspected the terminal turn:
    • If the trailing turn is a model turn containing unfulfilled functionCall parts, synthesizes paired functionResponse entries ("Response was lost or interrupted.") to maintain tool call/response pairing.
    • Appends a continuation user turn ({ role: 'user', parts: [{ text: 'Please continue.' }] }) whenever the request contents terminate on a model turn or empty turn.
    • Emits a debugLogger.warn for observability and keeps lastContentsToUse synchronized.

Related Issues

Fixes #29530
Related to #29574

How to Validate

  1. Automated Test Suites:

    # Run core regression tests
    npm test -w @google/gemini-cli-core -- src/core/geminiChat.test.ts
    
    # Run CLI hook tests
    npm test -w @google/gemini-cli -- src/ui/hooks/useGeminiStream.test.ts
    
    # Run full workspace lint and typecheck
    npm run lint
    npm run typecheck

    Expected result: All test suites pass with 0 errors and 0 warnings.

  2. Interactive Stream Cancellation & Continuation Validation:

    • Launch gemini.
    • Submit a prompt that triggers a tool execution (e.g., read_many_files).
    • Abort or interrupt mid-stream (Ctrl+C or Esc).
    • Send a subsequent user prompt.
      Expected result: Stream proceeds without HTTP 400 error.
  3. Interactive Rewind Validation:

    • Run /rewind in a session containing multi-step tool calls.
    • Select a previous user prompt to restore.
    • Submit a subsequent user prompt.
      Expected result: Session restores cleanly and model responds as expected.

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed)
  • Added/updated tests (if needed)
  • Noted breaking changes (if any)
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

@luisfelipe-alt
luisfelipe-alt requested a review from a team as a code owner October 2, 2026 13:54
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, 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

  • Terminal User Turn Invariant: Implemented a robust guard in geminiChat.ts to ensure that all requests dispatched to the Gemini API terminate with a valid user turn, preventing 'INVALID_ARGUMENT' errors when sessions end on model or empty turns.
  • Tool Response Integrity: Enhanced stripToolCallIdPrefixes to preserve nested parts in function responses and automatically synthesize placeholder responses for interrupted tool calls, ensuring conversation state remains valid.
  • State Machine Improvements: Updated useGeminiStream.ts to prevent incorrect rollback index overwrites during multi-turn tool loops and filtered synthetic tool responses from the RewindViewer to improve navigation accuracy.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@github-actions github-actions Bot added the size/l A large sized PR label Oct 2, 2026
@gemini-cli gemini-cli Bot added the area/agent Issues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality label Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

📊 PR Size: size/XL

  • Lines changed: 1704
  • Additions: +1618
  • Deletions: -86
  • Files changed: 12

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/core/geminiChat.ts Outdated
… 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
@luisfelipe-alt
luisfelipe-alt force-pushed the bugfix/WT-engineer_568384968 branch from 1210779 to 86e862d Compare October 2, 2026 14:35
@luisfelipe-alt

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kschaab kschaab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/cli/src/ui/components/RewindViewer.tsx Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
… 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.
@luisfelipe-alt

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/cli/src/ui/components/RewindViewer.tsx
…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.
@luisfelipe-alt

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 kschaab left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Stateful mutation inside the retryable apiCall closure ([PITFALL-007], [OOP-CVA-002], [PR-HYGIENE-002]): In packages/core/src/core/geminiChat.ts, the egress normalization block mutates this.agentHistory and this.chatRecordingService inside apiCall (which is invoked repeatedly by retryWithBackoff and streamWithRetries). This appends duplicate synthetic turns to persistent history and disk on every retry attempt, pollutes this.agentHistory when apiHistoryOverride or BeforeModel modifiedContents is used, pushes prefix-stripped tool call IDs into this.agentHistory (breaking ID pairing with unstripped model functionCall turns), and leaves lastContentsToUse stale for AfterModel hooks.
  2. Overly broad isToolResponseMessage predicate (Correctness): In packages/cli/src/ui/utils/rewindFileOps.ts, using parts.some(...) instead of parts.every(...) classifies any user message containing both a synthesized functionResponse (via closeUnansweredToolCallsTurn or 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).
  3. Production vs. test mismatch in useGeminiStream.ts rollback anchor ([PITFALL-003], Correctness): historyLengthAfterUserPromptRef.current = geminiClient.getHistory().length runs before geminiClient.sendMessageStream adds the user prompt to geminiClient. In production (unlike the unit test, which pre-seeds client.setHistory before calling submitQuery), cancelling a continuation tool call rolls geminiClient back to the previous turn's trailing model response and erases already-completed tool rounds from the current turn.
  4. Missing unit test coverage ([TEST-002]): packages/cli/src/ui/utils/rewindFileOps.test.ts was not updated with unit tests for isToolResponseMessage or multi-step tool turns in calculateTurnStats, and the new hadEmptyTrailingModelTurn branch in closeUnansweredToolResponseTurn has no test coverage.

Good PR Checkmarks Scorecard

ID Checkmark Grade Justification
[CHECK-1] PR Scope & Pure Refactor Isolation ⚠️ WARN 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 ⚠️ WARN 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]).

Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts Outdated
Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/cli/src/ui/utils/rewindFileOps.ts
Comment thread packages/cli/src/ui/utils/rewindFileOps.ts Outdated
Comment thread packages/cli/src/ui/components/RewindViewer.tsx Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.ts Outdated
Comment thread packages/cli/src/ui/hooks/useGeminiStream.test.tsx Outdated
Comment thread packages/core/src/core/geminiChat.ts
Comment thread packages/core/src/core/geminiChat.ts Outdated
…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.
@luisfelipe-alt

Copy link
Copy Markdown
Contributor Author

/gemini review

@github-actions github-actions Bot added the size/xl An extra large PR label Oct 6, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/core/client.ts
…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.
@luisfelipe-alt

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kschaab
kschaab enabled auto-merge October 6, 2026 21:46
@kschaab
kschaab added this pull request to the merge queue Oct 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 6, 2026
@DavidAPierce
DavidAPierce enabled auto-merge October 7, 2026 15:26
@DavidAPierce
DavidAPierce added this pull request to the merge queue Oct 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Oct 7, 2026
@DavidAPierce
DavidAPierce added this pull request to the merge queue Oct 7, 2026
Merged via the queue into google-gemini:main with commit 249d51c Oct 7, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/agent Issues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality size/l A large sized PR size/xl An extra large PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(core): 400 Bad Request "Requests ending with a model turn are not supported" after /rewind or interrupted stream

3 participants