Skip to content

Commit 4049599

Browse files
jacob314yashodipmore
authored andcommitted
robustness(core): static checks to validate history is immutable (google-gemini#21228)
1 parent dbf8955 commit 4049599

File tree

13 files changed

+34
-31
lines changed

13 files changed

+34
-31
lines changed

packages/cli/src/ui/utils/historyExportUtils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import type { Content } from '@google/genai';
1111
/**
1212
* Serializes chat history to a Markdown string.
1313
*/
14-
export function serializeHistoryToMarkdown(history: Content[]): string {
14+
export function serializeHistoryToMarkdown(
15+
history: readonly Content[],
16+
): string {
1517
return history
1618
.map((item) => {
1719
const text =
@@ -49,7 +51,7 @@ export function serializeHistoryToMarkdown(history: Content[]): string {
4951
* Options for exporting chat history.
5052
*/
5153
export interface ExportHistoryOptions {
52-
history: Content[];
54+
history: readonly Content[];
5355
filePath: string;
5456
}
5557

packages/core/src/commands/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface MessageActionReturn {
3131
export interface LoadHistoryActionReturn<HistoryType = unknown> {
3232
type: 'load_history';
3333
history: HistoryType;
34-
clientHistory: Content[]; // The history for the generative client
34+
clientHistory: readonly Content[]; // The history for the generative client
3535
}
3636

3737
/**

packages/core/src/core/client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,15 @@ export class GeminiClient {
255255
return this.chat !== undefined;
256256
}
257257

258-
getHistory(): Content[] {
258+
getHistory(): readonly Content[] {
259259
return this.getChat().getHistory();
260260
}
261261

262262
stripThoughtsFromHistory() {
263263
this.getChat().stripThoughtsFromHistory();
264264
}
265265

266-
setHistory(history: Content[]) {
266+
setHistory(history: readonly Content[]) {
267267
this.getChat().setHistory(history);
268268
this.updateTelemetryTokenCount();
269269
this.forceFullIdeContext = true;
@@ -1171,7 +1171,7 @@ export class GeminiClient {
11711171
/**
11721172
* Masks bulky tool outputs to save context window space.
11731173
*/
1174-
private async tryMaskToolOutputs(history: Content[]): Promise<void> {
1174+
private async tryMaskToolOutputs(history: readonly Content[]): Promise<void> {
11751175
if (!this.config.getToolOutputMaskingEnabled()) {
11761176
return;
11771177
}

packages/core/src/core/geminiChat.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ export class GeminiChat {
470470

471471
private async makeApiCallAndProcessStream(
472472
modelConfigKey: ModelConfigKey,
473-
requestContents: Content[],
473+
requestContents: readonly Content[],
474474
prompt_id: string,
475475
abortSignal: AbortSignal,
476476
role: LlmRole,
@@ -489,7 +489,7 @@ export class GeminiChat {
489489
let currentGenerateContentConfig: GenerateContentConfig =
490490
newAvailabilityConfig;
491491
let lastConfig: GenerateContentConfig = currentGenerateContentConfig;
492-
let lastContentsToUse: Content[] = requestContents;
492+
let lastContentsToUse: Content[] = [...requestContents];
493493

494494
const getAvailabilityContext = createAvailabilityContextProvider(
495495
this.config,
@@ -528,9 +528,9 @@ export class GeminiChat {
528528
abortSignal,
529529
};
530530

531-
let contentsToUse = supportsModernFeatures(modelToUse)
532-
? contentsForPreviewModel
533-
: requestContents;
531+
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
532+
? [...contentsForPreviewModel]
533+
: [...requestContents];
534534

535535
const hookSystem = this.config.getHookSystem();
536536
if (hookSystem) {
@@ -687,16 +687,10 @@ export class GeminiChat {
687687
* @return History contents alternating between user and model for the entire
688688
* chat session.
689689
*/
690-
getHistory(curated: boolean = false): Content[] {
690+
getHistory(curated: boolean = false): readonly Content[] {
691691
const history = curated
692692
? extractCuratedHistory(this.history)
693693
: this.history;
694-
// Return a shallow copy of the array to prevent callers from mutating
695-
// the internal history array (push/pop/splice). Content objects are
696-
// shared references — callers MUST NOT mutate them in place.
697-
// This replaces a prior structuredClone() which deep-copied the entire
698-
// conversation on every call, causing O(n) memory pressure per turn
699-
// that compounded into OOM crashes in long-running sessions.
700694
return [...history];
701695
}
702696

@@ -714,8 +708,8 @@ export class GeminiChat {
714708
this.history.push(content);
715709
}
716710

717-
setHistory(history: Content[]): void {
718-
this.history = history;
711+
setHistory(history: readonly Content[]): void {
712+
this.history = [...history];
719713
this.lastPromptTokenCount = estimateTokenCountSync(
720714
this.history.flatMap((c) => c.parts || []),
721715
);
@@ -742,7 +736,9 @@ export class GeminiChat {
742736
// To ensure our requests validate, the first function call in every model
743737
// turn within the active loop must have a `thoughtSignature` property.
744738
// If we do not do this, we will get back 400 errors from the API.
745-
ensureActiveLoopHasThoughtSignatures(requestContents: Content[]): Content[] {
739+
ensureActiveLoopHasThoughtSignatures(
740+
requestContents: readonly Content[],
741+
): readonly Content[] {
746742
// First, find the start of the active loop by finding the last user turn
747743
// with a text message, i.e. that is not a function response.
748744
let activeLoopStartIndex = -1;

packages/core/src/core/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface LogEntry {
2727
}
2828

2929
export interface Checkpoint {
30-
history: Content[];
30+
history: readonly Content[];
3131
authType?: AuthType;
3232
}
3333

packages/core/src/routing/routingStrategy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface RoutingDecision {
3131
*/
3232
export interface RoutingContext {
3333
/** The full history of the conversation. */
34-
history: Content[];
34+
history: readonly Content[];
3535
/** The immediate request parts to be processed. */
3636
request: PartListUnion;
3737
/** An abort signal to cancel an LLM call during routing. */

packages/core/src/safety/context-builder.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ export class ContextBuilder {
7474
}
7575

7676
// Helper to convert Google GenAI Content[] to Safety Protocol ConversationTurn[]
77-
private convertHistoryToTurns(history: Content[]): ConversationTurn[] {
77+
private convertHistoryToTurns(
78+
history: readonly Content[],
79+
): ConversationTurn[] {
7880
const turns: ConversationTurn[] = [];
7981
let currentUserRequest: { text: string } | undefined;
8082

packages/core/src/services/chatCompressionService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export function modelStringToModelConfigAlias(model: string): string {
130130
* contain massive tool outputs (like large grep results or logs).
131131
*/
132132
async function truncateHistoryToBudget(
133-
history: Content[],
133+
history: readonly Content[],
134134
config: Config,
135135
): Promise<Content[]> {
136136
let functionResponseTokenCounter = 0;

packages/core/src/services/chatRecordingService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ export class ChatRecordingService {
664664
* Updates the conversation history based on the provided API Content array.
665665
* This is used to persist changes made to the history (like masking) back to disk.
666666
*/
667-
updateMessagesFromHistory(history: Content[]): void {
667+
updateMessagesFromHistory(history: readonly Content[]): void {
668668
if (!this.conversationFile) return;
669669

670670
try {

packages/core/src/services/toolOutputMaskingService.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const EXEMPT_TOOLS = new Set([
4343
]);
4444

4545
export interface MaskingResult {
46-
newHistory: Content[];
46+
newHistory: readonly Content[];
4747
maskedCount: number;
4848
tokensSaved: number;
4949
}
@@ -67,7 +67,10 @@ export interface MaskingResult {
6767
* are preserved until they collectively reach the threshold.
6868
*/
6969
export class ToolOutputMaskingService {
70-
async mask(history: Content[], config: Config): Promise<MaskingResult> {
70+
async mask(
71+
history: readonly Content[],
72+
config: Config,
73+
): Promise<MaskingResult> {
7174
const maskingConfig = await config.getToolOutputMaskingConfig();
7275
if (!maskingConfig.enabled || history.length === 0) {
7376
return { newHistory: history, maskedCount: 0, tokensSaved: 0 };

0 commit comments

Comments
 (0)