Skip to content

fix(agent): prevent session context poisoning and infinite loops on interrupted turns - #29397

Closed
dylanyunlon wants to merge 8 commits into
google-gemini:mainfrom
dylanyunlon:fix/session-context-poisoning-interrupted-turns
Closed

dylanyunlon wants to merge 8 commits into
google-gemini:mainfrom
dylanyunlon:fix/session-context-poisoning-interrupted-turns

Conversation

@dylanyunlon

Copy link
Copy Markdown

Summary

When an agentic loop stream is interrupted (via SIGINT, timeout, or aborted tool execution), the CLI appends a synthetic assistant turn containing [The previous response was interrupted before it completed.] directly into the chat session history. This causes severe in-context session poisoning: on subsequent turns, the Gemini model recognizes this string as the expected completion pattern and parrots it back, breaking the agentic loop and blocking tool executions until the session is reset.

Details

  1. New Sanitization Module: interruptionSanitizer.ts provides detection (isInterruptionPlaceholder, isInterruptionContent) and replacement (sanitizePart, sanitizeContent, etc.) utilities that map the dangerous placeholder to a benign model turn text ("Continuing.").

  2. Source Fix: closeUnansweredToolResponseTurn() in geminiChat.ts now injects the benign replacement directly instead of the raw placeholder, preventing poisoning at the source.

  3. Curated History Defense: extractCuratedHistory() sanitizes model output turns before they enter the curated history that gets sent to the Gemini API. This catches any residual placeholders from older sessions or resumed transcripts.

  4. Next-Speaker Short Circuit: nextSpeakerChecker.ts now detects interruption placeholder turns and immediately returns { next_speaker: 'model' } without making an LLM call, preventing the checker from misinterpreting the interrupted turn.

  5. Compression Guard: chatCompressionService.ts sanitizes the curated history before feeding it to the summarization model, so compressed snapshots never contain the poisoned text.

  6. History Hardening: scrubHistory() and scrubContents() in historyHardening.ts now run interruption sanitization as part of the scrubbing pipeline, providing defense in depth.

  7. E2E Integration Test: e2e-interruption-test.mjs starts a mock Gemini HTTP server on localhost, imports the built dist modules, and verifies the full sanitization pipeline end-to-end.

AST Call Chain Coverage (5 levels deep)

SIGINT/abort -> sendMessageStream() finally -> rollback
  -> closeUnansweredToolResponseTurn()           [SOURCE FIX]
  -> getHistoryTurns(curated=true)
    -> extractCuratedHistory()                   [SANITIZATION]
      -> makeApiCallAndProcessStream() -> API    [CLEAN]
      -> checkNextSpeaker()                      [SHORT CIRCUIT]
      -> ChatCompressionService.compress()       [GUARD]
    -> scrubHistory()                            [DEFENSE IN DEPTH]

Related Issues

Resolves #29264

How to Validate

  1. Run the new sanitizer tests:
    npm test -w @google/gemini-cli-core -- src/utils/interruptionSanitizer.test.ts --run
  2. Run the core chat tests (118 tests):
    npm test -w @google/gemini-cli-core -- src/core/geminiChat.test.ts --run
  3. Run all 6 affected test suites (275 tests):
    npm test -w @google/gemini-cli-core -- src/utils/interruptionSanitizer.test.ts src/core/geminiChat.test.ts src/utils/nextSpeakerChecker.test.ts src/context/chatCompressionService.test.ts src/utils/historyHardening.test.ts src/agents/local-executor.test.ts --run
  4. Run the E2E integration test (mock API server):
    node packages/core/e2e-interruption-test.mjs
  5. Full build and type-check:
    npm run build && npm run typecheck -w @google/gemini-cli-core

Pre-Merge Checklist

  • Added/updated tests (25 new unit tests + 6 poisoning scenario tests + E2E)
  • Validated on required platforms/methods:
    • Linux (Ubuntu 24.04)
      • npm run build
      • npm link -> gemini --version
      • gemini -p (headless mode against mock API server)
  • Updated relevant documentation and README (if needed)
  • Noted breaking changes (if any)

…nterrupted turns

When an agentic loop stream is interrupted (via SIGINT, timeout, or
aborted tool execution), the CLI appends a synthetic assistant turn
containing the raw interruption placeholder directly into the chat
session history. On subsequent turns the Gemini model recognizes this
string as the expected completion pattern and parrots it back, breaking
the agentic loop and blocking tool executions until the session is
reset.

Changes:

1. New sanitization module (interruptionSanitizer.ts)
   - isInterruptionPlaceholder, isInterruptionContent for detection
   - sanitizePart, sanitizeContent, sanitizeInterruptedTurns for replacement
   - Maps the dangerous placeholder to a benign model turn ("Continuing.")

2. Source fix (geminiChat.ts)
   - closeUnansweredToolResponseTurn() now injects the benign replacement
     directly instead of the raw placeholder
   - extractCuratedHistory() sanitizes model output turns before they
     enter the curated history sent to the Gemini API

3. Next-speaker short circuit (nextSpeakerChecker.ts)
   - Detects interruption placeholder turns and immediately returns
     { next_speaker: 'model' } without making an LLM call

4. Compression guard (chatCompressionService.ts)
   - Sanitizes curated history before feeding it to the summarizer

5. History hardening (historyHardening.ts)
   - scrubHistory() and scrubContents() run interruption sanitization
     as part of the scrubbing pipeline

6. Downstream consumers
   - agentChatHistory.ts: diagnostic helper containsInterruptionPlaceholder()
   - local-executor.ts: abort path uses BENIGN_INTERRUPTION_REPLACEMENT
   - index.ts: export interruptionSanitizer from core package
   - build_package.js: build-time validation that the sanitizer compiled

7. E2E integration test (e2e-interruption-test.mjs)
   - Starts a mock Gemini HTTP server on localhost
   - Imports the built dist modules and verifies sanitization end-to-end
   - Validates no poisoned text reaches the API request body

AST call chain (5 levels):

  SIGINT/abort -> sendMessageStream() finally -> rollback
    -> closeUnansweredToolResponseTurn()           [SOURCE FIX]
    -> getHistoryTurns(curated=true)
      -> extractCuratedHistory()                   [SANITIZATION]
        -> makeApiCallAndProcessStream() -> API    [CLEAN]
        -> checkNextSpeaker()                      [SHORT CIRCUIT]
        -> ChatCompressionService.compress()        [GUARD]
      -> scrubHistory()                            [DEFENSE IN DEPTH]

Resolves google-gemini#29264
@dylanyunlon
dylanyunlon requested a review from a team as a code owner September 18, 2026 13:46
@google-cla

google-cla Bot commented Sep 18, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

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

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

📊 PR Size: size/XL

  • Lines changed: 1146
  • Additions: +1139
  • Deletions: -7
  • Files changed: 15

@dylanyunlon

Copy link
Copy Markdown
Author

@gemini-code-assist please review this PR.

This fixes session context poisoning (issue #29264) where interrupted agentic loop streams inject a raw placeholder string into chat history, causing the model to parrot it back on subsequent turns.

Key changes:

  • New interruptionSanitizer.ts module with detection and replacement utilities
  • Source fix in closeUnansweredToolResponseTurn() to inject benign text
  • Defense in depth across extractCuratedHistory, nextSpeakerChecker, chatCompressionService, and historyHardening
  • 275 tests passing across 6 test suites + E2E mock API integration test

All pre-commit hooks (eslint, prettier) pass. Full build and typecheck clean.

@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 a critical issue where interrupted agentic turns cause 'session context poisoning,' leading to infinite loops and broken tool execution. By introducing a centralized sanitization module and integrating it across history extraction, compression, and speaker checking, the agent now gracefully handles interruptions without leaking the problematic placeholder into the model's context.

Highlights

  • Sanitization Module: Introduced the interruptionSanitizer.ts module to provide robust detection and replacement of interruption placeholders.
  • Source Fix: Updated geminiChat.ts to inject a benign replacement string instead of the raw interruption placeholder, preventing context poisoning at the source.
  • Defense in Depth: Integrated sanitization into extractCuratedHistory, chatCompressionService, and historyHardening to ensure no residual placeholders enter the model context.
  • Short-Circuit Logic: Updated nextSpeakerChecker to immediately return a model-next response when an interruption turn is detected, avoiding unnecessary LLM calls.
  • E2E Testing: Added a comprehensive E2E integration test using a mock Gemini API server to verify the entire sanitization pipeline.
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. ↩

@gemini-cli gemini-cli Bot added priority/p2 Important but can be addressed in a future release. area/agent Issues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality labels Sep 18, 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 implements session context poisoning prevention on interrupted turns (Issue #29264) by introducing an interruptionSanitizer utility that replaces raw interruption placeholders with a benign 'Continuing.' string. This sanitization is integrated across the core chat history, compression, and execution flows, and is backed by extensive unit and end-to-end tests. The feedback suggests improving the nextSpeakerChecker to correctly identify both raw and sanitized interruption placeholders, importing the necessary constant, and updating the corresponding tests to verify both cases using self-contained literal values.

Note: Security Review did not run due to the size of the PR.

import type { BaseLlmClient } from '../core/baseLlmClient.js';
import type { GeminiChat } from '../core/geminiChat.js';
import { isFunctionResponse } from './messageInspectors.js';
import { isInterruptionContent } from './interruptionSanitizer.js';

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.

high

Import the BENIGN_INTERRUPTION_REPLACEMENT constant from the sanitizer module so it can be used to check for sanitized interruption placeholders.

Suggested change
import { isInterruptionContent } from './interruptionSanitizer.js';
import { isInterruptionContent, BENIGN_INTERRUPTION_REPLACEMENT } from './interruptionSanitizer.js';

Comment on lines +87 to +90
if (
lastComprehensiveMessage &&
isInterruptionContent(lastComprehensiveMessage)
) {

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.

high

The current implementation of isInterruptionContent only checks for the raw interruption placeholder (INTERRUPTED_RESPONSE_TEXT). However, if the stream was interrupted, the turn is sanitized and stored as BENIGN_INTERRUPTION_REPLACEMENT ("Continuing.").

To ensure the short-circuit logic correctly identifies both raw and sanitized interruption placeholders (as intended by the comment), we should also check if any part of the last model message matches BENIGN_INTERRUPTION_REPLACEMENT.

  if (
    lastComprehensiveMessage &&
    (isInterruptionContent(lastComprehensiveMessage) ||
      lastComprehensiveMessage.parts?.some(
        (part) => typeof part.text === 'string' && part.text === BENIGN_INTERRUPTION_REPLACEMENT
      ))
  ) {

Comment on lines +300 to +332
it("should return { next_speaker: 'model' } when last message is an interruption placeholder", async () => {
(chatInstance.getHistory as Mock).mockImplementation(
(curated?: boolean) => {
if (curated) {
return [
{
role: 'model',
parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
},
] as Content[];
}
return [
{
role: 'model',
parts: [{ text: INTERRUPTED_RESPONSE_PLACEHOLDER }],
},
] as Content[];
},
);
const result = await checkNextSpeaker(
chatInstance,
mockBaseLlmClient,
abortSignal,
promptId,
);
expect(result).toEqual({
reasoning:
'The last model response was an interruption placeholder, so the model should speak next to provide a real response.',
next_speaker: 'model',
});
// Should NOT make an LLM call — this is a short-circuit
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});

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.

high

Update the test case to verify that the next speaker checker correctly short-circuits and returns { next_speaker: 'model' } for both the raw interruption placeholder and the sanitized interruption placeholder. Note that in tests, we prefer using hardcoded literal values (such as 'Continuing.') instead of importing constants to ensure tests are self-contained and less brittle.

  it.each([
    ['raw placeholder', INTERRUPTED_RESPONSE_PLACEHOLDER],
    ['sanitized placeholder', 'Continuing.'],
  ])("should return { next_speaker: 'model' } when last message is %s", async (_, placeholder) => {
    (chatInstance.getHistory as Mock).mockImplementation(
      (curated?: boolean) => {
        const turn = [
          {
            role: 'model',
            parts: [{ text: placeholder }],
          },
        ] as Content[];
        return turn;
      },
    );
    const result = await checkNextSpeaker(
      chatInstance,
      mockBaseLlmClient,
      abortSignal,
      promptId,
    );
    expect(result).toEqual({
      reasoning:
        'The last model response was an interruption placeholder, so the model should speak next to provide a real response.',
      next_speaker: 'model',
    });
    // Should NOT make an LLM call — this is a short-circuit
    expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
  });
References
  1. In tests, prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.

@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 addresses session context poisoning (Issue #29264) by introducing an interruptionSanitizer utility that replaces dangerous interruption placeholders with a benign replacement ('Continuing.'). This sanitization is integrated across several core components, including GeminiChat, ChatCompressionService, LocalAgentExecutor, and the history hardening pipeline, alongside updates to the next-speaker checker and new E2E and unit tests. The reviewer feedback recommends adding defensive checks against null or undefined values for parameters and optional properties (such as content, part, turns, history, and parts) within the new sanitizer functions to prevent potential runtime TypeError exceptions.

Note: Security Review did not run due to the size of the PR.

Comment on lines +46 to +54
export function isInterruptionContent(content: Content): boolean {
if (content.role !== 'model') return false;
if (!content.parts || content.parts.length === 0) return false;

return content.parts.some(
(part) =>
typeof part.text === 'string' && isInterruptionPlaceholder(part.text),
);
}

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.

high

To prevent potential runtime TypeError exceptions, we should defensively guard against content being null/undefined, and ensure that each part in content.parts is also checked for null/undefined before accessing its properties. Since parts is an optional property, we should handle the undefined case using the nullish coalescing operator (??) to code against the interface contract.

export function isInterruptionContent(content: Content): boolean {
  if (!content || content.role !== 'model') return false;
  const parts = content.parts ?? [];
  return parts.some(
    (part) =>
      part && typeof part.text === 'string' && isInterruptionPlaceholder(part.text),
  );
}
References
  1. When consuming an object, if a property is optional in its type definition (interface), callers must handle the undefined case (e.g., by providing a default with ??). Do not rely on the implementation details of the function that creates the object to always provide a value, as this can change. Code against the interface contract.

Comment on lines +60 to +65
export function sanitizePart(part: Part): Part {
if (typeof part.text !== 'string' || !isInterruptionPlaceholder(part.text)) {
return part;
}
return { ...part, text: BENIGN_INTERRUPTION_REPLACEMENT };
}

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.

high

Defensively guard against part being null or undefined before attempting to access part.text. This prevents runtime crashes if the parts array contains nullish values.

Suggested change
export function sanitizePart(part: Part): Part {
if (typeof part.text !== 'string' || !isInterruptionPlaceholder(part.text)) {
return part;
}
return { ...part, text: BENIGN_INTERRUPTION_REPLACEMENT };
}
export function sanitizePart(part: Part): Part {
if (!part || typeof part.text !== 'string' || !isInterruptionPlaceholder(part.text)) {
return part;
}
return { ...part, text: BENIGN_INTERRUPTION_REPLACEMENT };
}

Comment on lines +71 to +79
export function sanitizeContent(content: Content): Content {
if (!isInterruptionContent(content)) {
return content;
}
return {
...content,
parts: (content.parts || []).map(sanitizePart),
};
}

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.

high

Add a defensive check to ensure content is not null or undefined before processing. Since parts is an optional property, use the nullish coalescing operator (??) to handle the undefined case.

Suggested change
export function sanitizeContent(content: Content): Content {
if (!isInterruptionContent(content)) {
return content;
}
return {
...content,
parts: (content.parts || []).map(sanitizePart),
};
}
export function sanitizeContent(content: Content): Content {
if (!content || !isInterruptionContent(content)) {
return content;
}
return {
...content,
parts: (content.parts ?? []).map(sanitizePart),
};
}
References
  1. When consuming an object, if a property is optional in its type definition (interface), callers must handle the undefined case (e.g., by providing a default with ??). Do not rely on the implementation details of the function that creates the object to always provide a value, as this can change. Code against the interface contract.

Comment on lines +86 to +96
export function sanitizeInterruptedTurns(turns: HistoryTurn[]): HistoryTurn[] {
return turns.map((turn) => {
if (!isInterruptionContent(turn.content)) {
return turn;
}
return {
...turn,
content: sanitizeContent(turn.content),
};
});
}

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.

high

Defensively guard against turns being null/undefined, and ensure that each turn and its content property are checked for null/undefined before processing.

Suggested change
export function sanitizeInterruptedTurns(turns: HistoryTurn[]): HistoryTurn[] {
return turns.map((turn) => {
if (!isInterruptionContent(turn.content)) {
return turn;
}
return {
...turn,
content: sanitizeContent(turn.content),
};
});
}
export function sanitizeInterruptedTurns(turns: HistoryTurn[]): HistoryTurn[] {
if (!turns) return [];
return turns.map((turn) => {
if (!turn || !turn.content || !isInterruptionContent(turn.content)) {
return turn;
}
return {
...turn,
content: sanitizeContent(turn.content),
};
});
}

Comment on lines +102 to +104
export function sanitizeContentHistory(history: Content[]): Content[] {
return history.map(sanitizeContent);
}

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.

high

Defensively guard against history being null or undefined before mapping over it.

Suggested change
export function sanitizeContentHistory(history: Content[]): Content[] {
return history.map(sanitizeContent);
}
export function sanitizeContentHistory(history: Content[]): Content[] {
if (!history) return [];
return history.map(sanitizeContent);
}

Resolve all 8 high-priority review comments from @gemini-code-assist:

1. interruptionSanitizer.ts: Add defensive null/undefined guards to
   isInterruptionContent, sanitizePart, sanitizeContent,
   sanitizeInterruptedTurns, and sanitizeContentHistory. Use nullish
   coalescing (??) for optional parts arrays.

2. nextSpeakerChecker.ts: Import BENIGN_INTERRUPTION_REPLACEMENT and
   extend the short-circuit check to detect both raw placeholders and
   sanitized 'Continuing.' turns.

3. nextSpeakerChecker.test.ts: Replace single test with parameterized
   it.each covering both raw and sanitized placeholder forms.

4. interruptionSanitizer.test.ts: Add null/undefined defensive tests
   for all public functions.

282 tests passing across 6 test suites + E2E.
@dylanyunlon

Copy link
Copy Markdown
Author

Thanks @gemini-code-assist for the thorough review! All 8 high-priority items addressed in the latest push:

  1. Defensive null/undefined guards - Added to isInterruptionContent, sanitizePart, sanitizeContent, sanitizeInterruptedTurns, and sanitizeContentHistory. Used ?? for optional parts arrays.
  2. Sanitized placeholder detection in nextSpeakerChecker - Now checks for both raw INTERRUPTED_RESPONSE_TEXT and sanitized BENIGN_INTERRUPTION_REPLACEMENT so resumed sessions with already-sanitized history also skip the LLM call.
  3. Parameterized test - Replaced single test with it.each covering both raw and sanitized forms.
  4. Null guard tests - Added for all public sanitizer functions.

282 tests passing across 6 suites + E2E mock API integration.

@gemini-code-assist please re-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 implements session context poisoning prevention (Issue #29264) by introducing an interruption sanitizer that replaces raw interruption placeholders with a benign 'Continuing.' string across history extraction, chat compression, history hardening, and local execution. It also updates the next speaker checker to short-circuit when an interruption is detected. The review identified a critical bug in the next speaker checker where any multi-part model response containing the transition word 'Continuing.' would trigger a false positive short-circuit, bypassing tool execution; restricting the match to single-part messages containing only the placeholder resolves this issue.

Comment on lines +87 to +93
const isSanitizedInterruption =
lastComprehensiveMessage &&
lastComprehensiveMessage.role === 'model' &&
lastComprehensiveMessage.parts?.some(
(p) =>
p && typeof p.text === 'string' && p.text === BENIGN_INTERRUPTION_REPLACEMENT,
);

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.

critical

The current implementation of isSanitizedInterruption uses parts.some() to check if any part of the last model message matches BENIGN_INTERRUPTION_REPLACEMENT ("Continuing."). This creates a critical bug: if the model outputs a multi-part response that contains the word "Continuing." (e.g., as a transition phrase) followed by a tool call, isSanitizedInterruption will evaluate to true. This triggers the short-circuit logic, returning { next_speaker: 'model' } immediately and bypassing the tool execution entirely, which breaks the agentic loop. To prevent this, we should ensure that isSanitizedInterruption only matches when the message consists solely of the synthetic interruption placeholder (i.e., it has exactly one part, and that part is the placeholder text).

Suggested change
const isSanitizedInterruption =
lastComprehensiveMessage &&
lastComprehensiveMessage.role === 'model' &&
lastComprehensiveMessage.parts?.some(
(p) =>
p && typeof p.text === 'string' && p.text === BENIGN_INTERRUPTION_REPLACEMENT,
);
const isSanitizedInterruption =
lastComprehensiveMessage &&
lastComprehensiveMessage.role === 'model' &&
lastComprehensiveMessage.parts?.length === 1 &&
lastComprehensiveMessage.parts[0] &&
typeof lastComprehensiveMessage.parts[0].text === 'string' &&
lastComprehensiveMessage.parts[0].text === BENIGN_INTERRUPTION_REPLACEMENT;

…n detection

Address critical review from @gemini-code-assist: the previous
parts.some() check for BENIGN_INTERRUPTION_REPLACEMENT could
false-positive on a real model response that happens to contain
"Continuing." as a transition phrase alongside a tool call. This
would bypass tool execution and break the agentic loop.

Fix: require parts.length === 1 so only the synthetic single-part
placeholder matches, never a multi-part real response.

Added regression test for the exact false-positive scenario:
a model turn with [{ text: 'Continuing.' }, { functionCall: ... }]
must NOT trigger the short-circuit.

283 tests passing across 6 suites + E2E.
@dylanyunlon

Copy link
Copy Markdown
Author

Fixed the critical finding from @gemini-code-assist:

Problem: isSanitizedInterruption used parts.some() which would false-positive when a real model response contains "Continuing." as a transition phrase alongside a tool call, bypassing tool execution and breaking the agentic loop.

Fix: Changed to strict single-part matching (parts.length === 1) so only the synthetic placeholder matches, never a multi-part real response.

Regression test added: A model turn [{ text: "Continuing." }, { functionCall: ... }] is verified to NOT trigger the short-circuit.

283 tests passing + E2E clean.

@gemini-code-assist please re-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 implements session context poisoning prevention on interrupted turns (Issue #29264) by introducing an interruptionSanitizer utility. This utility replaces the dangerous raw interruption placeholder with a benign 'Continuing.' string across history curation, chat compression, history hardening, next-speaker checking, and local execution. Comprehensive unit and end-to-end tests are also added. The feedback suggests combining consecutive .map calls in historyHardening.ts to avoid intermediate array allocations, and refactoring the assertion logic in e2e-interruption-test.mjs to use a try...finally block to guarantee cleanup of temporary directories on failure.

Comment on lines +394 to +396
const scrubbedParts = nonThoughtParts
.map((p) => scrubPart(p))
.map((p) => sanitizeInterruptionPart(p));

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.

high

To optimize performance and avoid creating intermediate arrays, combine the .map(scrubPart) and .map(sanitizeInterruptionPart) calls into a single .map pass. This reduces iteration overhead on the chat history turns.

Suggested change
const scrubbedParts = nonThoughtParts
.map((p) => scrubPart(p))
.map((p) => sanitizeInterruptionPart(p));
const scrubbedParts = nonThoughtParts.map((p) =>
sanitizeInterruptionPart(scrubPart(p)),
);

Comment on lines +434 to +436
const scrubbedParts = nonThoughtParts
.map((p) => scrubPart(p))
.map((p) => sanitizeInterruptionPart(p));

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.

high

Similarly, combine the .map calls here into a single pass to avoid intermediate array allocation and improve efficiency during history scrubbing.

Suggested change
const scrubbedParts = nonThoughtParts
.map((p) => scrubPart(p))
.map((p) => sanitizeInterruptionPart(p));
const scrubbedParts = nonThoughtParts.map((p) =>
sanitizeInterruptionPart(scrubPart(p)),
);

Comment on lines +291 to +297
function assert(condition, message) {
if (!condition) {
console.error(`\n ✗ ASSERTION FAILED: ${message}`);
server.close();
process.exit(1);
}
}

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.

high

If an assertion fails, process.exit(1) is called immediately, which bypasses the cleanup of the temporary directory tmpHome and leaves orphaned directories in the OS temp folder. To prevent resource leaks on test failures, refactor assert to throw an Error instead of exiting the process directly. Then, wrap the test execution in main() with a try...finally block to guarantee that server.close() and fs.rmSync(tmpHome, ...) are always executed.

function assert(condition, message) {
  if (!condition) {
    throw new Error(message);
  }
}

1. e2e-interruption-test.mjs: assert() now throws instead of calling
   process.exit(1), and main() body is wrapped in try/finally to
   guarantee tmpHome cleanup on assertion failure. Prevents orphaned
   temp directories in OS temp folder.

2. historyHardening.ts: Merged two consecutive .map() passes
   (.map(scrubPart).map(sanitizeInterruptionPart)) into a single
   .map(p => sanitizeInterruptionPart(scrubPart(p))) in both
   scrubHistory() and scrubContents(). Eliminates intermediate array
   allocation.

283 tests + E2E clean.
@dylanyunlon

Copy link
Copy Markdown
Author

Round 3 addressed, all 3 high-priority items:

  1. e2e-interruption-test.mjs - assert() now throws instead of process.exit(1). Test body wrapped in try/finally so tmpHome is always cleaned up even on failure.

  2. historyHardening.ts (scrubHistory) - Merged .map(scrubPart).map(sanitizeInterruptionPart) into single .map(p => sanitizeInterruptionPart(scrubPart(p))). No intermediate array.

  3. historyHardening.ts (scrubContents) - Same single-pass optimization.

283 tests + E2E clean.

@gemini-code-assist please re-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 a session context poisoning prevention mechanism by replacing dangerous raw interruption placeholders with a benign replacement ('Continuing.'). It adds an interruption sanitizer utility, integrates it across history curation, chat compression, and next-speaker checking, and includes comprehensive tests. The review feedback identifies a correctness bug in LocalAgentExecutor where using the benign replacement as a fallback for aborted subagent runs misleads the parent agent. The reviewer recommends reverting to the original explicit termination message, trimming the optional string, and updating the associated tests and imports to use hardcoded literal values.

Note: Security Review did not run due to the size of the PR.

Comment on lines +11 to +12
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js';

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.

high

The import of BENIGN_INTERRUPTION_REPLACEMENT is no longer needed if we revert the fallback result to the original explicit termination message.

Suggested change
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';

return {
result:
finalResult || 'Agent execution was terminated before completion.',
result: finalResult || BENIGN_INTERRUPTION_REPLACEMENT,

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.

high

Using BENIGN_INTERRUPTION_REPLACEMENT (which evaluates to 'Continuing.') as the fallback for an aborted or cancelled subagent run is a correctness bug. When a subagent is executed as a tool by a parent agent, the parent agent receives this result as the tool output. If the subagent was aborted, returning 'Continuing.' will mislead the parent agent into thinking the subagent completed successfully and returned 'Continuing.' as its output, rather than realizing it was aborted/cancelled. The original string 'Agent execution was terminated before completion.' does not contain the poisoned placeholder and therefore does not cause context poisoning. We should revert this fallback to the original explicit termination message. Additionally, when using an optional string with a fallback value, we should trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.

Suggested change
result: finalResult || BENIGN_INTERRUPTION_REPLACEMENT,
result: finalResult?.trim() || 'Agent execution was terminated before completion.',
References
  1. When using an optional string with a fallback value, trim the optional string and use the fallback if the result is empty to avoid uninformative messages from whitespace-only strings.

Comment on lines +108 to +109
} from './types.js';
import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js';

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.

high

The import of BENIGN_INTERRUPTION_REPLACEMENT is no longer needed in the test file once the assertions are updated to use the correct fallback string. In tests, we prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.

Suggested change
} from './types.js';
import { BENIGN_INTERRUPTION_REPLACEMENT } from '../utils/interruptionSanitizer.js';
} from './types.js';
References
  1. In tests, prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.

Comment on lines +4234 to +4256
describe('session context poisoning prevention (Issue #29264)', () => {
it('should use BENIGN_INTERRUPTION_REPLACEMENT as the fallback termination result', () => {
// Verify the constant is the short benign string, not the poisoning placeholder
expect(BENIGN_INTERRUPTION_REPLACEMENT).toBe('Continuing.');
expect(BENIGN_INTERRUPTION_REPLACEMENT).not.toContain('interrupted');
expect(BENIGN_INTERRUPTION_REPLACEMENT).not.toContain('[');
});

it('should not include raw interruption placeholder in default abort result messages', async () => {
// Read the source code to verify no raw placeholder is used in fallback results
const fs = await import('node:fs');
const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8');
// The old string 'Agent execution was terminated before completion.' has been
// replaced with BENIGN_INTERRUPTION_REPLACEMENT to prevent context poisoning
// when the executor result is injected back into the parent agent's history
expect(source).not.toContain(
"finalResult || 'Agent execution was terminated before completion.'",
);
expect(source).toContain(
'finalResult || BENIGN_INTERRUPTION_REPLACEMENT',
);
});
});

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.

high

Update the tests to assert the correct fallback string 'Agent execution was terminated before completion.' (with the trimmed optional string check) and verify that the raw interruption placeholder is not leaked, rather than asserting the incorrect 'Continuing.' fallback. Also, prefer using hardcoded literal values instead of importing constants to keep tests self-contained.

  describe('session context poisoning prevention (Issue #29264)', () => {
    it('should not include raw interruption placeholder in default abort result messages', async () => {
      const fs = await import('node:fs');
      const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8');
      expect(source).not.toContain('INTERRUPTED_RESPONSE_PLACEHOLDER');
      expect(source).not.toContain('INTERRUPTED_RESPONSE_TEXT');
      expect(source).toContain(
        "finalResult?.trim() || 'Agent execution was terminated before completion.'",
      );
    });
  });
References
  1. In tests, prefer using hardcoded literal values instead of importing constants to ensure tests are self-contained and less brittle.

…ssage

Address round-4 review from @gemini-code-assist (4 high):

Using BENIGN_INTERRUPTION_REPLACEMENT ('Continuing.') as the abort
fallback was a correctness bug: the parent agent would interpret it
as a successful completion rather than a termination. The original
string 'Agent execution was terminated before completion.' does NOT
contain the poisoning placeholder and therefore never caused context
poisoning in the first place.

Changes:
- local-executor.ts: revert fallback to original message, add
  .trim() guard against whitespace-only finalResult strings, remove
  unused BENIGN_INTERRUPTION_REPLACEMENT import
- local-executor.test.ts: remove constant import, rewrite test to
  use hardcoded literals and verify no raw placeholder leaks

282 tests + E2E clean.
@dylanyunlon

Copy link
Copy Markdown
Author

Round 4 addressed, all 4 high-priority items:

  1. local-executor.ts:820 - Reverted fallback to original "Agent execution was terminated before completion." (which never contained the poisoning placeholder). Added .trim() guard. Removed unused BENIGN_INTERRUPTION_REPLACEMENT import.

  2. local-executor.ts:12 - Import removed.

  3. local-executor.test.ts:109 - Import removed.

  4. local-executor.test.ts:4256 - Rewrote test with hardcoded literals: verifies no INTERRUPTED_RESPONSE_PLACEHOLDER or INTERRUPTED_RESPONSE_TEXT in source, and confirms the .trim() fallback pattern.

282 tests + E2E clean.

@gemini-code-assist please re-review.

@dylanyunlon

Copy link
Copy Markdown
Author

@gemini-code-assist All round-4 feedback has been addressed. Could you please do a final review of the latest commit (a4db215)?

Summary of current state:

  • interruptionSanitizer.ts: Full defensive null guards, ?? for optional parts
  • nextSpeakerChecker.ts: Strict single-part matching (parts.length === 1) for sanitized placeholder
  • historyHardening.ts: Single-pass .map() for scrub + sanitize
  • local-executor.ts: Reverted to original termination message with .trim() guard
  • e2e-interruption-test.mjs: assert throws, try/finally for cleanup
  • 282 tests + E2E passing

@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 prevention against session context poisoning caused by interrupted turns (Issue #29264). It adds a new interruptionSanitizer utility to replace dangerous raw interruption placeholders with a benign replacement ('Continuing.'), integrating this sanitization into history extraction, chat compression, and history scrubbing. It also updates the next-speaker checker to short-circuit on these placeholders and adds comprehensive unit and E2E tests. The review feedback suggests two valuable improvements: ensuring the next-speaker checker's sanitized interruption detection explicitly excludes thought parts to prevent false positives, and simplifying a redundant string equality check in isInterruptionPlaceholder since includes already covers exact matches.

Note: Security Review did not run due to the size of the PR.

Comment on lines +90 to +96
const isSanitizedInterruption =
lastComprehensiveMessage &&
lastComprehensiveMessage.role === 'model' &&
lastComprehensiveMessage.parts?.length === 1 &&
lastComprehensiveMessage.parts[0] &&
typeof lastComprehensiveMessage.parts[0].text === 'string' &&
lastComprehensiveMessage.parts[0].text === BENIGN_INTERRUPTION_REPLACEMENT;

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.

high

The check for isSanitizedInterruption should explicitly ensure that the single part is not a thought part. If the model generated a thought that happens to contain the benign replacement text, it could be incorrectly classified as a sanitized interruption turn.

  const isSanitizedInterruption =
    lastComprehensiveMessage &&
    lastComprehensiveMessage.role === 'model' &&
    lastComprehensiveMessage.parts?.length === 1 &&
    lastComprehensiveMessage.parts[0] &&
    !lastComprehensiveMessage.parts[0].thought &&
    typeof lastComprehensiveMessage.parts[0].text === 'string' &&
    lastComprehensiveMessage.parts[0].text === BENIGN_INTERRUPTION_REPLACEMENT;

Comment on lines +36 to +41
export function isInterruptionPlaceholder(text: string): boolean {
return (
text === INTERRUPTED_RESPONSE_TEXT ||
text.includes(INTERRUPTED_RESPONSE_TEXT)
);
}

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.

high

The check text === INTERRUPTED_RESPONSE_TEXT is redundant because text.includes(INTERRUPTED_RESPONSE_TEXT) already returns true when the strings are exactly equal. Simplifying this reduces redundant comparisons.

export function isInterruptionPlaceholder(text: string): boolean {
  return text.includes(INTERRUPTED_RESPONSE_TEXT);
}

@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 addresses session context poisoning (Issue #29264) caused by interrupted model turns. It introduces a new interruptionSanitizer utility to replace dangerous raw interruption placeholders with a benign replacement ('Continuing.') during history curation, chat compression, history scrubbing, and next speaker checking. Additionally, it includes comprehensive unit tests, a new end-to-end test, and build-time validation to ensure the sanitization logic works correctly. As there are no review comments provided, I have no feedback to offer on the review itself.

Note: Security Review did not run due to the size of the PR.

Round 5 from @gemini-code-assist (2 high):

1. interruptionSanitizer.ts: Remove redundant equality check in
   isInterruptionPlaceholder - text.includes() already covers the
   exact-match case.

2. nextSpeakerChecker.ts: Add !thought guard to isSanitizedInterruption
   so a thought part whose text happens to be 'Continuing.' is not
   misclassified as a synthetic interruption placeholder.

282 tests + E2E clean.
@dylanyunlon

Copy link
Copy Markdown
Author

Round 5 done (2 high):

  1. interruptionSanitizer.ts:41 - Removed redundant text === INTERRUPTED_RESPONSE_TEXT since text.includes() already covers exact match.

  2. nextSpeakerChecker.ts:96 - Added !thought guard to isSanitizedInterruption so a thought part containing "Continuing." cannot be misclassified as a synthetic interruption placeholder.

282 tests + E2E clean.

@gemini-code-assist please re-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 implements session context poisoning prevention (Issue #29264) by introducing an interruptionSanitizer utility to sanitize raw interruption placeholders and replace them with a benign 'Continuing.' message. The sanitization is integrated across GeminiChat, ChatCompressionService, historyHardening, and nextSpeakerChecker, and is supported by comprehensive unit and E2E tests. The review feedback highlights that several test files and the E2E script use hardcoded relative paths for file reading and dynamic imports, which makes execution fragile depending on the current working directory. It is recommended to use new URL(..., import.meta.url) to resolve these paths robustly relative to the source files.

Note: Security Review did not run due to the size of the PR.

describe('session context poisoning prevention (Issue #29264)', () => {
it('should not include raw interruption placeholder in default abort result messages', async () => {
const fs = await import('node:fs');
const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8');

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.

high

Using a hardcoded relative path like 'src/agents/local-executor.ts' makes the test execution fragile and dependent on the current working directory (CWD). If the tests are run from the monorepo root instead of the packages/core directory, this test will fail with an ENOENT error.

Using new URL('local-executor.ts', import.meta.url) resolves the path relative to the test file itself, making it robust and independent of where the test runner is invoked.

Suggested change
const source = fs.readFileSync('src/agents/local-executor.ts', 'utf-8');
const source = fs.readFileSync(new URL('local-executor.ts', import.meta.url), 'utf-8');

Comment on lines +119 to +127
const geminiChatModule = await import(
path.resolve('packages/core/dist/src/core/geminiChat.js')
);
const { INTERRUPTED_RESPONSE_PLACEHOLDER, GeminiChat } = geminiChatModule;

// Import the sanitizer to verify exports work
const sanitizerModule = await import(
path.resolve('packages/core/dist/src/utils/interruptionSanitizer.js')
);

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.

high

Using path.resolve with a hardcoded relative path like 'packages/core/dist/...' makes the dynamic imports fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail to resolve the modules.

Using new URL(..., import.meta.url).href resolves the paths relative to the script itself, making it robust and independent of where the script is invoked.

Suggested change
const geminiChatModule = await import(
path.resolve('packages/core/dist/src/core/geminiChat.js')
);
const { INTERRUPTED_RESPONSE_PLACEHOLDER, GeminiChat } = geminiChatModule;
// Import the sanitizer to verify exports work
const sanitizerModule = await import(
path.resolve('packages/core/dist/src/utils/interruptionSanitizer.js')
);
const geminiChatModule = await import(
new URL('dist/src/core/geminiChat.js', import.meta.url).href
);
const { INTERRUPTED_RESPONSE_PLACEHOLDER, GeminiChat } = geminiChatModule;
// Import the sanitizer to verify exports work
const sanitizerModule = await import(
new URL('dist/src/utils/interruptionSanitizer.js', import.meta.url).href
);

Comment on lines +185 to +188
const sourceCode = fs.readFileSync(
'packages/core/src/core/geminiChat.ts',
'utf-8',
);

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.

high

Using a hardcoded relative path like 'packages/core/src/core/geminiChat.ts' makes the file read fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail with an ENOENT error.

Using new URL('src/core/geminiChat.ts', import.meta.url) resolves the path relative to the script itself, making it robust and independent of where the script is invoked.

Suggested change
const sourceCode = fs.readFileSync(
'packages/core/src/core/geminiChat.ts',
'utf-8',
);
const sourceCode = fs.readFileSync(
new URL('src/core/geminiChat.ts', import.meta.url),
'utf-8',
);

Comment on lines +210 to +213
const nextSpeakerSource = fs.readFileSync(
'packages/core/src/utils/nextSpeakerChecker.ts',
'utf-8',
);

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.

high

Using a hardcoded relative path like 'packages/core/src/utils/nextSpeakerChecker.ts' makes the file read fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail with an ENOENT error.

Using new URL('src/utils/nextSpeakerChecker.ts', import.meta.url) resolves the path relative to the script itself, making it robust and independent of where the script is invoked.

Suggested change
const nextSpeakerSource = fs.readFileSync(
'packages/core/src/utils/nextSpeakerChecker.ts',
'utf-8',
);
const nextSpeakerSource = fs.readFileSync(
new URL('src/utils/nextSpeakerChecker.ts', import.meta.url),
'utf-8',
);

Comment on lines +228 to +231
const compressionSource = fs.readFileSync(
'packages/core/src/context/chatCompressionService.ts',
'utf-8',
);

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.

high

Using a hardcoded relative path like 'packages/core/src/context/chatCompressionService.ts' makes the file read fragile and dependent on the current working directory (CWD). If the script is run from the packages/core directory instead of the monorepo root, it will fail with an ENOENT error.

Using new URL('src/context/chatCompressionService.ts', import.meta.url) resolves the path relative to the script itself, making it robust and independent of where the script is invoked.

Suggested change
const compressionSource = fs.readFileSync(
'packages/core/src/context/chatCompressionService.ts',
'utf-8',
);
const compressionSource = fs.readFileSync(
new URL('src/context/chatCompressionService.ts', import.meta.url),
'utf-8',
);

Round 6 from @gemini-code-assist (5 high):

All hardcoded relative paths in e2e-interruption-test.mjs and
local-executor.test.ts replaced with import.meta.url-based resolution.
Scripts now work correctly regardless of which directory they are
invoked from (monorepo root, packages/core, or /tmp).

Verified from 3 different CWDs: all pass.
282 tests + E2E clean.
@dylanyunlon

Copy link
Copy Markdown
Author

Round 6 done (5 high, all same category):

Replaced all hardcoded relative paths with import.meta.url-based resolution in e2e-interruption-test.mjs (4 locations: 2 dynamic imports + 3 readFileSync) and local-executor.test.ts (1 readFileSync).

Verified the E2E test now passes from 3 different CWDs:

  • monorepo root
  • packages/core/
  • /tmp/ (absolute path invocation)

282 tests + E2E clean.

@gemini-code-assist please re-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 a robust mechanism to prevent session context poisoning caused by interrupted turns (Issue #29264). It adds an interruptionSanitizer utility to replace raw interruption placeholders with a benign 'Continuing.' response, integrating this sanitization across history curation, chat compression, history scrubbing, and next-speaker checking. Additionally, it updates the local executor's fallback messages and includes comprehensive unit and end-to-end tests. The review feedback highlights a critical improvement opportunity in isInterruptionPlaceholder: replacing the .includes() check with a strict exact match (using .trim()) to avoid false positives and data loss if the model legitimately references the placeholder text, along with a corresponding update to the unit tests.

Comment on lines +36 to +38
export function isInterruptionPlaceholder(text: string): boolean {
return text.includes(INTERRUPTED_RESPONSE_TEXT);
}

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.

high

Using .includes() to detect the interruption placeholder can lead to severe false positives and data loss. If the model legitimately discusses, explains, or quotes the interruption message (for example, in response to a user query asking about it), the entire text of that part will be aggressively replaced with "Continuing." and the surrounding text will be completely lost.

To prevent this, we should use a strict exact match (with .trim() to handle leading/trailing whitespace or newlines). Since the system-generated turn is always exactly the placeholder string, this is highly robust and safe.

Suggested change
export function isInterruptionPlaceholder(text: string): boolean {
return text.includes(INTERRUPTED_RESPONSE_TEXT);
}
export function isInterruptionPlaceholder(text: string): boolean {
return text.trim() === INTERRUPTED_RESPONSE_TEXT;
}

Comment on lines +27 to +33
it('should match when placeholder is embedded in surrounding text', () => {
expect(
isInterruptionPlaceholder(
`Some prefix ${INTERRUPTED_RESPONSE_TEXT} some suffix`,
),
).toBe(true);
});

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.

high

Update the test to reflect the strict exact matching behavior for the interruption placeholder, ensuring that embedded occurrences are not falsely matched.

Suggested change
it('should match when placeholder is embedded in surrounding text', () => {
expect(
isInterruptionPlaceholder(
`Some prefix ${INTERRUPTED_RESPONSE_TEXT} some suffix`,
),
).toBe(true);
});
it('should not match when placeholder is embedded in surrounding text', () => {
expect(
isInterruptionPlaceholder(
'Some prefix ' + INTERRUPTED_RESPONSE_TEXT + ' some suffix',
),
).toBe(false);
});

…tion

Round 7 from @gemini-code-assist (2 high):

1. interruptionSanitizer.ts: Changed isInterruptionPlaceholder from
   .includes() to .trim() === exact match. Prevents false positives
   where the model legitimately discusses or quotes the interruption
   message -- .includes() would aggressively replace the entire part
   text and lose surrounding content.

2. interruptionSanitizer.test.ts: Updated the embedded-text test to
   expect false (not true) since strict matching rejects substrings.

282 tests + E2E from 3 CWDs + gemini CLI binary all clean.
@dylanyunlon

Copy link
Copy Markdown
Author

Round 7 done (2 high):

  1. interruptionSanitizer.ts:38 - isInterruptionPlaceholder now uses .trim() === strict exact match instead of .includes(). Prevents data loss when the model legitimately discusses or quotes the interruption message.

  2. interruptionSanitizer.test.ts:33 - Updated embedded-text test to expect false since strict matching rejects substrings.

Verified: 282 unit tests + E2E from 3 CWDs + real gemini CLI binary against mock API server.

@gemini-code-assist please re-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 implements session context poisoning prevention for interrupted turns (Issue #29264). It introduces a new utility module interruptionSanitizer that replaces dangerous raw interruption placeholders with a benign replacement (Continuing.). This sanitization is integrated across several core services, including GeminiChat history extraction, ChatCompressionService, and the history scrubbing pipeline (historyHardening). Additionally, nextSpeakerChecker is updated to short-circuit and prompt the model to speak next when an interruption is detected. Comprehensive unit and end-to-end tests are added to verify the changes. There are no review comments, so I have no feedback to provide.

Note: Security Review did not run due to the size of the PR.

@gemini-cli

gemini-cli Bot commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Hi there! Thank you for your interest in contributing to Gemini CLI.

To ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.

This PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@gemini-cli

gemini-cli Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

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 priority/p2 Important but can be addressed in a future release. size/xl An extra large PR status/pr-nudge-sent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(agent): prevent session context poisoning and infinite loops on interrupted turns

1 participant