diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index d7ee4a84e1b..0e6d3a627b3 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -4037,8 +4037,8 @@ export class GeminiClient { if (!turn.pendingToolCalls.length && signal && !signal.aborted) { // Save cache-safe params here — before any early return — so that - // background extract/dream agents calling getCacheSafeParams() always - // see the current turn's history regardless of which path exits below. + // background readers calling getCacheSafeParams(sessionId) can see the + // current turn's history regardless of which path exits below. try { const chat = this.getChat(); const maxHistoryForCache = 40; diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index a1b4044c1cf..8746b51894c 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -12,9 +12,19 @@ import { } from './speculation.js'; import type { Content } from '@google/genai'; import { ApprovalMode, type Config } from '../config/config.js'; +import type { CacheSafeParams } from '../utils/forkedAgent.js'; import type { ToolResultBoundaryObservation } from '../utils/tool-result-boundary-diagnostics.js'; const forkedAgentMocks = vi.hoisted(() => ({ + getCacheSafeParams: vi.fn< + (expectedSessionId?: string) => CacheSafeParams | null + >(() => ({ + generationConfig: {}, + history: [], + model: 'qwen-fast', + version: 1, + })), + createForkedChat: vi.fn(), runForkedAgent: vi.fn(), sendMessageStream: vi.fn(), })); @@ -33,15 +43,12 @@ vi.mock( ); vi.mock('../utils/forkedAgent.js', () => ({ - getCacheSafeParams: vi.fn(() => ({ - generationConfig: {}, - history: [], - model: 'qwen-fast', - version: 1, - })), - createForkedChat: vi.fn(() => ({ - sendMessageStream: forkedAgentMocks.sendMessageStream, - })), + getCacheSafeParams: forkedAgentMocks.getCacheSafeParams, + createForkedChat: forkedAgentMocks.createForkedChat.mockImplementation( + () => ({ + sendMessageStream: forkedAgentMocks.sendMessageStream, + }), + ), runForkedAgent: forkedAgentMocks.runForkedAgent, runWithForkedChatModel: vi.fn( async ( @@ -57,6 +64,20 @@ afterEach(() => { }); describe('startSpeculation', () => { + it('does not start when the session-scoped lookup returns null', async () => { + const config = { + getSessionId: vi.fn().mockReturnValue('spec-session'), + } as unknown as Config; + forkedAgentMocks.getCacheSafeParams.mockReturnValueOnce(null); + + await expect(startSpeculation(config, 'read a.ts')).rejects.toThrow( + 'CacheSafeParams not available for speculation', + ); + + expect(forkedAgentMocks.createForkedChat).not.toHaveBeenCalled(); + expect(forkedAgentMocks.runForkedAgent).not.toHaveBeenCalled(); + }); + it('stops at a boundary when the host guard denies a speculative invocation', async () => { const execute = vi.fn(); const guard = vi.fn().mockResolvedValue({ @@ -112,6 +133,9 @@ describe('startSpeculation', () => { const state = await startSpeculation(config, 'read a.ts'); await vi.waitFor(() => expect(state.status).toBe('boundary')); + expect(forkedAgentMocks.getCacheSafeParams).toHaveBeenCalledWith( + 'spec-session', + ); expect(guard).toHaveBeenCalledWith({ callId: 'call-speculation-guard', toolName: 'read_file', @@ -179,7 +203,14 @@ describe('startSpeculation', () => { const state = await startSpeculation(config, 'read a.ts'); await vi.waitFor(() => expect(state.status).toBe('completed')); + await vi.waitFor(() => + expect(forkedAgentMocks.getCacheSafeParams).toHaveBeenCalledTimes(2), + ); + expect(forkedAgentMocks.getCacheSafeParams).toHaveBeenNthCalledWith( + 2, + 'spec-session', + ); expect(guard).toHaveBeenCalledWith({ callId: 'call-speculation-guard-allow', toolName: 'read_file', @@ -212,6 +243,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -295,6 +327,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -357,6 +390,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -418,6 +452,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -481,6 +516,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolOutputBatchBudget: vi.fn().mockReturnValue(10_000), } as unknown as Config; @@ -544,6 +580,7 @@ describe('startSpeculation', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; forkedAgentMocks.runForkedAgent.mockResolvedValue({ @@ -585,6 +622,42 @@ describe('startSpeculation', () => { await abortSpeculation(state); }); + + it('does not generate a pipelined suggestion when its scoped lookup returns null', async () => { + const config = { + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getCwd: vi.fn().mockReturnValue(process.cwd()), + getFastModel: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('spec-session'), + } as unknown as Config; + forkedAgentMocks.getCacheSafeParams + .mockReturnValueOnce({ + generationConfig: {}, + history: [], + model: 'qwen-fast', + version: 1, + }) + .mockReturnValueOnce(null); + forkedAgentMocks.sendMessageStream.mockImplementation(async function* () { + yield { + type: 'chunk', + value: { + candidates: [{ content: { parts: [{ text: 'done' }] } }], + }, + }; + }); + + const state = await startSpeculation(config, 'do something'); + await vi.waitFor(() => expect(state.status).toBe('completed')); + await vi.waitFor(() => + expect(forkedAgentMocks.getCacheSafeParams).toHaveBeenCalledTimes(2), + ); + + expect(forkedAgentMocks.runForkedAgent).not.toHaveBeenCalled(); + expect(state.pipelinedSuggestion).toBeUndefined(); + + await abortSpeculation(state); + }); }); describe.each([ @@ -606,6 +679,7 @@ describe.each([ getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(fastModel), + getSessionId: vi.fn().mockReturnValue('spec-session'), getToolRegistry: vi.fn().mockReturnValue({ ensureTool: vi.fn().mockResolvedValue({ build: vi.fn().mockReturnValue({ diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 443ac65b4db..2f1837672d6 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -141,7 +141,7 @@ export async function startSpeculation( parentSignal?: AbortSignal, options?: { model?: string }, ): Promise { - const cacheSafe = getCacheSafeParams(); + const cacheSafe = getCacheSafeParams(config.getSessionId()); if (!cacheSafe) { throw new Error('CacheSafeParams not available for speculation'); } @@ -718,7 +718,7 @@ The assistant responded: ${speculatedSummary || '(tool calls executed)'} ${SUGGESTION_PROMPT}`; - const cacheSafeParams = getCacheSafeParams(); + const cacheSafeParams = getCacheSafeParams(config.getSessionId()); if (!cacheSafeParams) return null; const model = modelOverride ?? config.getFastModel(); const resolvedModel = model ?? cacheSafeParams.model; diff --git a/packages/core/src/followup/suggestionGenerator.test.ts b/packages/core/src/followup/suggestionGenerator.test.ts index c88e5aa89d5..ee20c90fb0e 100644 --- a/packages/core/src/followup/suggestionGenerator.test.ts +++ b/packages/core/src/followup/suggestionGenerator.test.ts @@ -85,6 +85,7 @@ describe('generatePromptSuggestion', () => { enableCacheSharing: true, }); + expect(mockGetCacheSafeParams).toHaveBeenCalledWith('test-session'); expect(mockRunForkedAgent).toHaveBeenCalledWith( expect.objectContaining({ model: 'main-model', abortSignal: signal }), ); @@ -175,8 +176,8 @@ describe('generatePromptSuggestion', () => { { enableCacheSharing: true }, ); - // The foreign slot is rejected before cloning the full cached payload. - expect(mockGetCacheSafeParams).not.toHaveBeenCalled(); + // The cache API rejects the foreign slot before cloning its payload. + expect(mockGetCacheSafeParams).toHaveBeenCalledWith('session-A'); // 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 951a22330cc..3fd9d755ea1 100644 --- a/packages/core/src/followup/suggestionGenerator.ts +++ b/packages/core/src/followup/suggestionGenerator.ts @@ -110,18 +110,15 @@ export async function generatePromptSuggestion( const cacheSafeSessionId = options?.enableCacheSharing ? getCacheSafeParamsSessionId() : undefined; - const cacheSafe = - cacheSafeSessionId === sessionId ? getCacheSafeParams() : null; + const cacheSafe = options?.enableCacheSharing + ? getCacheSafeParams(sessionId) + : 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; - const cacheSharingState = sessionCacheSafe + const cacheSharingState = cacheSafe ? 'true' : cacheSafeSessionId ? 'session_mismatch' @@ -129,10 +126,10 @@ export async function generatePromptSuggestion( debugLogger.debug( `Generating suggestion: cacheSharing=${cacheSharingState}, model=${modelOverride || '(default)'}`, ); - const raw = sessionCacheSafe + const raw = cacheSafe ? await generateViaForkedQuery( config, - sessionCacheSafe, + cacheSafe, abortSignal, modelOverride, ) diff --git a/packages/core/src/memory/extract.test.ts b/packages/core/src/memory/extract.test.ts index 9e8d6be68e2..486466d1943 100644 --- a/packages/core/src/memory/extract.test.ts +++ b/packages/core/src/memory/extract.test.ts @@ -19,11 +19,17 @@ import { rebuildUserAutoMemoryIndex, } from './indexer.js'; import { refreshMemoryInstruction } from './refresh.js'; +import { getCacheSafeParamsSessionId } from '../utils/forkedAgent.js'; vi.mock('./extractionAgentPlanner.js', () => ({ runAutoMemoryExtractionByAgent: vi.fn(), })); +vi.mock('../utils/forkedAgent.js', async (importOriginal) => ({ + ...(await importOriginal()), + getCacheSafeParamsSessionId: vi.fn(), +})); + vi.mock('./indexer.js', () => ({ rebuildManagedAutoMemoryIndex: vi.fn().mockResolvedValue(''), rebuildUserAutoMemoryIndex: vi.fn().mockResolvedValue(''), @@ -75,6 +81,7 @@ describe('auto-memory extraction', () => { getModel: vi.fn().mockReturnValue('qwen3-coder-plus'), } as unknown as Config; vi.clearAllMocks(); + vi.mocked(getCacheSafeParamsSessionId).mockReturnValue('session-1'); }); afterEach(async () => { @@ -125,6 +132,65 @@ describe('auto-memory extraction', () => { expect(cursor.processedOffset).toBe(2); }); + it('skips a session mismatch without advancing the cursor', async () => { + vi.mocked(getCacheSafeParamsSessionId) + .mockReturnValueOnce('session-1') + .mockReturnValueOnce('session-2'); + const cursorBefore = await fs.readFile( + getAutoMemoryExtractCursorPath(projectRoot), + 'utf-8', + ); + + const result = await runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + config: mockConfig, + history: [{ role: 'user', parts: [{ text: 'Remember this.' }] }], + }); + + expect(result.skippedReason).toBe('session_mismatch'); + expect(result.cursor.processedOffset).toBeUndefined(); + expect(runAutoMemoryExtractionByAgent).not.toHaveBeenCalled(); + expect( + await fs.readFile(getAutoMemoryExtractCursorPath(projectRoot), 'utf-8'), + ).toBe(cursorBefore); + }); + + it('skips an existing session mismatch before scaffold IO', async () => { + vi.mocked(getCacheSafeParamsSessionId).mockReturnValue('session-2'); + const uncreatedProjectRoot = path.join(tempDir, 'not-created'); + + const result = await runAutoMemoryExtract({ + projectRoot: uncreatedProjectRoot, + sessionId: 'session-1', + config: mockConfig, + history: [{ role: 'user', parts: [{ text: 'Remember this.' }] }], + }); + + expect(result.skippedReason).toBe('session_mismatch'); + await expect(fs.stat(uncreatedProjectRoot)).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(runAutoMemoryExtractionByAgent).not.toHaveBeenCalled(); + }); + + it('preserves the empty-cache failure path', async () => { + vi.mocked(getCacheSafeParamsSessionId).mockReturnValue(undefined); + vi.mocked(runAutoMemoryExtractionByAgent).mockRejectedValueOnce( + new Error('no cache-safe params'), + ); + + await expect( + runAutoMemoryExtract({ + projectRoot, + sessionId: 'session-1', + config: mockConfig, + history: [{ role: 'user', parts: [{ text: 'Remember this.' }] }], + }), + ).rejects.toThrow('no cache-safe params'); + expect(runAutoMemoryExtractionByAgent).toHaveBeenCalledOnce(); + }); + it('throws when config is missing because heuristic fallback was removed', async () => { await expect( runAutoMemoryExtract({ diff --git a/packages/core/src/memory/extract.ts b/packages/core/src/memory/extract.ts index 4b6f3b395b6..b1fd4c8152b 100644 --- a/packages/core/src/memory/extract.ts +++ b/packages/core/src/memory/extract.ts @@ -23,6 +23,7 @@ import { rebuildManagedAutoMemoryIndex, rebuildUserAutoMemoryIndex, } from './indexer.js'; +import { getCacheSafeParamsSessionId } from '../utils/forkedAgent.js'; import { refreshMemoryInstruction } from './refresh.js'; import { type AutoMemoryExtractCursor, @@ -38,11 +39,32 @@ export interface AutoMemoryExtractResult { | 'already_running' | 'queued' | 'memory_tool' - | 'memory_pressure'; + | 'memory_pressure' + | 'session_mismatch'; systemMessage?: string; cursor: AutoMemoryExtractCursor; } +function getSessionMismatchResult( + sessionId: string, + expectedSessionId: string, + now: Date, +): AutoMemoryExtractResult | null { + const cachedSessionId = getCacheSafeParamsSessionId(); + if (cachedSessionId === undefined || cachedSessionId === expectedSessionId) { + return null; + } + debugLogger.debug('Skipping auto-memory extract: session_mismatch.'); + return { + touchedTopics: [], + skippedReason: 'session_mismatch', + cursor: { + sessionId, + updatedAt: now.toISOString(), + }, + }; +} + async function readExtractCursor( projectRoot: string, ): Promise { @@ -108,6 +130,19 @@ export async function runAutoMemoryExtract(params: { config?: Config; }): Promise { const now = params.now ?? new Date(); + if (!params.config) { + throw new Error( + 'Managed auto-memory extraction requires config for forked-agent execution.', + ); + } + const expectedSessionId = params.config.getSessionId(); + const earlyMismatch = getSessionMismatchResult( + params.sessionId, + expectedSessionId, + now, + ); + if (earlyMismatch) return earlyMismatch; + // Per-project scaffold is required (extraction cursor + metadata live // there). User-level scaffold is optional — a brand-new user without // write access to `~/.qwen/memories/` should still be able to use @@ -121,15 +156,10 @@ export async function runAutoMemoryExtract(params: { ); } - if (!params.config) { - throw new Error( - 'Managed auto-memory extraction requires config for forked-agent execution.', - ); - } - // Read the cursor first, then scan only the unprocessed slice. The old // code ran partToString().replace() over EVERY message but the resulting - // text was never read — fork agent context comes from getCacheSafeParams(). + // text was never read — fork agent context comes from the session-scoped + // cache-safe params lookup. const currentCursor = await readExtractCursor(params.projectRoot); const rawOffset = currentCursor.sessionId === params.sessionId @@ -158,6 +188,13 @@ export async function runAutoMemoryExtract(params: { return { touchedTopics: [], cursor }; } + const lateMismatch = getSessionMismatchResult( + params.sessionId, + expectedSessionId, + now, + ); + if (lateMismatch) return lateMismatch; + const agentResult = await runAutoMemoryExtractionByAgent( params.config, params.projectRoot, diff --git a/packages/core/src/memory/extractAgent.test.ts b/packages/core/src/memory/extractAgent.test.ts index 5786fd349da..36e24296d95 100644 --- a/packages/core/src/memory/extractAgent.test.ts +++ b/packages/core/src/memory/extractAgent.test.ts @@ -22,7 +22,9 @@ vi.mock('./extractionAgentPlanner.js', () => ({ describe('auto-memory extraction with agent planner', () => { let tempDir: string; let projectRoot: string; - const mockConfig = {} as Config; + const mockConfig = { + getSessionId: () => 'session-1', + } as Config; beforeEach(async () => { tempDir = await fs.mkdtemp( diff --git a/packages/core/src/memory/extractionAgentPlanner.test.ts b/packages/core/src/memory/extractionAgentPlanner.test.ts index 64d59ca43a1..2b7ab36e4f8 100644 --- a/packages/core/src/memory/extractionAgentPlanner.test.ts +++ b/packages/core/src/memory/extractionAgentPlanner.test.ts @@ -94,6 +94,7 @@ describe('runAutoMemoryExtractionByAgent', () => { hasToolActivity: true, systemMessage: 'Managed auto-memory updated: user.md', }); + expect(getCacheSafeParams).toHaveBeenCalledWith('session-1'); expect(runForkedAgent).toHaveBeenCalledWith( expect.objectContaining({ tools: [ diff --git a/packages/core/src/memory/extractionAgentPlanner.ts b/packages/core/src/memory/extractionAgentPlanner.ts index 65de8389ce9..7597846c062 100644 --- a/packages/core/src/memory/extractionAgentPlanner.ts +++ b/packages/core/src/memory/extractionAgentPlanner.ts @@ -247,7 +247,7 @@ export async function runAutoMemoryExtractionByAgent( config: Config, projectRoot: string, ): Promise { - const cacheSafe = getCacheSafeParams(); + const cacheSafe = getCacheSafeParams(config.getSessionId()); if (!cacheSafe) { throw new Error( 'runAutoMemoryExtractionByAgent: no cache-safe params available; ' + diff --git a/packages/core/src/memory/manager.test.ts b/packages/core/src/memory/manager.test.ts index e7ccbacc93e..d91210befb4 100644 --- a/packages/core/src/memory/manager.test.ts +++ b/packages/core/src/memory/manager.test.ts @@ -19,6 +19,15 @@ import type { Config } from '../config/config.js'; // ─── Mocks ──────────────────────────────────────────────────────────────────── +const telemetryMocks = vi.hoisted(() => ({ + logMemoryExtract: vi.fn(), +})); + +vi.mock('../telemetry/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + logMemoryExtract: telemetryMocks.logMemoryExtract, +})); + vi.mock('./extract.js', () => ({ runAutoMemoryExtract: vi.fn(), })); @@ -137,6 +146,38 @@ describe('MemoryManager', () => { expect(tasks.some((t) => t.status === 'completed')).toBe(true); }); + it('records a session mismatch as skipped', async () => { + vi.mocked(runAutoMemoryExtract).mockResolvedValue({ + touchedTopics: [], + skippedReason: 'session_mismatch', + cursor: { sessionId: 'sess-1', updatedAt: new Date().toISOString() }, + }); + const config = makeMockConfig(); + + const mgr = new MemoryManager(); + const result = await mgr.scheduleExtract({ + projectRoot, + sessionId: 'sess-1', + config, + history: [{ role: 'user', parts: [{ text: 'hi' }] }], + }); + + expect(result.skippedReason).toBe('session_mismatch'); + expect(mgr.listTasksByType('extract', projectRoot)[0]).toMatchObject({ + status: 'skipped', + progressText: 'Skipped: session mismatch.', + metadata: { skippedReason: 'session_mismatch' }, + }); + const event = telemetryMocks.logMemoryExtract.mock.calls[0]?.[1] as { + status: string; + skipped_reason?: string; + }; + expect(event).toMatchObject({ + status: 'skipped', + skipped_reason: 'session_mismatch', + }); + }); + it.each([ ['private', '.qwen/memory/user/test.md'], ['team', '.qwen/team-memory/test.md'], diff --git a/packages/core/src/memory/manager.ts b/packages/core/src/memory/manager.ts index 0be50c94982..934e414da15 100644 --- a/packages/core/src/memory/manager.ts +++ b/packages/core/src/memory/manager.ts @@ -762,17 +762,21 @@ export class MemoryManager { const result = await runAutoMemoryExtract(params); const durationMs = Date.now() - t0; + const skippedReason = result.skippedReason; + const status = skippedReason ? 'skipped' : 'completed'; this.update(record, { - status: result.skippedReason ? 'skipped' : 'completed', + status, progressText: result.systemMessage ?? - (result.touchedTopics.length > 0 - ? `Managed auto-memory updated: ${result.touchedTopics.join(', ')}.` - : 'Managed auto-memory extraction completed without durable changes.'), + (skippedReason + ? `Skipped: ${skippedReason.replaceAll('_', ' ')}.` + : result.touchedTopics.length > 0 + ? `Managed auto-memory updated: ${result.touchedTopics.join(', ')}.` + : 'Managed auto-memory extraction completed without durable changes.'), metadata: { touchedTopics: result.touchedTopics, processedOffset: result.cursor.processedOffset, - skippedReason: result.skippedReason, + skippedReason, }, }); if (params.config) { @@ -780,7 +784,8 @@ export class MemoryManager { params.config, new MemoryExtractEvent({ trigger: 'auto', - status: 'completed', + status, + ...(skippedReason ? { skipped_reason: skippedReason } : {}), patches_count: result.touchedTopics.length, touched_topics: result.touchedTopics, duration_ms: durationMs, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index b274cd2e310..fe1801eb351 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -1521,7 +1521,8 @@ export class MemoryExtractEvent implements BaseTelemetryEvent { | 'already_running' | 'queued' | 'memory_tool' - | 'memory_pressure'; + | 'memory_pressure' + | 'session_mismatch'; patches_count: number; touched_topics: string; duration_ms: number; @@ -1533,7 +1534,8 @@ export class MemoryExtractEvent implements BaseTelemetryEvent { | 'already_running' | 'queued' | 'memory_tool' - | 'memory_pressure'; + | 'memory_pressure' + | 'session_mismatch'; patches_count: number; touched_topics: string[]; duration_ms: number; diff --git a/packages/core/src/utils/forkedAgent.cache.test.ts b/packages/core/src/utils/forkedAgent.cache.test.ts index 883b189dff7..52419012d33 100644 --- a/packages/core/src/utils/forkedAgent.cache.test.ts +++ b/packages/core/src/utils/forkedAgent.cache.test.ts @@ -75,6 +75,13 @@ describe('CacheSafeParams', () => { expect(getCacheSafeParams()?.sessionId).toBe('session-a'); }); + it('rejects params owned by another session', () => { + saveCacheSafeParams({}, [], 'model', 'session-b'); + + expect(getCacheSafeParams('session-a')).toBeNull(); + expect(getCacheSafeParams('session-b')?.sessionId).toBe('session-b'); + }); + it('returns the current session id without reading full params', () => { saveCacheSafeParams({}, [], 'model', 'session-a'); diff --git a/packages/core/src/utils/forkedAgent.ts b/packages/core/src/utils/forkedAgent.ts index dd186eb2ace..e6526e6f5df 100644 --- a/packages/core/src/utils/forkedAgent.ts +++ b/packages/core/src/utils/forkedAgent.ts @@ -140,10 +140,23 @@ export function saveCacheSafeParams( } /** - * Get the current cache-safe params, or null if not yet captured. + * Get the current cache-safe params, or null if not yet captured or owned by + * another session. Production readers must pass their session id; omitting it + * is retained for tests and callers that inspect the raw process-global slot. */ -export function getCacheSafeParams(): CacheSafeParams | null { +export function getCacheSafeParams( + expectedSessionId?: string, +): CacheSafeParams | null { if (!currentCacheSafeParams) return null; + if ( + expectedSessionId !== undefined && + currentCacheSafeParams.sessionId !== expectedSessionId + ) { + debugLogger.debug( + `CacheSafeParams session_mismatch: requested=${expectedSessionId}, cached=${currentCacheSafeParams.sessionId ?? '(none)'}`, + ); + return null; + } return { generationConfig: structuredClone(currentCacheSafeParams.generationConfig), history: copyHistoryContainers(currentCacheSafeParams.history),