/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { generateSummary, getPreviousSession } from './sessionSummaryUtils.js'; import type { Config } from '../config/config.js'; import type { ContentGenerator } from '../core/contentGenerator.js'; import * as chatRecordingService from './chatRecordingService.js'; import type { ConversationRecord } from './chatRecordingService.js'; import { CoreToolCallStatus } from '../scheduler/types.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; // Mock the SessionSummaryService module vi.mock('./sessionSummaryService.js', () => ({ SessionSummaryService: vi.fn().mockImplementation(() => ({ generateSummary: vi.fn(), })), })); // Mock the BaseLlmClient module vi.mock('../core/baseLlmClient.js', () => ({ BaseLlmClient: vi.fn(), })); vi.mock('./chatRecordingService.js', async () => { const actual = await vi.importActual< typeof import('./chatRecordingService.js') >('./chatRecordingService.js'); return { ...actual, loadConversationRecord: vi.fn(actual.loadConversationRecord), }; }); interface SessionFixture { summary?: string; memoryScratchpad?: unknown; sessionId?: string; startTime?: string; lastUpdated?: string; kind?: ConversationRecord['kind']; messages?: ConversationRecord['messages']; userMessageCount: number; } function buildLegacySessionJson(fixture: SessionFixture): string { const messages = fixture.messages ?? Array.from({ length: fixture.userMessageCount }, (_, i) => ({ id: String(i + 1), timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: `Message ${i + 1}` }], })); return JSON.stringify({ sessionId: fixture.sessionId ?? 'session-id', projectHash: 'abc123', startTime: fixture.startTime ?? '2024-01-01T00:00:00Z', lastUpdated: fixture.lastUpdated ?? '2024-01-01T00:00:00Z', summary: fixture.summary, memoryScratchpad: fixture.memoryScratchpad, ...(fixture.kind ? { kind: fixture.kind } : {}), messages, }); } function buildJsonlSession(fixture: SessionFixture): string { const metadata = { sessionId: fixture.sessionId ?? 'session-id', projectHash: 'abc123', startTime: fixture.startTime ?? '2024-01-01T00:00:00Z', lastUpdated: fixture.lastUpdated ?? '2024-01-01T00:00:00Z', ...(fixture.summary !== undefined ? { summary: fixture.summary } : {}), ...(fixture.memoryScratchpad !== undefined ? { memoryScratchpad: fixture.memoryScratchpad } : {}), ...(fixture.kind ? { kind: fixture.kind } : {}), }; const messages = fixture.messages ?? Array.from({ length: fixture.userMessageCount }, (_, i) => ({ id: String(i + 1), timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: `Message ${i + 1}` }], })); const lines: string[] = [JSON.stringify(metadata)]; for (const message of messages) { lines.push(JSON.stringify(message)); } return lines.join('\n') + '\n'; } async function writeSession( chatsDir: string, fileName: string, contents: string, ): Promise { const filePath = path.join(chatsDir, fileName); await fs.writeFile(filePath, contents); return filePath; } async function setSessionMtime( filePath: string, timestamp: string, ): Promise { const date = new Date(timestamp); await fs.utimes(filePath, date, date); } describe('sessionSummaryUtils', () => { let tmpDir: string; let projectTempDir: string; let chatsDir: string; let mockConfig: Config; let mockContentGenerator: ContentGenerator; let mockGenerateSummary: ReturnType; beforeEach(async () => { vi.clearAllMocks(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-summary-utils-')); projectTempDir = path.join(tmpDir, 'project'); chatsDir = path.join(projectTempDir, 'chats'); await fs.mkdir(chatsDir, { recursive: true }); mockContentGenerator = {} as ContentGenerator; mockConfig = { getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), getProjectRoot: vi.fn().mockReturnValue(projectTempDir), getSessionId: vi.fn().mockReturnValue('current-session'), storage: { getProjectTempDir: vi.fn().mockReturnValue(projectTempDir), }, } as unknown as Config; mockGenerateSummary = vi.fn().mockResolvedValue('Add dark mode to the app'); const { SessionSummaryService } = await import( './sessionSummaryService.js' ); ( SessionSummaryService as unknown as ReturnType ).mockImplementation(() => ({ generateSummary: mockGenerateSummary, })); }); afterEach(async () => { vi.restoreAllMocks(); await fs.rm(tmpDir, { recursive: true, force: true }); }); describe('getPreviousSession', () => { it('should return null if chats directory does not exist', async () => { await fs.rm(chatsDir, { recursive: true, force: true }); const result = await getPreviousSession(mockConfig); expect(result).toBeNull(); }); it('should return null if no session files exist', async () => { const result = await getPreviousSession(mockConfig); expect(result).toBeNull(); }); it('should return null if most recent session already has summary metadata', async () => { await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', buildLegacySessionJson({ userMessageCount: 5, summary: 'Existing summary', memoryScratchpad: { version: 1, workflowSummary: 'read_file -> edit', }, }), ); const result = await getPreviousSession(mockConfig); expect(result).toBeNull(); }); it('should return path if most recent session has summary but no scratchpad', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', buildLegacySessionJson({ userMessageCount: 5, summary: 'Existing summary', }), ); const result = await getPreviousSession(mockConfig); expect(result).toBe(filePath); }); it('should return null if most recent session has scratchpad but no summary', async () => { await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', buildLegacySessionJson({ userMessageCount: 5, memoryScratchpad: { version: 1, workflowSummary: 'read_file -> edit', }, }), ); const result = await getPreviousSession(mockConfig); expect(result).toBeNull(); }); it('should return null if most recent session has 1 or fewer user messages', async () => { await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', buildLegacySessionJson({ userMessageCount: 1 }), ); const result = await getPreviousSession(mockConfig); expect(result).toBeNull(); }); it('should return path if most recent session has more than 1 user message and no summary', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', buildLegacySessionJson({ userMessageCount: 2 }), ); const result = await getPreviousSession(mockConfig); expect(result).toBe(filePath); }); it('should select most recently updated session', async () => { await writeSession( chatsDir, 'session-2024-01-01T10-00-older000.json', buildLegacySessionJson({ userMessageCount: 2, lastUpdated: '2024-01-01T10:00:00Z', }), ); const newerPath = await writeSession( chatsDir, 'session-2024-01-02T10-00-newer000.json', buildLegacySessionJson({ userMessageCount: 2, lastUpdated: '2024-01-02T10:00:00Z', }), ); const result = await getPreviousSession(mockConfig); expect(result).toBe(newerPath); }); it('should ignore corrupted session files', async () => { await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', 'invalid json', ); const result = await getPreviousSession(mockConfig); expect(result).toBeNull(); }); it('should support JSONL sessions and sort by lastUpdated instead of filename', async () => { await writeSession( chatsDir, 'session-2024-01-02T10-00-older000.jsonl', buildJsonlSession({ userMessageCount: 2, lastUpdated: '2024-01-01T10:00:00Z', sessionId: 'older-session', }), ); const newerPath = await writeSession( chatsDir, 'session-2024-01-01T10-00-newer000.jsonl', buildJsonlSession({ userMessageCount: 2, lastUpdated: '2024-01-03T10:00:00Z', sessionId: 'newer-session', }), ); const result = await getPreviousSession(mockConfig); expect(result).toBe(newerPath); }); it('should stop scanning once older mtimes cannot beat the best lastUpdated', async () => { const loadConversationRecord = vi.mocked( chatRecordingService.loadConversationRecord, ); const currentPath = await writeSession( chatsDir, 'session-2024-01-03T10-00-cur00001.jsonl', buildJsonlSession({ sessionId: 'current-session', userMessageCount: 2, lastUpdated: '2024-01-03T10:00:00Z', }), ); await setSessionMtime(currentPath, '2024-01-03T10:00:00Z'); const bestPath = await writeSession( chatsDir, 'session-2024-01-02T10-00-best0001.jsonl', buildJsonlSession({ sessionId: 'best-session', userMessageCount: 2, lastUpdated: '2024-01-02T10:00:00Z', }), ); await setSessionMtime(bestPath, '2024-01-02T10:00:00Z'); const olderPath = await writeSession( chatsDir, 'session-2024-01-01T10-00-older001.jsonl', buildJsonlSession({ sessionId: 'older-session', userMessageCount: 2, lastUpdated: '2024-01-01T10:00:00Z', }), ); await setSessionMtime(olderPath, '2024-01-01T10:00:00Z'); const result = await getPreviousSession(mockConfig); expect(result).toBe(bestPath); expect(loadConversationRecord).toHaveBeenCalledTimes(2); expect(loadConversationRecord).not.toHaveBeenCalledWith(olderPath, { metadataOnly: true, }); }); it('should skip subagent sessions when backfilling scratchpads', async () => { const mainPath = await writeSession( chatsDir, 'session-2024-01-01T10-00-main0001.jsonl', buildJsonlSession({ sessionId: 'main-session', userMessageCount: 2, lastUpdated: '2024-01-01T10:00:00Z', summary: 'Main session summary', }), ); await setSessionMtime(mainPath, '2024-01-01T10:00:00Z'); await writeSession( chatsDir, 'session-2024-01-02T10-00-sub00001.jsonl', buildJsonlSession({ sessionId: 'subagent-session', userMessageCount: 2, lastUpdated: '2024-01-02T10:00:00Z', summary: 'Subagent summary', kind: 'subagent', }), ); const result = await getPreviousSession(mockConfig); expect(result).toBe(mainPath); }); }); describe('generateSummary', () => { it('should not throw if getPreviousSession returns null', async () => { await fs.rm(chatsDir, { recursive: true, force: true }); await expect(generateSummary(mockConfig)).resolves.not.toThrow(); }); it('should generate and save summary for legacy JSON sessions', async () => { const lastUpdated = '2024-01-01T10:00:00Z'; const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', buildLegacySessionJson({ userMessageCount: 2, lastUpdated }), ); await generateSummary(mockConfig); expect(mockGenerateSummary).toHaveBeenCalledTimes(1); const written = JSON.parse(await fs.readFile(filePath, 'utf-8')); expect(written.summary).toBe('Add dark mode to the app'); expect(written.memoryScratchpad).toEqual({ version: 1 }); expect(written.lastUpdated).toBe(lastUpdated); }); it('should handle errors gracefully without throwing', async () => { await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.json', buildLegacySessionJson({ userMessageCount: 2 }), ); mockGenerateSummary.mockRejectedValue(new Error('API Error')); await expect(generateSummary(mockConfig)).resolves.not.toThrow(); }); it('should append a metadata update when saving a summary to JSONL', async () => { const lastUpdated = '2024-01-01T10:00:00Z'; const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-abc12345.jsonl', buildJsonlSession({ userMessageCount: 2, lastUpdated }), ); await generateSummary(mockConfig); expect(mockGenerateSummary).toHaveBeenCalledTimes(1); const lines = (await fs.readFile(filePath, 'utf-8')) .split('\n') .filter(Boolean); const lastRecord = JSON.parse(lines[lines.length - 1]); expect(lastRecord).toEqual({ $set: { summary: 'Add dark mode to the app', memoryScratchpad: { version: 1, }, }, }); }); it('should backfill scratchpad without regenerating summary', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-backfill.jsonl', buildJsonlSession({ userMessageCount: 2, summary: 'Existing summary', }), ); await generateSummary(mockConfig); expect(mockGenerateSummary).not.toHaveBeenCalled(); const lines = (await fs.readFile(filePath, 'utf-8')) .split('\n') .filter(Boolean); const lastRecord = JSON.parse(lines[lines.length - 1]); expect(lastRecord).toEqual({ $set: { memoryScratchpad: { version: 1, }, }, }); }); it('should not retry summary generation after writing a scratchpad fallback', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-summary-fallback.jsonl', buildJsonlSession({ sessionId: 'summary-fallback-session', userMessageCount: 2, messages: [ { id: 'u1', timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: 'Read package metadata' }], }, { id: 'g1', timestamp: '2024-01-01T00:00:01Z', type: 'gemini', content: [{ text: 'Reading package.json' }], toolCalls: [ { id: 'tool-1', name: 'read_file', args: { file_path: 'package.json' }, status: CoreToolCallStatus.Success, timestamp: '2024-01-01T00:00:01Z', }, ], }, { id: 'u2', timestamp: '2024-01-01T00:00:02Z', type: 'user', content: [{ text: 'Done' }], }, ], }), ); mockGenerateSummary.mockResolvedValue(undefined); await generateSummary(mockConfig); await generateSummary(mockConfig); expect(mockGenerateSummary).toHaveBeenCalledTimes(1); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.summary).toBeUndefined(); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1, workflowSummary: 'read_file | paths package.json', toolSequence: ['read_file'], touchedPaths: ['package.json'], }); }); it('should refresh stale scratchpads when messages were appended after metadata', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-resumed1.jsonl', buildJsonlSession({ sessionId: 'resumed-session', userMessageCount: 2, summary: 'Existing summary', lastUpdated: '2024-01-01T10:00:00Z', }), ); await fs.appendFile( filePath, `${JSON.stringify({ $set: { memoryScratchpad: { version: 1, workflowSummary: 'read_file', toolSequence: ['read_file'], }, }, })}\n`, ); await fs.appendFile( filePath, [ JSON.stringify({ id: 'u-resumed', timestamp: '2024-01-02T00:00:00Z', type: 'user', content: [{ text: 'Update src/app.ts' }], }), JSON.stringify({ id: 'g-resumed', timestamp: '2024-01-02T00:00:01Z', type: 'gemini', content: [{ text: 'Editing file' }], toolCalls: [ { id: 'tool-resumed', name: 'replace', args: { file_path: 'src/app.ts' }, status: CoreToolCallStatus.Success, timestamp: '2024-01-02T00:00:01Z', }, ], }), JSON.stringify({ $set: { lastUpdated: '2024-01-02T00:00:02Z' }, }), ].join('\n') + '\n', ); await generateSummary(mockConfig); expect(mockGenerateSummary).not.toHaveBeenCalled(); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1, workflowSummary: 'replace | paths src/app.ts', toolSequence: ['replace'], touchedPaths: ['src/app.ts'], }); }); it('should preserve a newer JSONL lastUpdated written concurrently', async () => { const initialLastUpdated = '2024-01-01T10:00:00Z'; const newerLastUpdated = '2024-01-02T12:34:56Z'; const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-race.jsonl', buildJsonlSession({ userMessageCount: 2, lastUpdated: initialLastUpdated, }), ); const actualChatRecordingService = await vi.importActual< typeof import('./chatRecordingService.js') >('./chatRecordingService.js'); let injectedConcurrentUpdate = false; let sessionReadCount = 0; vi.mocked(chatRecordingService.loadConversationRecord).mockImplementation( async (targetPath, options) => { const conversation = await actualChatRecordingService.loadConversationRecord( targetPath, options, ); if (targetPath === filePath) { sessionReadCount += 1; } if ( !injectedConcurrentUpdate && targetPath === filePath && sessionReadCount === 2 ) { injectedConcurrentUpdate = true; await fs.appendFile( filePath, `${JSON.stringify({ $set: { lastUpdated: newerLastUpdated } })}\n`, ); } return conversation; }, ); await generateSummary(mockConfig); expect(injectedConcurrentUpdate).toBe(true); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.summary).toBe('Add dark mode to the app'); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1 }); expect(savedConversation?.lastUpdated).toBe(newerLastUpdated); const lines = (await fs.readFile(filePath, 'utf-8')) .split('\n') .filter(Boolean); const lastRecord = JSON.parse(lines[lines.length - 1]); expect(lastRecord).toEqual({ $set: { summary: 'Add dark mode to the app', memoryScratchpad: { version: 1, }, }, }); }); it('should skip the active startup session and summarize the previous session', async () => { const previousPath = await writeSession( chatsDir, 'session-2024-01-01T10-00-prev0001.jsonl', buildJsonlSession({ sessionId: 'previous-session', userMessageCount: 2, lastUpdated: '2024-01-01T10:00:00Z', }), ); const currentPath = await writeSession( chatsDir, 'session-2024-01-02T10-00-cur00001.jsonl', buildJsonlSession({ sessionId: 'current-session', userMessageCount: 1, lastUpdated: '2024-01-02T10:00:00Z', }), ); await generateSummary(mockConfig); expect(mockGenerateSummary).toHaveBeenCalledTimes(1); const previousLines = (await fs.readFile(previousPath, 'utf-8')) .split('\n') .filter(Boolean); expect(JSON.parse(previousLines[previousLines.length - 1])).toEqual({ $set: { summary: 'Add dark mode to the app', memoryScratchpad: { version: 1, }, }, }); const currentLines = (await fs.readFile(currentPath, 'utf-8')) .split('\n') .filter(Boolean); expect(currentLines).toHaveLength(2); }); it('should preserve repo-root file names in scratchpad touched paths', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-rootpath.jsonl', buildJsonlSession({ sessionId: 'root-path-session', userMessageCount: 2, summary: 'Existing summary', messages: [ { id: 'u1', timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: 'Inspect package.json' }], }, { id: 'g1', timestamp: '2024-01-01T00:00:01Z', type: 'gemini', content: [{ text: 'Reading files' }], toolCalls: [ { id: 'tool-1', name: 'read_file', args: { file_path: 'package.json' }, status: CoreToolCallStatus.Success, timestamp: '2024-01-01T00:00:01Z', }, ], }, { id: 'u2', timestamp: '2024-01-01T00:00:02Z', type: 'user', content: [{ text: 'Done' }], }, ], }), ); await generateSummary(mockConfig); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1, workflowSummary: 'read_file | paths package.json', toolSequence: ['read_file'], touchedPaths: ['package.json'], }); }); it('should summarize shell commands without raw arguments in scratchpad tool sequence', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-shellcmd.jsonl', buildJsonlSession({ sessionId: 'shell-command-session', userMessageCount: 2, summary: 'Existing summary', messages: [ { id: 'u1', timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: 'Run the migration and regenerate docs' }], }, { id: 'g1', timestamp: '2024-01-01T00:00:01Z', type: 'gemini', content: [{ text: 'Running commands' }], toolCalls: [ { id: 'tool-1', name: 'run_shell_command', args: { command: 'curl https://api.example.com -H "Authorization: Bearer sk-secret-token"', }, status: CoreToolCallStatus.Success, timestamp: '2024-01-01T00:00:01Z', }, { id: 'tool-2', name: 'run_shell_command', args: { command: 'DATABASE_URL=postgresql://user:password@localhost/db npm run migrate -- --name add-users', }, status: CoreToolCallStatus.Success, timestamp: '2024-01-01T00:00:02Z', }, ], }, { id: 'u2', timestamp: '2024-01-01T00:00:03Z', type: 'user', content: [{ text: 'Done' }], }, ], }), ); await generateSummary(mockConfig); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1, workflowSummary: 'run_shell_command: curl -> run_shell_command: npm', toolSequence: ['run_shell_command: curl', 'run_shell_command: npm'], }); expect( savedConversation?.memoryScratchpad?.workflowSummary, ).not.toContain('Authorization'); expect( savedConversation?.memoryScratchpad?.workflowSummary, ).not.toContain('sk-secret-token'); expect( savedConversation?.memoryScratchpad?.workflowSummary, ).not.toContain('password'); expect( savedConversation?.memoryScratchpad?.workflowSummary, ).not.toContain('add-users'); }); it('should not classify validation substrings as validation tools', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-validation-substring.jsonl', buildJsonlSession({ sessionId: 'validation-substring-session', userMessageCount: 2, summary: 'Existing summary', messages: [ { id: 'u1', timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: 'Run the contest helper' }], }, { id: 'g1', timestamp: '2024-01-01T00:00:01Z', type: 'gemini', content: [{ text: 'Running helper' }], toolCalls: [ { id: 'tool-1', name: 'contest_runner', args: {}, status: CoreToolCallStatus.Success, timestamp: '2024-01-01T00:00:01Z', }, ], }, { id: 'u2', timestamp: '2024-01-01T00:00:02Z', type: 'user', content: [{ text: 'Done' }], }, ], }), ); await generateSummary(mockConfig); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1, workflowSummary: 'contest_runner', toolSequence: ['contest_runner'], }); }); it('should cap nested path extraction depth', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-deep-paths.jsonl', buildJsonlSession({ sessionId: 'deep-paths-session', userMessageCount: 2, summary: 'Existing summary', messages: [ { id: 'u1', timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: 'Edit shallow and deeply nested files' }], }, { id: 'g1', timestamp: '2024-01-01T00:00:01Z', type: 'gemini', content: [{ text: 'Editing files' }], toolCalls: [ { id: 'tool-1', name: 'replace', args: { file_path: 'src/shallow.ts', level1: { level2: { level3: { level4: { level5: { level6: { level7: { file_path: 'src/deep.ts', }, }, }, }, }, }, }, }, status: CoreToolCallStatus.Success, timestamp: '2024-01-01T00:00:01Z', }, ], }, { id: 'u2', timestamp: '2024-01-01T00:00:02Z', type: 'user', content: [{ text: 'Done' }], }, ], }), ); await generateSummary(mockConfig); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1, workflowSummary: 'replace | paths src/shallow.ts', toolSequence: ['replace'], touchedPaths: ['src/shallow.ts'], }); }); it('should use the latest validation result in scratchpad metadata', async () => { const filePath = await writeSession( chatsDir, 'session-2024-01-01T10-00-validation.jsonl', buildJsonlSession({ sessionId: 'validation-session', userMessageCount: 2, summary: 'Existing summary', messages: [ { id: 'u1', timestamp: '2024-01-01T00:00:00Z', type: 'user', content: [{ text: 'Fix the tests' }], }, { id: 'g1', timestamp: '2024-01-01T00:00:01Z', type: 'gemini', content: [{ text: 'Running tests' }], toolCalls: [ { id: 'tool-1', name: 'run_shell_command', args: { command: 'npm test' }, status: CoreToolCallStatus.Error, timestamp: '2024-01-01T00:00:01Z', }, { id: 'tool-2', name: 'run_shell_command', args: { command: 'npm test' }, status: CoreToolCallStatus.Success, timestamp: '2024-01-01T00:00:02Z', }, ], }, { id: 'u2', timestamp: '2024-01-01T00:00:03Z', type: 'user', content: [{ text: 'Done' }], }, ], }), ); await generateSummary(mockConfig); const savedConversation = await chatRecordingService.loadConversationRecord(filePath); expect(savedConversation?.memoryScratchpad).toEqual({ version: 1, workflowSummary: 'run_shell_command: npm | validated', toolSequence: ['run_shell_command: npm'], validationStatus: 'passed', }); }); }); });