From 4c9f9bb3e27071c2145186e449408c86e5cf6bd2 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Mon, 9 Mar 2026 11:26:03 -0700 Subject: [PATCH] robustness(core): static checks to validate history is immutable (#21228) --- .../cli/src/ui/utils/historyExportUtils.ts | 6 +++-- packages/core/src/commands/types.ts | 2 +- packages/core/src/core/client.ts | 6 ++--- packages/core/src/core/geminiChat.ts | 26 ++++++++----------- packages/core/src/core/logger.ts | 2 +- packages/core/src/routing/routingStrategy.ts | 2 +- packages/core/src/safety/context-builder.ts | 4 ++- .../src/services/chatCompressionService.ts | 2 +- .../core/src/services/chatRecordingService.ts | 2 +- .../src/services/toolOutputMaskingService.ts | 7 +++-- packages/core/src/utils/checkpointUtils.ts | 2 +- packages/sdk/src/session.ts | 2 +- packages/sdk/src/types.ts | 2 +- 13 files changed, 34 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/ui/utils/historyExportUtils.ts b/packages/cli/src/ui/utils/historyExportUtils.ts index 85a53dd330..325c880b2b 100644 --- a/packages/cli/src/ui/utils/historyExportUtils.ts +++ b/packages/cli/src/ui/utils/historyExportUtils.ts @@ -11,7 +11,9 @@ import type { Content } from '@google/genai'; /** * Serializes chat history to a Markdown string. */ -export function serializeHistoryToMarkdown(history: Content[]): string { +export function serializeHistoryToMarkdown( + history: readonly Content[], +): string { return history .map((item) => { const text = @@ -49,7 +51,7 @@ export function serializeHistoryToMarkdown(history: Content[]): string { * Options for exporting chat history. */ export interface ExportHistoryOptions { - history: Content[]; + history: readonly Content[]; filePath: string; } diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index 31491a27be..d9cc7a24e9 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -31,7 +31,7 @@ export interface MessageActionReturn { export interface LoadHistoryActionReturn { type: 'load_history'; history: HistoryType; - clientHistory: Content[]; // The history for the generative client + clientHistory: readonly Content[]; // The history for the generative client } /** diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index db6c5fb574..bbef15d491 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -255,7 +255,7 @@ export class GeminiClient { return this.chat !== undefined; } - getHistory(): Content[] { + getHistory(): readonly Content[] { return this.getChat().getHistory(); } @@ -263,7 +263,7 @@ export class GeminiClient { this.getChat().stripThoughtsFromHistory(); } - setHistory(history: Content[]) { + setHistory(history: readonly Content[]) { this.getChat().setHistory(history); this.updateTelemetryTokenCount(); this.forceFullIdeContext = true; @@ -1171,7 +1171,7 @@ export class GeminiClient { /** * Masks bulky tool outputs to save context window space. */ - private async tryMaskToolOutputs(history: Content[]): Promise { + private async tryMaskToolOutputs(history: readonly Content[]): Promise { if (!this.config.getToolOutputMaskingEnabled()) { return; } diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index ae5f46db37..1c0f1a5685 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -470,7 +470,7 @@ export class GeminiChat { private async makeApiCallAndProcessStream( modelConfigKey: ModelConfigKey, - requestContents: Content[], + requestContents: readonly Content[], prompt_id: string, abortSignal: AbortSignal, role: LlmRole, @@ -489,7 +489,7 @@ export class GeminiChat { let currentGenerateContentConfig: GenerateContentConfig = newAvailabilityConfig; let lastConfig: GenerateContentConfig = currentGenerateContentConfig; - let lastContentsToUse: Content[] = requestContents; + let lastContentsToUse: Content[] = [...requestContents]; const getAvailabilityContext = createAvailabilityContextProvider( this.config, @@ -528,9 +528,9 @@ export class GeminiChat { abortSignal, }; - let contentsToUse = supportsModernFeatures(modelToUse) - ? contentsForPreviewModel - : requestContents; + let contentsToUse: Content[] = supportsModernFeatures(modelToUse) + ? [...contentsForPreviewModel] + : [...requestContents]; const hookSystem = this.config.getHookSystem(); if (hookSystem) { @@ -687,16 +687,10 @@ export class GeminiChat { * @return History contents alternating between user and model for the entire * chat session. */ - getHistory(curated: boolean = false): Content[] { + getHistory(curated: boolean = false): readonly Content[] { const history = curated ? extractCuratedHistory(this.history) : this.history; - // Return a shallow copy of the array to prevent callers from mutating - // the internal history array (push/pop/splice). Content objects are - // shared references — callers MUST NOT mutate them in place. - // This replaces a prior structuredClone() which deep-copied the entire - // conversation on every call, causing O(n) memory pressure per turn - // that compounded into OOM crashes in long-running sessions. return [...history]; } @@ -714,8 +708,8 @@ export class GeminiChat { this.history.push(content); } - setHistory(history: Content[]): void { - this.history = history; + setHistory(history: readonly Content[]): void { + this.history = [...history]; this.lastPromptTokenCount = estimateTokenCountSync( this.history.flatMap((c) => c.parts || []), ); @@ -742,7 +736,9 @@ export class GeminiChat { // To ensure our requests validate, the first function call in every model // turn within the active loop must have a `thoughtSignature` property. // If we do not do this, we will get back 400 errors from the API. - ensureActiveLoopHasThoughtSignatures(requestContents: Content[]): Content[] { + ensureActiveLoopHasThoughtSignatures( + requestContents: readonly Content[], + ): readonly Content[] { // First, find the start of the active loop by finding the last user turn // with a text message, i.e. that is not a function response. let activeLoopStartIndex = -1; diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 362601f895..c75d4d7ffa 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -27,7 +27,7 @@ export interface LogEntry { } export interface Checkpoint { - history: Content[]; + history: readonly Content[]; authType?: AuthType; } diff --git a/packages/core/src/routing/routingStrategy.ts b/packages/core/src/routing/routingStrategy.ts index a2f9448989..a2aaf8c14f 100644 --- a/packages/core/src/routing/routingStrategy.ts +++ b/packages/core/src/routing/routingStrategy.ts @@ -31,7 +31,7 @@ export interface RoutingDecision { */ export interface RoutingContext { /** The full history of the conversation. */ - history: Content[]; + history: readonly Content[]; /** The immediate request parts to be processed. */ request: PartListUnion; /** An abort signal to cancel an LLM call during routing. */ diff --git a/packages/core/src/safety/context-builder.ts b/packages/core/src/safety/context-builder.ts index c7b33f5e2f..f73cae6e42 100644 --- a/packages/core/src/safety/context-builder.ts +++ b/packages/core/src/safety/context-builder.ts @@ -74,7 +74,9 @@ export class ContextBuilder { } // Helper to convert Google GenAI Content[] to Safety Protocol ConversationTurn[] - private convertHistoryToTurns(history: Content[]): ConversationTurn[] { + private convertHistoryToTurns( + history: readonly Content[], + ): ConversationTurn[] { const turns: ConversationTurn[] = []; let currentUserRequest: { text: string } | undefined; diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 8dceb18f4b..a1f9c12f2c 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -130,7 +130,7 @@ export function modelStringToModelConfigAlias(model: string): string { * contain massive tool outputs (like large grep results or logs). */ async function truncateHistoryToBudget( - history: Content[], + history: readonly Content[], config: Config, ): Promise { let functionResponseTokenCounter = 0; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index cd8d1e53c1..6dd24fd42a 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -664,7 +664,7 @@ export class ChatRecordingService { * Updates the conversation history based on the provided API Content array. * This is used to persist changes made to the history (like masking) back to disk. */ - updateMessagesFromHistory(history: Content[]): void { + updateMessagesFromHistory(history: readonly Content[]): void { if (!this.conversationFile) return; try { diff --git a/packages/core/src/services/toolOutputMaskingService.ts b/packages/core/src/services/toolOutputMaskingService.ts index 8a7ae0090d..9d5a3fb2c2 100644 --- a/packages/core/src/services/toolOutputMaskingService.ts +++ b/packages/core/src/services/toolOutputMaskingService.ts @@ -43,7 +43,7 @@ const EXEMPT_TOOLS = new Set([ ]); export interface MaskingResult { - newHistory: Content[]; + newHistory: readonly Content[]; maskedCount: number; tokensSaved: number; } @@ -67,7 +67,10 @@ export interface MaskingResult { * are preserved until they collectively reach the threshold. */ export class ToolOutputMaskingService { - async mask(history: Content[], config: Config): Promise { + async mask( + history: readonly Content[], + config: Config, + ): Promise { const maskingConfig = await config.getToolOutputMaskingConfig(); if (!maskingConfig.enabled || history.length === 0) { return { newHistory: history, maskedCount: 0, tokensSaved: 0 }; diff --git a/packages/core/src/utils/checkpointUtils.ts b/packages/core/src/utils/checkpointUtils.ts index 2252fdf70b..4e1989efbd 100644 --- a/packages/core/src/utils/checkpointUtils.ts +++ b/packages/core/src/utils/checkpointUtils.ts @@ -14,7 +14,7 @@ import type { ToolCallRequestInfo } from '../scheduler/types.js'; export interface ToolCallData { history?: HistoryType; - clientHistory?: Content[]; + clientHistory?: readonly Content[]; commitHash?: string; toolCall: { name: string; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 8332ef29d0..59ed857937 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -226,7 +226,7 @@ export class GeminiCliSession { break; } - const transcript: Content[] = client.getHistory(); + const transcript: readonly Content[] = client.getHistory(); const context: SessionContext = { sessionId, transcript, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 9b6bf7093a..f6ba1cd8a5 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -51,7 +51,7 @@ export interface AgentShell { export interface SessionContext { sessionId: string; - transcript: Content[]; + transcript: readonly Content[]; cwd: string; timestamp: string; fs: AgentFilesystem;