Download packages/core/src/services/sessionSummaryUtils.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 31.9 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/services/sessionSummaryUtils.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/services/sessionSummaryUtils.test.ts
-
curl -L -o sessionSummaryUtils.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/services/sessionSummaryUtils.test.ts
31.9 kB
| /** | |
| * @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<string> { | |
| const filePath = path.join(chatsDir, fileName); | |
| await fs.writeFile(filePath, contents); | |
| return filePath; | |
| } | |
| async function setSessionMtime( | |
| filePath: string, | |
| timestamp: string, | |
| ): Promise<void> { | |
| 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<typeof vi.fn>; | |
| 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<typeof vi.fn> | |
| ).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', | |
| }); | |
| }); | |
| }); | |
| }); | |