From acaaeaa9832bdd3fad6b1faaa9ef530391ebc4f6 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 14:25:59 +0000 Subject: [PATCH 1/7] fix(cli): honour the declared enableCacheSharing default in both suggestion gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enableCacheSharing declares default: true in the settings schema, but mergeSettings never applies schema defaults and both runtime gates compared with === true (AppContainer and the ACP Session path), so the cache-aware forked suggestion query was dead code unless the user explicitly set the flag — on prefix-caching servers the default side query then defeats the main session's prefix cache every turn (#9230). Flip both gates to !== false, the exact treatment the adjacent enableFollowupSuggestions gate already uses for the same quirk. Pin the unset=>true and explicit-false=>false behavior in Session.test.ts. --- .../acp-integration/session/Session.test.ts | 29 +++++++++++++++++-- .../src/acp-integration/session/Session.ts | 7 ++++- packages/cli/src/ui/AppContainer.tsx | 8 ++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index ac07952d01f..d51b4979228 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -33357,12 +33357,37 @@ describe('Session', () => { }); // The generator received an AbortSignal so the daemon can cancel - // mid-flight if the next prompt arrives first. + // mid-flight if the next prompt arrives first. `merged.ui` above leaves + // `enableCacheSharing` UNSET, so the gate must honour the schema's + // declared `default: true` — `mergeSettings` never applies schema + // defaults, and gating on `=== true` turned the cache-aware fork into + // dead code unless the user explicitly set the flag (#9230). expect(generateMock).toHaveBeenCalledWith( mockConfig, expect.any(Array), expect.any(AbortSignal), - expect.objectContaining({ enableCacheSharing: expect.any(Boolean) }), + expect.objectContaining({ enableCacheSharing: true }), + ); + }); + + it('forwards an explicit enableCacheSharing=false opt-out', async () => { + (mockSettings as unknown as { merged: { ui: unknown } }).merged.ui = { + enableFollowupSuggestions: true, + enableCacheSharing: false, + }; + generateMock.mockResolvedValue({ suggestion: null }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => expect(generateMock).toHaveBeenCalled()); + expect(generateMock).toHaveBeenCalledWith( + mockConfig, + expect.any(Array), + expect.any(AbortSignal), + expect.objectContaining({ enableCacheSharing: false }), ); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 971762fbb12..4afef4087ba 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3945,8 +3945,13 @@ export class Session implements SessionContext { conversationHistory, ac.signal, { + // On by default: the schema declares `default: true`, but + // `mergeSettings` doesn't apply schema defaults, so an unset value + // is `undefined` and a `=== true` gate left the cache-aware fork + // as dead code unless the flag was explicitly set (#9230). Mirrors + // AppContainer — only an explicit `false` opts out. enableCacheSharing: - this.settings.merged.ui?.enableCacheSharing === true, + this.settings.merged.ui?.enableCacheSharing !== false, }, ); if (ac.signal.aborted) return; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0772117c824..1a69b6561f5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -3153,7 +3153,13 @@ export const AppContainer = (props: AppContainerProps) => { // causes transient heap peaks that trigger OOM (#4624). const conversationHistory = geminiClient.getHistoryTail(40, true); generatePromptSuggestion(config, conversationHistory, ac.signal, { - enableCacheSharing: settings.merged.ui?.enableCacheSharing === true, + // On by default: the schema declares `default: true`, but + // `mergeSettings` doesn't apply schema defaults, so an unset value is + // `undefined` and a `=== true` gate left the cache-aware fork as dead + // code unless the flag was explicitly set (#9230). Same treatment as + // `enableFollowupSuggestions` above — only an explicit `false` opts + // out. + enableCacheSharing: settings.merged.ui?.enableCacheSharing !== false, }) .then((result) => { if (ac.signal.aborted) return; From 42f09f14c789bd960d20c104d59d5239c7e38f57 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 00:04:02 +0800 Subject: [PATCH 2/7] fix(core): propagate prompt suggestion abort signal --- .../core/src/followup/suggestionGenerator.test.ts | 12 +++++------- packages/core/src/followup/suggestionGenerator.ts | 1 + 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/core/src/followup/suggestionGenerator.test.ts b/packages/core/src/followup/suggestionGenerator.test.ts index ebd176ac1f7..573c536a86c 100644 --- a/packages/core/src/followup/suggestionGenerator.test.ts +++ b/packages/core/src/followup/suggestionGenerator.test.ts @@ -59,15 +59,13 @@ describe('generatePromptSuggestion', () => { getModel: vi.fn(() => 'main-model'), } as unknown as Config; - await generatePromptSuggestion( - config, - conversationHistory, - new AbortController().signal, - { enableCacheSharing: true }, - ); + const signal = new AbortController().signal; + await generatePromptSuggestion(config, conversationHistory, signal, { + enableCacheSharing: true, + }); expect(mockRunForkedAgent).toHaveBeenCalledWith( - expect.objectContaining({ model: 'main-model' }), + expect.objectContaining({ model: 'main-model', abortSignal: signal }), ); }); diff --git a/packages/core/src/followup/suggestionGenerator.ts b/packages/core/src/followup/suggestionGenerator.ts index 3824bce8c29..bf64fff16e4 100644 --- a/packages/core/src/followup/suggestionGenerator.ts +++ b/packages/core/src/followup/suggestionGenerator.ts @@ -157,6 +157,7 @@ async function generateViaForkedQuery( jsonSchema: SUGGESTION_SCHEMA, model, preserveTools: model === cacheSafeParams.model, + abortSignal, }); if (result.jsonResult) { From 92241be2a4d41454b5424ada2344f393795751e8 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 00:05:14 +0800 Subject: [PATCH 3/7] fix(cli): avoid cloning full history for suggestions --- packages/cli/src/acp-integration/session/Session.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 4afef4087ba..f4b6b773693 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3929,16 +3929,14 @@ export class Session implements SessionContext { void (async () => { try { - const fullHistory = chat.getHistory(true); - const lastEntry = fullHistory[fullHistory.length - 1]; + const conversationHistory = chat.getHistoryTail(40, true); + const lastEntry = conversationHistory[conversationHistory.length - 1]; if (!lastEntry || lastEntry.role !== 'model') { debugLogger.debug( 'Skipping followup suggestion: last history entry is not model', ); return; } - const conversationHistory = - fullHistory.length > 40 ? fullHistory.slice(-40) : fullHistory; const r = await generatePromptSuggestion( this.config, From e461db40a15c9ac02689f75f910c2569bd1c0a78 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 15 Aug 2026 17:41:28 +0000 Subject: [PATCH 4/7] fix(cli): scope the cache-sharing suggestion slot per session (#9233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-1 (Critical): the cache-aware follow-up-suggestion fork read the process-global, session-unbound currentCacheSafeParams slot, so in a multi-session daemon one session's suggestion could be built from another session's transcript + systemInstruction — a cross-session content leak this PR's default-on gate newly enabled. Record the owning session id in saveCacheSafeParams and, in generatePromptSuggestion, fall back to the session-safe base-LLM path when the slot's sessionId does not match config.getSessionId(). generateViaForkedQuery now receives the session-checked params instead of re-reading the slot (closes the check-then-read race). R2-2: corrected the #maybeEmitFollowupSuggestion JSDoc to reference getHistoryTail(40, true) instead of the removed getHistory(true).slice(-40). R2-3: pinned getHistoryTail(40, true) in the daemon followup test so a revert to the full structuredClone shape (#4624) or a dropped curated argument cannot pass silently. Tests: 4 cache-mode tests updated to carry a matching sessionId; new cross-session regression test asserts the fork is NOT used and the base-LLM fallback runs when the slot belongs to another session. --- .../acp-integration/session/Session.test.ts | 7 ++ .../src/acp-integration/session/Session.ts | 2 +- packages/core/src/core/client.ts | 1 + .../src/followup/suggestionGenerator.test.ts | 64 +++++++++++++++++-- .../core/src/followup/suggestionGenerator.ts | 28 ++++++-- packages/core/src/utils/forkedAgent.ts | 11 ++++ 6 files changed, 102 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index d51b4979228..db5910a3deb 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -33338,6 +33338,7 @@ describe('Session', () => { it('fires prompt-suggestion extNotification after end_turn when enabled', async () => { generateMock.mockResolvedValue({ suggestion: 'Run the tests next?' }); + vi.mocked(mockChat.getHistoryTail).mockClear(); await session.prompt({ sessionId: 'test-session-id', @@ -33356,6 +33357,12 @@ describe('Session', () => { ); }); + // Pin the curated-history tail: a revert to `chat.getHistory(true)` + // (full structuredClone per end_turn, the #4624 heap-peak shape) or a + // dropped curated argument (`getHistoryTail(40)` defaults + // curated=false) must not pass silently (#9233). + expect(vi.mocked(mockChat.getHistoryTail)).toHaveBeenCalledWith(40, true); + // The generator received an AbortSignal so the daemon can cancel // mid-flight if the next prompt arrives first. `merged.ui` above leaves // `enableCacheSharing` UNSET, so the gate must honour the schema's diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f4b6b773693..08ac81d32b9 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -3889,7 +3889,7 @@ export class Session implements SessionContext { * `qwen/notify/session/prompt-suggestion` extNotification. Mirrors * the CLI's `AppContainer.tsx` integration: same `generatePromptSuggestion` * call, same `enableCacheSharing` flag forwarding, same curated - * history slice (`getHistory(true).slice(-40)`). + * history tail (`getHistoryTail(40, true)`). * * Differences from the CLI: * - Triggers only on `stopReason === 'end_turn'` (the daemon diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 60ffda30a8b..4d4582778cc 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -3871,6 +3871,7 @@ export class GeminiClient { chat.getGenerationConfig(), cachedHistory, this.config.getModel(), + this.config.getSessionId(), ); } catch { // Best-effort — don't block the main flow diff --git a/packages/core/src/followup/suggestionGenerator.test.ts b/packages/core/src/followup/suggestionGenerator.test.ts index 573c536a86c..ccfd7b86c4f 100644 --- a/packages/core/src/followup/suggestionGenerator.test.ts +++ b/packages/core/src/followup/suggestionGenerator.test.ts @@ -8,10 +8,20 @@ import type { Content } from '@google/genai'; import { beforeEach, describe, it, expect, vi } from 'vitest'; import type { Config } from '../config/config.js'; -const { mockGetCacheSafeParams, mockRunForkedAgent } = vi.hoisted(() => ({ - mockGetCacheSafeParams: vi.fn(), - mockRunForkedAgent: vi.fn(), -})); +const { mockGetCacheSafeParams, mockRunForkedAgent, mockRunSideQuery } = + vi.hoisted(() => ({ + mockGetCacheSafeParams: vi.fn(), + mockRunForkedAgent: vi.fn(), + mockRunSideQuery: vi.fn(), + })); + +vi.mock('../utils/sideQuery.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runSideQuery: mockRunSideQuery, + }; +}); vi.mock('../utils/forkedAgent.js', async (importOriginal) => { const actual = @@ -40,6 +50,7 @@ describe('generatePromptSuggestion', () => { beforeEach(() => { mockGetCacheSafeParams.mockReset(); mockRunForkedAgent.mockReset(); + mockRunSideQuery.mockReset(); }); it('passes cache-safe model in cache mode when no explicit or fast model exists', async () => { @@ -48,6 +59,7 @@ describe('generatePromptSuggestion', () => { history: conversationHistory, model: 'main-model', version: 1, + sessionId: 'test-session', }); mockRunForkedAgent.mockResolvedValue({ text: null, @@ -57,6 +69,7 @@ describe('generatePromptSuggestion', () => { const config = { getFastModel: vi.fn(() => undefined), getModel: vi.fn(() => 'main-model'), + getSessionId: vi.fn(() => 'test-session'), } as unknown as Config; const signal = new AbortController().signal; @@ -75,6 +88,7 @@ describe('generatePromptSuggestion', () => { history: conversationHistory, model: 'main-model', version: 1, + sessionId: 'test-session', }); mockRunForkedAgent.mockResolvedValue({ text: null, @@ -84,6 +98,7 @@ describe('generatePromptSuggestion', () => { const config = { getFastModel: vi.fn(() => 'openai:fast-model'), getModel: vi.fn(() => 'main-model'), + getSessionId: vi.fn(() => 'test-session'), } as unknown as Config; await generatePromptSuggestion( @@ -103,6 +118,7 @@ describe('generatePromptSuggestion', () => { history: conversationHistory, model: 'main-model', version: 1, + sessionId: 'test-session', }); mockRunForkedAgent.mockResolvedValue({ text: null, @@ -112,6 +128,7 @@ describe('generatePromptSuggestion', () => { const config = { getFastModel: vi.fn(() => undefined), getModel: vi.fn(() => 'main-model'), + getSessionId: vi.fn(() => 'test-session'), } as unknown as Config; await generatePromptSuggestion( @@ -126,12 +143,50 @@ describe('generatePromptSuggestion', () => { ); }); + it('falls back to the base LLM when the cache-safe slot belongs to another session', async () => { + // The cache-safe slot is a process-global: in a multi-session daemon it + // can hold ANOTHER session's transcript + systemInstruction. The + // suggestion must NOT fork from a foreign session's params (cross-session + // content leak) — it falls back to the session-safe base-LLM path + // (#9233). + mockGetCacheSafeParams.mockReturnValue({ + generationConfig: {}, + history: conversationHistory, + model: 'main-model', + version: 1, + sessionId: 'session-B', // another session saved these params + }); + mockRunSideQuery.mockResolvedValue({ + text: '{"suggestion":"from base llm"}', + usage: { inputTokens: 1, outputTokens: 1 }, + }); + const config = { + getFastModel: vi.fn(() => undefined), + getModel: vi.fn(() => 'main-model'), + getSessionId: vi.fn(() => 'session-A'), // this session is different + } as unknown as Config; + + const result = await generatePromptSuggestion( + config, + conversationHistory, + new AbortController().signal, + { enableCacheSharing: true }, + ); + + // The fork must NOT be used for a foreign session's params. + expect(mockRunForkedAgent).not.toHaveBeenCalled(); + // The session-safe base-LLM path is used instead. + expect(mockRunSideQuery).toHaveBeenCalled(); + expect(result.suggestion).toBe('from base llm'); + }); + it('passes preserveTools: false when fast model differs from cache-safe model', async () => { mockGetCacheSafeParams.mockReturnValue({ generationConfig: {}, history: conversationHistory, model: 'main-model', version: 1, + sessionId: 'test-session', }); mockRunForkedAgent.mockResolvedValue({ text: null, @@ -141,6 +196,7 @@ describe('generatePromptSuggestion', () => { const config = { getFastModel: vi.fn(() => 'different-fast-model'), getModel: vi.fn(() => 'main-model'), + getSessionId: vi.fn(() => 'test-session'), } as unknown as Config; await generatePromptSuggestion( diff --git a/packages/core/src/followup/suggestionGenerator.ts b/packages/core/src/followup/suggestionGenerator.ts index bf64fff16e4..e751ab5ab9f 100644 --- a/packages/core/src/followup/suggestionGenerator.ts +++ b/packages/core/src/followup/suggestionGenerator.ts @@ -11,7 +11,11 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; -import { getCacheSafeParams, runForkedAgent } from '../utils/forkedAgent.js'; +import { + getCacheSafeParams, + runForkedAgent, + type CacheSafeParams, +} from '../utils/forkedAgent.js'; import { runSideQuery } from '../utils/sideQuery.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -102,12 +106,25 @@ export async function generatePromptSuggestion( try { // Try cache-aware forked query if enabled and params available const cacheSafe = options?.enableCacheSharing ? getCacheSafeParams() : null; + // The cache-safe slot is a process-global: in a multi-session daemon it + // can hold ANOTHER session's transcript + systemInstruction. Only use it + // when it belongs to THIS session; otherwise fall back to the + // session-safe base-LLM path (#9233). + const sessionCacheSafe = + cacheSafe && cacheSafe.sessionId === config.getSessionId() + ? cacheSafe + : null; const modelOverride = options?.model; debugLogger.debug( - `Generating suggestion: cacheSharing=${!!cacheSafe}, model=${modelOverride || '(default)'}`, + `Generating suggestion: cacheSharing=${!!sessionCacheSafe}, model=${modelOverride || '(default)'}`, ); - const raw = cacheSafe - ? await generateViaForkedQuery(config, abortSignal, modelOverride) + const raw = sessionCacheSafe + ? await generateViaForkedQuery( + config, + sessionCacheSafe, + abortSignal, + modelOverride, + ) : await generateViaBaseLlm( config, conversationHistory, @@ -144,11 +161,10 @@ export async function generatePromptSuggestion( /** Generate suggestion via cache-aware forked query */ async function generateViaForkedQuery( config: Config, + cacheSafeParams: CacheSafeParams, abortSignal: AbortSignal, modelOverride?: string, ): Promise { - const cacheSafeParams = getCacheSafeParams(); - if (!cacheSafeParams) return null; const model = modelOverride ?? config.getFastModel() ?? cacheSafeParams.model; const result = await runForkedAgent({ config, diff --git a/packages/core/src/utils/forkedAgent.ts b/packages/core/src/utils/forkedAgent.ts index 03509b05e7f..cfb940e0d38 100644 --- a/packages/core/src/utils/forkedAgent.ts +++ b/packages/core/src/utils/forkedAgent.ts @@ -86,6 +86,14 @@ export interface CacheSafeParams { model: string; /** Version number — increments when systemInstruction or tools change */ version: number; + /** + * The session that saved these params. The slot is a process-global, so + * in a multi-session daemon it can hold another session's params; readers + * must match this against their own session id before trusting it, or a + * forked query could be built from a different session's transcript + * (cross-session content leak) (#9233). + */ + sessionId?: string; } // Module-level slot written after each successful main turn. @@ -107,6 +115,7 @@ export function saveCacheSafeParams( generationConfig: GenerateContentConfig, history: Content[], model: string, + sessionId?: string, ): void { const prevConfig = currentCacheSafeParams?.generationConfig; const sysChanged = @@ -126,6 +135,7 @@ export function saveCacheSafeParams( history: copyHistoryContainers(history), model, version: currentVersion, + sessionId, }; } @@ -139,6 +149,7 @@ export function getCacheSafeParams(): CacheSafeParams | null { history: copyHistoryContainers(currentCacheSafeParams.history), model: currentCacheSafeParams.model, version: currentCacheSafeParams.version, + sessionId: currentCacheSafeParams.sessionId, }; } From 783f1d81c90325e8452f2ddac86631e740b2853f Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 18:59:17 +0800 Subject: [PATCH 5/7] fix(core): skip cloning foreign cache slot --- packages/core/src/core/client.test.ts | 1 + .../src/followup/suggestionGenerator.test.ts | 30 +++++++++++-------- .../core/src/followup/suggestionGenerator.ts | 15 ++++++++-- packages/core/src/utils/forkedAgent.ts | 4 +++ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 1939fef424c..d8ee662929e 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -4809,6 +4809,7 @@ describe('Gemini Client (client.ts)', () => { const history = JSON.stringify(getCacheSafeParams()?.history); expect(history).not.toContain('image-bytes'); expect(history).toContain('pdf-bytes'); + expect(getCacheSafeParams()?.sessionId).toBe('test-session-id'); }); it.each([ diff --git a/packages/core/src/followup/suggestionGenerator.test.ts b/packages/core/src/followup/suggestionGenerator.test.ts index ccfd7b86c4f..56f0321b31a 100644 --- a/packages/core/src/followup/suggestionGenerator.test.ts +++ b/packages/core/src/followup/suggestionGenerator.test.ts @@ -8,12 +8,17 @@ import type { Content } from '@google/genai'; import { beforeEach, describe, it, expect, vi } from 'vitest'; import type { Config } from '../config/config.js'; -const { mockGetCacheSafeParams, mockRunForkedAgent, mockRunSideQuery } = - vi.hoisted(() => ({ - mockGetCacheSafeParams: vi.fn(), - mockRunForkedAgent: vi.fn(), - mockRunSideQuery: vi.fn(), - })); +const { + mockGetCacheSafeParams, + mockGetCacheSafeParamsSessionId, + mockRunForkedAgent, + mockRunSideQuery, +} = vi.hoisted(() => ({ + mockGetCacheSafeParams: vi.fn(), + mockGetCacheSafeParamsSessionId: vi.fn(), + mockRunForkedAgent: vi.fn(), + mockRunSideQuery: vi.fn(), +})); vi.mock('../utils/sideQuery.js', async (importOriginal) => { const actual = await importOriginal(); @@ -29,6 +34,7 @@ vi.mock('../utils/forkedAgent.js', async (importOriginal) => { return { ...actual, getCacheSafeParams: mockGetCacheSafeParams, + getCacheSafeParamsSessionId: mockGetCacheSafeParamsSessionId, runForkedAgent: mockRunForkedAgent, }; }); @@ -49,6 +55,8 @@ const conversationHistory: Content[] = [ describe('generatePromptSuggestion', () => { beforeEach(() => { mockGetCacheSafeParams.mockReset(); + mockGetCacheSafeParamsSessionId.mockReset(); + mockGetCacheSafeParamsSessionId.mockReturnValue('test-session'); mockRunForkedAgent.mockReset(); mockRunSideQuery.mockReset(); }); @@ -149,13 +157,7 @@ describe('generatePromptSuggestion', () => { // suggestion must NOT fork from a foreign session's params (cross-session // content leak) — it falls back to the session-safe base-LLM path // (#9233). - mockGetCacheSafeParams.mockReturnValue({ - generationConfig: {}, - history: conversationHistory, - model: 'main-model', - version: 1, - sessionId: 'session-B', // another session saved these params - }); + mockGetCacheSafeParamsSessionId.mockReturnValue('session-B'); mockRunSideQuery.mockResolvedValue({ text: '{"suggestion":"from base llm"}', usage: { inputTokens: 1, outputTokens: 1 }, @@ -173,6 +175,8 @@ describe('generatePromptSuggestion', () => { { enableCacheSharing: true }, ); + // The foreign slot is rejected before cloning the full cached payload. + expect(mockGetCacheSafeParams).not.toHaveBeenCalled(); // The fork must NOT be used for a foreign session's params. expect(mockRunForkedAgent).not.toHaveBeenCalled(); // The session-safe base-LLM path is used instead. diff --git a/packages/core/src/followup/suggestionGenerator.ts b/packages/core/src/followup/suggestionGenerator.ts index e751ab5ab9f..951a22330cc 100644 --- a/packages/core/src/followup/suggestionGenerator.ts +++ b/packages/core/src/followup/suggestionGenerator.ts @@ -13,6 +13,7 @@ import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; import { getCacheSafeParams, + getCacheSafeParamsSessionId, runForkedAgent, type CacheSafeParams, } from '../utils/forkedAgent.js'; @@ -105,7 +106,12 @@ export async function generatePromptSuggestion( try { // Try cache-aware forked query if enabled and params available - const cacheSafe = options?.enableCacheSharing ? getCacheSafeParams() : null; + const sessionId = config.getSessionId(); + const cacheSafeSessionId = options?.enableCacheSharing + ? getCacheSafeParamsSessionId() + : undefined; + const cacheSafe = + cacheSafeSessionId === sessionId ? getCacheSafeParams() : null; // The cache-safe slot is a process-global: in a multi-session daemon it // can hold ANOTHER session's transcript + systemInstruction. Only use it // when it belongs to THIS session; otherwise fall back to the @@ -115,8 +121,13 @@ export async function generatePromptSuggestion( ? cacheSafe : null; const modelOverride = options?.model; + const cacheSharingState = sessionCacheSafe + ? 'true' + : cacheSafeSessionId + ? 'session_mismatch' + : 'false'; debugLogger.debug( - `Generating suggestion: cacheSharing=${!!sessionCacheSafe}, model=${modelOverride || '(default)'}`, + `Generating suggestion: cacheSharing=${cacheSharingState}, model=${modelOverride || '(default)'}`, ); const raw = sessionCacheSafe ? await generateViaForkedQuery( diff --git a/packages/core/src/utils/forkedAgent.ts b/packages/core/src/utils/forkedAgent.ts index cfb940e0d38..a8aebe06fcc 100644 --- a/packages/core/src/utils/forkedAgent.ts +++ b/packages/core/src/utils/forkedAgent.ts @@ -153,6 +153,10 @@ export function getCacheSafeParams(): CacheSafeParams | null { }; } +export function getCacheSafeParamsSessionId(): string | undefined { + return currentCacheSafeParams?.sessionId; +} + /** * Clear cache-safe params (e.g., on session reset). */ From bd5e5abf04bdb93f56731d31115da11c051fab36 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 19:05:51 +0800 Subject: [PATCH 6/7] test(core): pin cache slot session id --- packages/core/src/utils/forkedAgent.cache.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/utils/forkedAgent.cache.test.ts b/packages/core/src/utils/forkedAgent.cache.test.ts index 9cdbecca145..786b2fb0b26 100644 --- a/packages/core/src/utils/forkedAgent.cache.test.ts +++ b/packages/core/src/utils/forkedAgent.cache.test.ts @@ -68,6 +68,12 @@ describe('CacheSafeParams', () => { expect(params!.version).toBeGreaterThan(0); }); + it('stores session id', () => { + saveCacheSafeParams({}, [], 'model', 'session-a'); + + expect(getCacheSafeParams()?.sessionId).toBe('session-a'); + }); + it('deep clones generationConfig', () => { const config: GenerateContentConfig = { systemInstruction: 'test', From eba25bf9824c3f4a5b9f43ddbf1c572f23e09f80 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 16 Aug 2026 22:40:30 +0800 Subject: [PATCH 7/7] test(core): pin cache sharing opt-out --- .../src/followup/suggestionGenerator.test.ts | 32 +++++++++++++++++++ .../core/src/utils/forkedAgent.cache.test.ts | 9 ++++++ 2 files changed, 41 insertions(+) diff --git a/packages/core/src/followup/suggestionGenerator.test.ts b/packages/core/src/followup/suggestionGenerator.test.ts index 56f0321b31a..c88e5aa89d5 100644 --- a/packages/core/src/followup/suggestionGenerator.test.ts +++ b/packages/core/src/followup/suggestionGenerator.test.ts @@ -184,6 +184,38 @@ describe('generatePromptSuggestion', () => { expect(result.suggestion).toBe('from base llm'); }); + it('does not use the cache-safe fork when cache sharing is disabled', async () => { + mockGetCacheSafeParams.mockReturnValue({ + generationConfig: {}, + history: conversationHistory, + model: 'main-model', + version: 1, + sessionId: 'test-session', + }); + mockRunSideQuery.mockResolvedValue({ + text: '{"suggestion":"from base llm"}', + usage: { inputTokens: 1, outputTokens: 1 }, + }); + const config = { + getFastModel: vi.fn(() => undefined), + getModel: vi.fn(() => 'main-model'), + getSessionId: vi.fn(() => 'test-session'), + } as unknown as Config; + + const result = await generatePromptSuggestion( + config, + conversationHistory, + new AbortController().signal, + { enableCacheSharing: false }, + ); + + expect(mockGetCacheSafeParamsSessionId).not.toHaveBeenCalled(); + expect(mockGetCacheSafeParams).not.toHaveBeenCalled(); + expect(mockRunForkedAgent).not.toHaveBeenCalled(); + expect(mockRunSideQuery).toHaveBeenCalled(); + expect(result.suggestion).toBe('from base llm'); + }); + it('passes preserveTools: false when fast model differs from cache-safe model', async () => { mockGetCacheSafeParams.mockReturnValue({ generationConfig: {}, diff --git a/packages/core/src/utils/forkedAgent.cache.test.ts b/packages/core/src/utils/forkedAgent.cache.test.ts index 786b2fb0b26..88bcbe0c481 100644 --- a/packages/core/src/utils/forkedAgent.cache.test.ts +++ b/packages/core/src/utils/forkedAgent.cache.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { saveCacheSafeParams, getCacheSafeParams, + getCacheSafeParamsSessionId, clearCacheSafeParams, runForkedAgent, } from './forkedAgent.js'; @@ -74,6 +75,14 @@ describe('CacheSafeParams', () => { expect(getCacheSafeParams()?.sessionId).toBe('session-a'); }); + it('returns the current session id without reading full params', () => { + saveCacheSafeParams({}, [], 'model', 'session-a'); + + expect(getCacheSafeParamsSessionId()).toBe('session-a'); + clearCacheSafeParams(); + expect(getCacheSafeParamsSessionId()).toBeUndefined(); + }); + it('deep clones generationConfig', () => { const config: GenerateContentConfig = { systemInstruction: 'test',