diff --git a/packages/builtin-tools/src/tools/AgentTool/runAgent.ts b/packages/builtin-tools/src/tools/AgentTool/runAgent.ts index 0d718f538..7857b7445 100644 --- a/packages/builtin-tools/src/tools/AgentTool/runAgent.ts +++ b/packages/builtin-tools/src/tools/AgentTool/runAgent.ts @@ -853,6 +853,11 @@ export async function* runAgent({ if (feature('PROMPT_CACHE_BREAK_DETECTION')) { cleanupAgentTracking(agentId) } + if (agentToolUseContext.readFileState !== toolUseContext.readFileState) { + toolUseContext.readFileState.mergeFrom( + agentToolUseContext.readFileState, + ) + } // Release cloned file state cache memory agentToolUseContext.readFileState.clear() // Release the cloned fork context messages diff --git a/src/utils/__tests__/fileStateCache.test.ts b/src/utils/__tests__/fileStateCache.test.ts index 0cfb3d21b..d7288ad94 100644 --- a/src/utils/__tests__/fileStateCache.test.ts +++ b/src/utils/__tests__/fileStateCache.test.ts @@ -146,3 +146,80 @@ describe('coerceToolContentToString', () => { expect(coerceToolContentToString(nested)).toBe('{"a":{"b":[1,2,3]}}') }) }) + +describe('FileStateCache.mergeFrom', () => { + test('merges new entries from other cache into target', () => { + const target = createFileStateCacheWithSizeLimit(100) + const other = createFileStateCacheWithSizeLimit(100) + + target.set('/a.txt', makeEntry('old-a', { timestamp: 100 })) + other.set('/b.txt', makeEntry('new-b', { timestamp: 200 })) + + target.mergeFrom(other) + + expect(target.get('/a.txt')?.content).toBe('old-a') + expect(target.get('/b.txt')?.content).toBe('new-b') + expect(target.size).toBe(2) + }) + + test('overrides entries with newer timestamp from other', () => { + const target = createFileStateCacheWithSizeLimit(100) + const other = createFileStateCacheWithSizeLimit(100) + + target.set('/a.txt', makeEntry('old-content', { timestamp: 100 })) + other.set('/a.txt', makeEntry('new-content', { timestamp: 200 })) + + target.mergeFrom(other) + + expect(target.get('/a.txt')?.content).toBe('new-content') + }) + + test('does not override entries with older timestamp', () => { + const target = createFileStateCacheWithSizeLimit(100) + const other = createFileStateCacheWithSizeLimit(100) + + target.set('/a.txt', makeEntry('newer-content', { timestamp: 200 })) + other.set('/a.txt', makeEntry('older-content', { timestamp: 100 })) + + target.mergeFrom(other) + + expect(target.get('/a.txt')?.content).toBe('newer-content') + }) + + test('handles empty other cache (no-op)', () => { + const target = createFileStateCacheWithSizeLimit(100) + const other = createFileStateCacheWithSizeLimit(100) + + target.set('/a.txt', makeEntry('content')) + target.mergeFrom(other) + + expect(target.size).toBe(1) + expect(target.get('/a.txt')?.content).toBe('content') + }) + + test('merges into empty target cache', () => { + const target = createFileStateCacheWithSizeLimit(100) + const other = createFileStateCacheWithSizeLimit(100) + + other.set('/a.txt', makeEntry('content-a')) + other.set('/b.txt', makeEntry('content-b')) + target.mergeFrom(other) + + expect(target.size).toBe(2) + expect(target.get('/a.txt')?.content).toBe('content-a') + expect(target.get('/b.txt')?.content).toBe('content-b') + }) + + test('preserves existing entries when merging new ones', () => { + const target = createFileStateCacheWithSizeLimit(100) + const other = createFileStateCacheWithSizeLimit(100) + + target.set('/keep.txt', makeEntry('keep-me')) + other.set('/new.txt', makeEntry('new-file')) + + target.mergeFrom(other) + + expect(target.has('/keep.txt')).toBe(true) + expect(target.has('/new.txt')).toBe(true) + }) +}) diff --git a/src/utils/fileStateCache.ts b/src/utils/fileStateCache.ts index 165b3fa1d..822b75a71 100644 --- a/src/utils/fileStateCache.ts +++ b/src/utils/fileStateCache.ts @@ -101,6 +101,15 @@ export class FileStateCache { load(entries: ReturnType['dump']>): void { this.cache.load(entries) } + + mergeFrom(other: FileStateCache): void { + for (const [filePath, fileState] of other.entries()) { + const existing = this.get(filePath) + if (!existing || fileState.timestamp > existing.timestamp) { + this.set(filePath, fileState) + } + } + } } /**