From bc234de2ef08e637e8c4fc180040885029d811d5 Mon Sep 17 00:00:00 2001 From: ameliesther Date: Mon, 15 Jun 2026 20:06:15 +0000 Subject: [PATCH 1/9] fix(core): filter out thought parts from request history to prevent leakage --- package-lock.json | 34 +------- .../core/src/utils/historyHardening.test.ts | 85 +++++++++++++++++++ packages/core/src/utils/historyHardening.ts | 27 +++++- 3 files changed, 114 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index bde6259b114..5ed8049e843 100644 --- a/package-lock.json +++ b/package-lock.json @@ -449,8 +449,7 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", @@ -1536,7 +1535,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -2244,7 +2242,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2425,7 +2422,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2475,7 +2471,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2826,7 +2821,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2861,7 +2855,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" @@ -2917,7 +2910,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", @@ -4170,7 +4162,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4444,7 +4435,6 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -5220,7 +5210,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7332,8 +7321,7 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7918,7 +7906,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8529,7 +8516,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9799,7 +9785,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10062,7 +10047,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz", "integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==", "license": "MIT", - "peer": true, "dependencies": { "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.3", @@ -13838,7 +13822,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13849,7 +13832,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16004,7 +15986,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16227,8 +16208,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16236,7 +16216,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16402,7 +16381,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16470,7 +16448,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -16890,7 +16867,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17461,7 +17437,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17474,7 +17449,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18128,7 +18102,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18652,7 +18625,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/core/src/utils/historyHardening.test.ts b/packages/core/src/utils/historyHardening.test.ts index face6671324..3e8c2ddccf4 100644 --- a/packages/core/src/utils/historyHardening.test.ts +++ b/packages/core/src/utils/historyHardening.test.ts @@ -375,4 +375,89 @@ describe('hardenHistory', () => { expect(hardened[0].content.parts![0]).not.toHaveProperty('extraProp'); expect(hardened[0].content.parts![0]).toHaveProperty('text', 'hello'); }); + + it('should completely filter out thought parts from the scrubbed history', () => { + const history: HistoryTurn[] = [ + { + id: '1', + content: { + role: 'user', + parts: [{ text: 'User prompt' }], + }, + }, + { + id: '2', + content: { + role: 'model', + parts: [ + { + text: 'Previous model thought...', + thought: true, + } as unknown as Part, + { text: 'Actual conversational text response' }, + ], + }, + }, + { + id: '3', + content: { + role: 'user', + parts: [{ text: 'User follow-up prompt' }], + }, + }, + ]; + + const hardened = hardenHistory(history); + // Model turn (Turn 2, index 1 in hardened) should only contain the actual conversational text part + const modelTurn = hardened[1]; + expect(modelTurn.content.parts).toHaveLength(1); + expect(modelTurn.content.parts![0]).toHaveProperty( + 'text', + 'Actual conversational text response', + ); + expect(modelTurn.content.parts![0]).not.toHaveProperty('thought'); + }); + + it('should remove the entire turn if it only contained thought parts and is now empty', () => { + const history: HistoryTurn[] = [ + { + id: '1', + content: { + role: 'user', + parts: [{ text: 'User prompt' }], + }, + }, + { + id: '2', + content: { + role: 'model', + parts: [ + { + text: 'Model is just thinking internally...', + thought: true, + } as unknown as Part, + ], + }, + }, + { + id: '3', + content: { + role: 'user', + parts: [{ text: 'User follow-up prompt' }], + }, + }, + ]; + + const hardened = hardenHistory(history); + // After scrubbing, Turn 2 should have 0 parts. + // The history mapping filters out empty turns, so the total turns should coalesce and reduce to 1 coalesced user turn. + // Let's inspect the hardened array: + // User prompt (Turn 1) + User follow-up prompt (Turn 3) will be coalesced into 1 User turn. + expect(hardened).toHaveLength(1); + expect(hardened[0].content.role).toBe('user'); + expect(hardened[0].content.parts).toEqual([ + { text: 'User prompt' }, + { text: 'User follow-up prompt' }, + ]); + }); }); diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index e469b08e834..047c67ae9a1 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -44,8 +44,11 @@ export function hardenHistory( const sentinels = { ...DEFAULT_SENTINELS, ...options.sentinels }; + // Pass 0: Strip internal thoughts and remove empty turns + const processed = stripThoughts(history); + // Pass 1: Initial Coalesce & Empty Turn Removal - let coalesced = coalesce(history); + let coalesced = coalesce(processed); // Pass 2: Tool Pairing & Signatures (The semantic layer) coalesced = pairToolsAndEnforceSignatures(coalesced, sentinels); @@ -62,6 +65,28 @@ export function hardenHistory( return final; } +/** + * Removes parts that represent thoughts (where part.thought === true) + * and filters out turns that become empty as a result. + */ +function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { + return history + .map((turn) => { + if (!turn.content.parts) return turn; + const nonThoughtParts = turn.content.parts.filter( + (p) => !('thought' in p && p.thought === true), + ); + return { + id: turn.id, + content: { + ...turn.content, + parts: nonThoughtParts, + }, + }; + }) + .filter((turn) => turn.content.parts && turn.content.parts.length > 0); +} + /** * Combines adjacent turns with the same role and removes empty turns. */ From 92dc783ea262a949d79807580b1fb6d290ecd7e6 Mon Sep 17 00:00:00 2001 From: ameliesther Date: Tue, 16 Jun 2026 23:08:00 +0000 Subject: [PATCH 2/9] fix(core): strip thoughts from scrubbed history turns and add unit tests --- packages/core/src/core/geminiChat.test.ts | 29 +++++++++++++++++++++ packages/core/src/utils/historyHardening.ts | 16 ++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index b6f9ef98868..22de6c2a050 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2253,6 +2253,35 @@ describe('GeminiChat', () => { }); }); + describe('thought leakage in getHistoryTurns', () => { + it('should completely filter out thought parts from getHistoryTurns when context management is enabled', () => { + vi.mocked(mockConfig.isContextManagementEnabled).mockReturnValue(true); + + chat.setHistory([ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + { + role: 'model', + parts: [ + { text: 'internal monologue', thought: true }, + { text: 'actual conversational response' }, + ], + }, + ]); + + const turns = chat.getHistoryTurns(true); + + expect(turns).toHaveLength(2); + const modelTurn = turns[1]; + expect(modelTurn.content.parts).toHaveLength(1); + expect(modelTurn.content.parts![0]).toEqual({ + text: 'actual conversational response', + }); + }); + }); + describe('ensureActiveLoopHasThoughtSignatures', () => { it('should add thoughtSignature to the first functionCall in each model turn of the active loop', () => { const chat = new GeminiChat(mockConfig, '', [], []); diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index 047c67ae9a1..7479feef654 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -369,20 +369,26 @@ function enforceRoleConstraints( * This ensures compatibility with strict APIs (like Vertex AI) that reject unknown fields. */ export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] { - return history.map((turn) => ({ + const scrubbed = history.map((turn) => ({ id: turn.id, content: scrubContents([turn.content])[0], })); + return coalesce(scrubbed); } /** * Deep-scrubs an array of Content objects to remove non-standard properties. */ export function scrubContents(contents: Content[]): Content[] { - return contents.map((content) => ({ - role: content.role, - parts: (content.parts || []).map((p) => scrubPart(p)), - })); + return contents.map((content) => { + const nonThoughtParts = (content.parts || []).filter( + (p) => !('thought' in p && p.thought), + ); + return { + role: content.role, + parts: nonThoughtParts.map((p) => scrubPart(p)), + }; + }); } interface ThoughtPart extends Part { From 2d0b4e675e8e0054e5eaa477e1bb99b0f7c4864d Mon Sep 17 00:00:00 2001 From: amelidev Date: Tue, 16 Jun 2026 17:22:02 -0600 Subject: [PATCH 3/9] Update packages/core/src/utils/historyHardening.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/utils/historyHardening.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index 7479feef654..c72653785a0 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -74,7 +74,7 @@ function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { .map((turn) => { if (!turn.content.parts) return turn; const nonThoughtParts = turn.content.parts.filter( - (p) => !('thought' in p && p.thought === true), + (p) => !('thought' in p && p.thought), ); return { id: turn.id, From d4307ffc23d66174ee5ee8edc4865698360b909a Mon Sep 17 00:00:00 2001 From: amelidev Date: Tue, 16 Jun 2026 17:28:14 -0600 Subject: [PATCH 4/9] Update packages/core/src/utils/historyHardening.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/utils/historyHardening.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index c72653785a0..aa6175189cf 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -381,8 +381,8 @@ export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] { */ export function scrubContents(contents: Content[]): Content[] { return contents.map((content) => { - const nonThoughtParts = (content.parts || []).filter( - (p) => !('thought' in p && p.thought), + const nonThoughtParts = (content.parts ?? []).filter( + (p) => !(p && typeof p === 'object' && 'thought' in p && p.thought), ); return { role: content.role, From cef5800fd571d2b30e9ef0cae3f7ef6c1b0c018e Mon Sep 17 00:00:00 2001 From: amelidev Date: Tue, 16 Jun 2026 17:28:24 -0600 Subject: [PATCH 5/9] Update packages/core/src/utils/historyHardening.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/utils/historyHardening.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index aa6175189cf..0743170829b 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -74,7 +74,7 @@ function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { .map((turn) => { if (!turn.content.parts) return turn; const nonThoughtParts = turn.content.parts.filter( - (p) => !('thought' in p && p.thought), + (p) => !(p && typeof p === 'object' && 'thought' in p && p.thought), ); return { id: turn.id, From dffbb33c944f7a6e07c293e25de1712a743c6b0f Mon Sep 17 00:00:00 2001 From: ameliesther Date: Wed, 24 Jun 2026 05:38:59 +0000 Subject: [PATCH 6/9] refactor(core): address PR feedback on history hardening allocations and type safety - Cast synthetic model thought parts in geminiChat.test.ts using as unknown as Part for strict type safety and consistency. - Extract isInternalThought helper function with no type casting or ESLint suppression required. - Optimize stripThoughts using .some() to avoid unnecessary allocations on thought-free turns. - Remove redundant trailing filter in stripThoughts. - Consolidate multi-pass loop in scrubHistory into a single, high-performance pass that scrubs and coalesces adjacent turns immutably. - Ensure strict immutability in coalesce and scrubHistory by completely replacing the turn object instead of mutating in place. --- packages/core/src/core/geminiChat.test.ts | 3 +- packages/core/src/utils/historyHardening.ts | 94 +++++++++++++++------ 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 22de6c2a050..07fe800f3ba 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -10,6 +10,7 @@ import { ThinkingLevel, type Content, type GenerateContentResponse, + type Part, } from '@google/genai'; import type { ContentGenerator } from '../core/contentGenerator.js'; import { @@ -2265,7 +2266,7 @@ describe('GeminiChat', () => { { role: 'model', parts: [ - { text: 'internal monologue', thought: true }, + { text: 'internal monologue', thought: true } as unknown as Part, { text: 'actual conversational response' }, ], }, diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index 0743170829b..d96eb24bc0d 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -66,25 +66,33 @@ export function hardenHistory( } /** - * Removes parts that represent thoughts (where part.thought === true) - * and filters out turns that become empty as a result. + * Helper to check if a Part object represents an internal thought. + */ +function isInternalThought(part: Part): boolean { + return part.thought === true; +} + +/** + * Removes parts that represent thoughts (where part.thought === true). + * Empty turns resulting from thought removal are handled in subsequent coalescing passes. */ function stripThoughts(history: HistoryTurn[]): HistoryTurn[] { - return history - .map((turn) => { - if (!turn.content.parts) return turn; - const nonThoughtParts = turn.content.parts.filter( - (p) => !(p && typeof p === 'object' && 'thought' in p && p.thought), - ); - return { - id: turn.id, - content: { - ...turn.content, - parts: nonThoughtParts, - }, - }; - }) - .filter((turn) => turn.content.parts && turn.content.parts.length > 0); + return history.map((turn) => { + if (!turn.content.parts) return turn; + const hasThought = turn.content.parts.some(isInternalThought); + if (!hasThought) return turn; + + const nonThoughtParts = turn.content.parts.filter( + (p) => !isInternalThought(p), + ); + return { + id: turn.id, + content: { + ...turn.content, + parts: nonThoughtParts, + }, + }; + }); } /** @@ -95,12 +103,16 @@ function coalesce(history: HistoryTurn[]): HistoryTurn[] { for (const turn of history) { if (!turn.content.parts || turn.content.parts.length === 0) continue; - const last = result[result.length - 1]; + const lastIdx = result.length - 1; + const last = result[lastIdx]; if (last && last.content.role === turn.content.role) { - last.content.parts = [ - ...(last.content.parts || []), - ...(turn.content.parts || []), - ]; + result[lastIdx] = { + id: last.id, + content: { + ...last.content, + parts: [...(last.content.parts || []), ...(turn.content.parts || [])], + }, + }; } else { // Shallow clone the turn and content so we don't mutate the original history array structure result.push({ id: turn.id, content: { ...turn.content } }); @@ -369,11 +381,37 @@ function enforceRoleConstraints( * This ensures compatibility with strict APIs (like Vertex AI) that reject unknown fields. */ export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] { - const scrubbed = history.map((turn) => ({ - id: turn.id, - content: scrubContents([turn.content])[0], - })); - return coalesce(scrubbed); + const result: HistoryTurn[] = []; + for (const turn of history) { + const nonThoughtParts = (turn.content.parts ?? []).filter( + (p) => !isInternalThought(p), + ); + if (nonThoughtParts.length === 0) continue; // Skip turns that became empty + + const scrubbedParts = nonThoughtParts.map((p) => scrubPart(p)); + + const lastIdx = result.length - 1; + const last = result[lastIdx]; + if (last && last.content.role === turn.content.role) { + // Coalesce inline with strict immutability + result[lastIdx] = { + id: last.id, + content: { + ...last.content, + parts: [...(last.content.parts || []), ...scrubbedParts], + }, + }; + } else { + result.push({ + id: turn.id, + content: { + role: turn.content.role, + parts: scrubbedParts, + }, + }); + } + } + return result; } /** @@ -382,7 +420,7 @@ export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] { export function scrubContents(contents: Content[]): Content[] { return contents.map((content) => { const nonThoughtParts = (content.parts ?? []).filter( - (p) => !(p && typeof p === 'object' && 'thought' in p && p.thought), + (p) => !isInternalThought(p), ); return { role: content.role, From 26fc9e4dc880d85a51b0223489d3ce104a534503 Mon Sep 17 00:00:00 2001 From: amelidev Date: Thu, 25 Jun 2026 11:07:47 -0600 Subject: [PATCH 7/9] Update packages/core/src/utils/historyHardening.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/utils/historyHardening.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index d96eb24bc0d..d39fc388b37 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -69,7 +69,7 @@ export function hardenHistory( * Helper to check if a Part object represents an internal thought. */ function isInternalThought(part: Part): boolean { - return part.thought === true; + return !!part.thought; } /** From a868d97d4910b966b13da0086d355f0c21ef5ec8 Mon Sep 17 00:00:00 2001 From: ameliesther Date: Thu, 25 Jun 2026 21:03:24 +0000 Subject: [PATCH 8/9] fix(core): filter out empty content turns in scrubContents --- .../core/src/utils/historyHardening.test.ts | 104 +++++++++++++++++- packages/core/src/utils/historyHardening.ts | 20 ++-- 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/packages/core/src/utils/historyHardening.test.ts b/packages/core/src/utils/historyHardening.test.ts index 3e8c2ddccf4..12347f620c4 100644 --- a/packages/core/src/utils/historyHardening.test.ts +++ b/packages/core/src/utils/historyHardening.test.ts @@ -8,10 +8,12 @@ import { describe, it, expect } from 'vitest'; import { hardenHistory, SYNTHETIC_THOUGHT_SIGNATURE, + scrubContents, + scrubHistory, } from './historyHardening.js'; import type { HistoryTurn } from '../core/agentChatHistory.js'; import { deriveStableId } from './cryptoUtils.js'; -import type { Part } from '@google/genai'; +import type { Part, Content } from '@google/genai'; describe('hardenHistory', () => { it('should return an empty array if input is empty', () => { @@ -461,3 +463,103 @@ describe('hardenHistory', () => { ]); }); }); + +describe('scrubContents', () => { + it('should scrub non-standard fields from parts', () => { + const contents: Content[] = [ + { + role: 'user', + parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part], + }, + ]; + const scrubbed = scrubContents(contents); + expect(scrubbed).toEqual([ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + ]); + }); + + it('should filter out internal thought parts', () => { + const contents: Content[] = [ + { + role: 'model', + parts: [ + { text: 'thought', thought: true } as unknown as Part, + { text: 'response' }, + ], + }, + ]; + const scrubbed = scrubContents(contents); + expect(scrubbed).toEqual([ + { + role: 'model', + parts: [{ text: 'response' }], + }, + ]); + }); + + it('should completely filter out Content objects that have no parts left after thought scrubbing', () => { + const contents: Content[] = [ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + { + role: 'model', + parts: [{ text: 'thought', thought: true } as unknown as Part], + }, + { + role: 'user', + parts: [{ text: 'How are you?' }], + }, + ]; + const scrubbed = scrubContents(contents); + expect(scrubbed).toEqual([ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + { + role: 'user', + parts: [{ text: 'How are you?' }], + }, + ]); + }); +}); + +describe('scrubHistory', () => { + it('should scrub non-standard fields and filter empty turns in history', () => { + const history: HistoryTurn[] = [ + { + id: '1', + content: { + role: 'user', + parts: [{ text: 'Hello', customField: 'ignored' } as unknown as Part], + }, + }, + { + id: '2', + content: { + role: 'model', + parts: [{ text: 'thought', thought: true } as unknown as Part], + }, + }, + { + id: '3', + content: { + role: 'user', + parts: [{ text: 'World' }], + }, + }, + ]; + + const scrubbed = scrubHistory(history); + expect(scrubbed.length).toBe(1); // Since user turns are coalesced (Turn 1 + Turn 3) and Turn 2 is removed because it has 0 parts + expect(scrubbed[0].content.parts).toEqual([ + { text: 'Hello' }, + { text: 'World' }, + ]); + }); +}); diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index d39fc388b37..b187f559495 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -418,15 +418,17 @@ export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] { * Deep-scrubs an array of Content objects to remove non-standard properties. */ export function scrubContents(contents: Content[]): Content[] { - return contents.map((content) => { - const nonThoughtParts = (content.parts ?? []).filter( - (p) => !isInternalThought(p), - ); - return { - role: content.role, - parts: nonThoughtParts.map((p) => scrubPart(p)), - }; - }); + return contents + .map((content) => { + const nonThoughtParts = (content.parts ?? []).filter( + (p) => !isInternalThought(p), + ); + return { + role: content.role, + parts: nonThoughtParts.map((p) => scrubPart(p)), + }; + }) + .filter((content) => content.parts.length > 0); } interface ThoughtPart extends Part { From 0fa93d8dfc4e42c909080f3d66f85e068b828a18 Mon Sep 17 00:00:00 2001 From: ameliesther Date: Thu, 25 Jun 2026 23:36:01 +0000 Subject: [PATCH 9/9] refactor(core): cast Part to ThoughtPart in isInternalThought for strict type safety --- packages/core/src/utils/historyHardening.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index b187f559495..9a58b230beb 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -69,7 +69,7 @@ export function hardenHistory( * Helper to check if a Part object represents an internal thought. */ function isInternalThought(part: Part): boolean { - return !!part.thought; + return !!(part as ThoughtPart).thought; } /** @@ -432,6 +432,7 @@ export function scrubContents(contents: Content[]): Content[] { } interface ThoughtPart extends Part { + thought?: boolean; thoughtSignature?: string; }