Skip to content

Commit f744913

Browse files
authored
feat(core): migrate chat recording to JSONL streaming (#23749)
1 parent 45100f7 commit f744913

15 files changed

Lines changed: 903 additions & 662 deletions

packages/cli/src/ui/hooks/useSessionBrowser.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ import {
1111
useSessionBrowser,
1212
convertSessionToHistoryFormats,
1313
} from './useSessionBrowser.js';
14-
import * as fs from 'node:fs/promises';
1514
import path from 'node:path';
1615
import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js';
1716
import {
1817
type Config,
1918
type ConversationRecord,
2019
type MessageRecord,
2120
CoreToolCallStatus,
21+
loadConversationRecord,
2222
} from '@google/gemini-cli-core';
2323
import {
2424
coreEvents,
@@ -46,6 +46,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
4646
clear: vi.fn(),
4747
hydrate: vi.fn(),
4848
},
49+
loadConversationRecord: vi.fn(),
4950
};
5051
});
5152

@@ -55,7 +56,6 @@ const MOCKED_SESSION_ID = 'test-session-123';
5556
const MOCKED_CURRENT_SESSION_ID = 'current-session-id';
5657

5758
describe('useSessionBrowser', () => {
58-
const mockedFs = vi.mocked(fs);
5959
const mockedPath = vi.mocked(path);
6060
const mockedGetSessionFiles = vi.mocked(getSessionFiles);
6161

@@ -98,7 +98,7 @@ describe('useSessionBrowser', () => {
9898
fileName: MOCKED_FILENAME,
9999
} as SessionInfo;
100100
mockedGetSessionFiles.mockResolvedValue([mockSession]);
101-
mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation));
101+
vi.mocked(loadConversationRecord).mockResolvedValue(mockConversation);
102102

103103
const { result } = await renderHook(() =>
104104
useSessionBrowser(mockConfig, mockOnLoadHistory),
@@ -107,9 +107,8 @@ describe('useSessionBrowser', () => {
107107
await act(async () => {
108108
await result.current.handleResumeSession(mockSession);
109109
});
110-
expect(mockedFs.readFile).toHaveBeenCalledWith(
110+
expect(loadConversationRecord).toHaveBeenCalledWith(
111111
`${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`,
112-
'utf8',
113112
);
114113
expect(mockConfig.setSessionId).toHaveBeenCalledWith(
115114
'existing-session-456',
@@ -125,7 +124,9 @@ describe('useSessionBrowser', () => {
125124
id: MOCKED_SESSION_ID,
126125
fileName: MOCKED_FILENAME,
127126
} as SessionInfo;
128-
mockedFs.readFile.mockRejectedValue(new Error('File not found'));
127+
vi.mocked(loadConversationRecord).mockRejectedValue(
128+
new Error('File not found'),
129+
);
129130

130131
const { result } = await renderHook(() =>
131132
useSessionBrowser(mockConfig, mockOnLoadHistory),
@@ -149,7 +150,7 @@ describe('useSessionBrowser', () => {
149150
id: MOCKED_SESSION_ID,
150151
fileName: MOCKED_FILENAME,
151152
} as SessionInfo;
152-
mockedFs.readFile.mockResolvedValue('invalid json');
153+
vi.mocked(loadConversationRecord).mockResolvedValue(null);
153154

154155
const { result } = await renderHook(() =>
155156
useSessionBrowser(mockConfig, mockOnLoadHistory),

packages/cli/src/ui/hooks/useSessionBrowser.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66

77
import { useState, useCallback } from 'react';
88
import type { HistoryItemWithoutId } from '../types.js';
9-
import * as fs from 'node:fs/promises';
109
import path from 'node:path';
1110
import {
1211
coreEvents,
1312
convertSessionToClientHistory,
1413
uiTelemetryService,
14+
loadConversationRecord,
1515
type Config,
16-
type ConversationRecord,
1716
type ResumedSessionData,
1817
} from '@google/gemini-cli-core';
1918
import {
@@ -61,10 +60,12 @@ export const useSessionBrowser = (
6160
const originalFilePath = path.join(chatsDir, fileName);
6261

6362
// Load up the conversation.
64-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
65-
const conversation: ConversationRecord = JSON.parse(
66-
await fs.readFile(originalFilePath, 'utf8'),
67-
);
63+
const conversation = await loadConversationRecord(originalFilePath);
64+
if (!conversation) {
65+
throw new Error(
66+
`Failed to parse conversation from ${originalFilePath}`,
67+
);
68+
}
6869

6970
// Use the old session's ID to continue it.
7071
const existingSessionId = conversation.sessionId;

packages/cli/src/utils/sessionUtils.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type Storage,
1313
type ConversationRecord,
1414
type MessageRecord,
15+
loadConversationRecord,
1516
} from '@google/gemini-cli-core';
1617
import * as fs from 'node:fs/promises';
1718
import path from 'node:path';
@@ -250,23 +251,27 @@ export const getAllSessionFiles = async (
250251
try {
251252
const files = await fs.readdir(chatsDir);
252253
const sessionFiles = files
253-
.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'))
254+
.filter(
255+
(f) =>
256+
f.startsWith(SESSION_FILE_PREFIX) &&
257+
(f.endsWith('.json') || f.endsWith('.jsonl')),
258+
)
254259
.sort(); // Sort by filename, which includes timestamp
255260

256261
const sessionPromises = sessionFiles.map(
257262
async (file): Promise<SessionFileEntry> => {
258263
const filePath = path.join(chatsDir, file);
259264
try {
260-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
261-
const content: ConversationRecord = JSON.parse(
262-
await fs.readFile(filePath, 'utf8'),
263-
);
265+
const content = await loadConversationRecord(filePath, {
266+
metadataOnly: !options.includeFullContent,
267+
});
268+
if (!content) {
269+
return { fileName: file, sessionInfo: null };
270+
}
264271

265272
// Validate required fields
266273
if (
267274
!content.sessionId ||
268-
!content.messages ||
269-
!Array.isArray(content.messages) ||
270275
!content.startTime ||
271276
!content.lastUpdated
272277
) {
@@ -275,7 +280,7 @@ export const getAllSessionFiles = async (
275280
}
276281

277282
// Skip sessions that only contain system messages (info, error, warning)
278-
if (!hasUserOrAssistantMessage(content.messages)) {
283+
if (!content.hasUserOrAssistantMessage) {
279284
return { fileName: file, sessionInfo: null };
280285
}
281286

@@ -285,7 +290,9 @@ export const getAllSessionFiles = async (
285290
return { fileName: file, sessionInfo: null };
286291
}
287292

288-
const firstUserMessage = extractFirstUserMessage(content.messages);
293+
const firstUserMessage = content.firstUserMessage
294+
? cleanMessage(content.firstUserMessage)
295+
: extractFirstUserMessage(content.messages);
289296
const isCurrentSession = currentSessionId
290297
? file.includes(currentSessionId.slice(0, 8))
291298
: false;
@@ -310,11 +317,11 @@ export const getAllSessionFiles = async (
310317

311318
const sessionInfo: SessionInfo = {
312319
id: content.sessionId,
313-
file: file.replace('.json', ''),
320+
file: file.replace(/\.jsonl?$/, ''),
314321
fileName: file,
315322
startTime: content.startTime,
316323
lastUpdated: content.lastUpdated,
317-
messageCount: content.messages.length,
324+
messageCount: content.messageCount ?? content.messages.length,
318325
displayName: content.summary
319326
? stripUnsafeCharacters(content.summary)
320327
: firstUserMessage,
@@ -505,10 +512,10 @@ export class SessionSelector {
505512
const sessionPath = path.join(chatsDir, sessionInfo.fileName);
506513

507514
try {
508-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
509-
const sessionData: ConversationRecord = JSON.parse(
510-
await fs.readFile(sessionPath, 'utf8'),
511-
);
515+
const sessionData = await loadConversationRecord(sessionPath);
516+
if (!sessionData) {
517+
throw new Error('Failed to load session data');
518+
}
512519

513520
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
514521

packages/core/src/agents/local-executor.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ vi.mock('../core/geminiChat.js', () => ({
141141
CHUNK: 'chunk',
142142
},
143143
GeminiChat: vi.fn().mockImplementation(() => ({
144+
initialize: vi.fn(),
144145
sendMessageStream: mockSendMessageStream,
145146
getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]),
146147
setHistory: mockSetHistory,
@@ -434,6 +435,7 @@ describe('LocalAgentExecutor', () => {
434435
MockedGeminiChat.mockImplementation(
435436
() =>
436437
({
438+
initialize: vi.fn(),
437439
sendMessageStream: mockSendMessageStream,
438440
setSystemInstruction: mockSetSystemInstruction,
439441
getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]),

packages/core/src/agents/local-executor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,15 +1021,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
10211021
: undefined;
10221022

10231023
try {
1024-
return new GeminiChat(
1024+
const chat = new GeminiChat(
10251025
this.executionContext,
10261026
systemInstruction,
10271027
[{ functionDeclarations: tools }],
10281028
startHistory,
10291029
undefined,
10301030
undefined,
1031-
'subagent',
10321031
);
1032+
await chat.initialize(undefined, 'subagent');
1033+
return chat;
10331034
} catch (e: unknown) {
10341035
await reportError(
10351036
e,

packages/core/src/config/storage.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,9 @@ export class Storage {
353353
const chatsDir = path.join(this.getProjectTempDir(), 'chats');
354354
try {
355355
const files = await fs.promises.readdir(chatsDir);
356-
const jsonFiles = files.filter((f) => f.endsWith('.json'));
356+
const jsonFiles = files.filter(
357+
(f) => f.endsWith('.json') || f.endsWith('.jsonl'),
358+
);
357359

358360
const sessions = await Promise.all(
359361
jsonFiles.map(async (file) => {

packages/core/src/core/client.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ vi.mock('node:fs', () => {
6363
writeFileSync: vi.fn((path: string, data: string) => {
6464
mockFileSystem.set(path, data);
6565
}),
66+
appendFileSync: vi.fn((path: string, data: string) => {
67+
const current = mockFileSystem.get(path) || '';
68+
mockFileSystem.set(path, current + data);
69+
}),
6670
readFileSync: vi.fn((path: string) => {
6771
if (mockFileSystem.has(path)) {
6872
return mockFileSystem.get(path);

packages/core/src/core/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ export class GeminiClient {
378378
try {
379379
const systemMemory = this.config.getSystemInstructionMemory();
380380
const systemInstruction = getCoreSystemPrompt(this.config, systemMemory);
381-
return new GeminiChat(
381+
const chat = new GeminiChat(
382382
this.config,
383383
systemInstruction,
384384
tools,
@@ -392,6 +392,8 @@ export class GeminiClient {
392392
return [{ functionDeclarations: toolDeclarations }];
393393
},
394394
);
395+
await chat.initialize(resumedSessionData, 'main');
396+
return chat;
395397
} catch (error) {
396398
await reportError(
397399
error,

packages/core/src/core/geminiChat.test.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ vi.mock('node:fs', () => {
4848
writeFileSync: vi.fn((path: string, data: string) => {
4949
mockFileSystem.set(path, data);
5050
}),
51+
appendFileSync: vi.fn((path: string, data: string) => {
52+
const current = mockFileSystem.get(path) || '';
53+
mockFileSystem.set(path, current + data);
54+
}),
5155
readFileSync: vi.fn((path: string) => {
5256
if (mockFileSystem.has(path)) {
5357
return mockFileSystem.get(path);
@@ -1082,8 +1086,10 @@ describe('GeminiChat', () => {
10821086
);
10831087

10841088
const { default: fs } = await import('node:fs');
1085-
const writeFileSync = vi.mocked(fs.writeFileSync);
1086-
const writeCountBefore = writeFileSync.mock.calls.length;
1089+
const appendFileSync = vi.mocked(fs.appendFileSync);
1090+
const writeCountBefore = appendFileSync.mock.calls.length;
1091+
1092+
await chat.initialize();
10871093

10881094
const stream = await chat.sendMessageStream(
10891095
{ model: 'test-model' },
@@ -1096,17 +1102,19 @@ describe('GeminiChat', () => {
10961102
// consume
10971103
}
10981104

1099-
const newWrites = writeFileSync.mock.calls.slice(writeCountBefore);
1105+
const newWrites = appendFileSync.mock.calls.slice(writeCountBefore);
11001106
expect(newWrites.length).toBeGreaterThan(0);
11011107

1102-
const lastWriteData = JSON.parse(
1103-
newWrites[newWrites.length - 1][1] as string,
1104-
) as { messages: Array<{ type: string }> };
1108+
const geminiWrite = newWrites.find((w) => {
1109+
try {
1110+
const data = JSON.parse(w[1] as string);
1111+
return data.type === 'gemini';
1112+
} catch {
1113+
return false;
1114+
}
1115+
});
11051116

1106-
const geminiMessages = lastWriteData.messages.filter(
1107-
(m) => m.type === 'gemini',
1108-
);
1109-
expect(geminiMessages.length).toBeGreaterThan(0);
1117+
expect(geminiWrite).toBeDefined();
11101118
});
11111119
});
11121120

packages/core/src/core/geminiChat.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,16 +256,21 @@ export class GeminiChat {
256256
private history: Content[] = [],
257257
resumedSessionData?: ResumedSessionData,
258258
private readonly onModelChanged?: (modelId: string) => Promise<Tool[]>,
259-
kind: 'main' | 'subagent' = 'main',
260259
) {
261260
validateHistory(history);
262261
this.chatRecordingService = new ChatRecordingService(context);
263-
this.chatRecordingService.initialize(resumedSessionData, kind);
264262
this.lastPromptTokenCount = estimateTokenCountSync(
265263
this.history.flatMap((c) => c.parts || []),
266264
);
267265
}
268266

267+
async initialize(
268+
resumedSessionData?: ResumedSessionData,
269+
kind: 'main' | 'subagent' = 'main',
270+
) {
271+
await this.chatRecordingService.initialize(resumedSessionData, kind);
272+
}
273+
269274
setSystemInstruction(sysInstr: string) {
270275
this.systemInstruction = sysInstr;
271276
}

0 commit comments

Comments
 (0)