Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c971dfb
feat(core): migrate chat recording to JSONL streaming
spencer426 Mar 25, 2026
5d48cd6
fix(core): address PR comments and optimize jsonl streaming memory
spencer426 Mar 25, 2026
f43806b
fix(core): ensure display property propagates memory truncation to CL…
spencer426 Mar 26, 2026
ed0a803
fix(core): remove bounding and truncation limits from jsonl migration
spencer426 Mar 27, 2026
b7f5b77
fix(core): resolve rebase conflicts and stabilize tests for JSONL mig…
spencer426 Apr 1, 2026
09d4d58
chore: clean up temporary and ignore files
spencer426 Apr 1, 2026
a50a292
fix(core): remove bounding and truncation limits from jsonl migration
spencer426 Apr 2, 2026
666592b
fix(core): resolve legacy json loading and hasUserOrAssistantMessage …
spencer426 Apr 8, 2026
0fdf580
fix(core): remove unsafe type assertions in chatRecordingService
spencer426 Apr 8, 2026
2c9f054
fix(cli): use loadConversationRecord in useSessionBrowser to support …
spencer426 Apr 8, 2026
d0737f9
fix(cli): correctly use messageCount from legacy payload instead of m…
spencer426 Apr 8, 2026
b0a84c9
fix(cli): use firstUserMessage from payload instead of re-extracting …
spencer426 Apr 8, 2026
46f5d7d
refactor(core): address PR comments, clean up service, and remove red…
spencer426 Apr 9, 2026
4369139
chore(core): update copyright year header to 2026
spencer426 Apr 9, 2026
3a454f1
test(core): fix local-executor tests after making GeminiChat initiali…
spencer426 Apr 9, 2026
36d16d7
fix(core): properly initialize subagent chat recording and migrate le…
spencer426 Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions packages/cli/src/ui/hooks/useSessionBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
useSessionBrowser,
convertSessionToHistoryFormats,
} from './useSessionBrowser.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js';
import {
type Config,
type ConversationRecord,
type MessageRecord,
CoreToolCallStatus,
loadConversationRecord,
} from '@google/gemini-cli-core';
import {
coreEvents,
Expand Down Expand Up @@ -46,6 +46,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
clear: vi.fn(),
hydrate: vi.fn(),
},
loadConversationRecord: vi.fn(),
};
});

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

describe('useSessionBrowser', () => {
const mockedFs = vi.mocked(fs);
const mockedPath = vi.mocked(path);
const mockedGetSessionFiles = vi.mocked(getSessionFiles);

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

const { result } = await renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
Expand All @@ -107,9 +107,8 @@ describe('useSessionBrowser', () => {
await act(async () => {
await result.current.handleResumeSession(mockSession);
});
expect(mockedFs.readFile).toHaveBeenCalledWith(
expect(loadConversationRecord).toHaveBeenCalledWith(
`${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`,
'utf8',
);
expect(mockConfig.setSessionId).toHaveBeenCalledWith(
'existing-session-456',
Expand All @@ -125,7 +124,9 @@ describe('useSessionBrowser', () => {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
} as SessionInfo;
mockedFs.readFile.mockRejectedValue(new Error('File not found'));
vi.mocked(loadConversationRecord).mockRejectedValue(
new Error('File not found'),
);

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

const { result } = await renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/ui/hooks/useSessionBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@

import { useState, useCallback } from 'react';
import type { HistoryItemWithoutId } from '../types.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
coreEvents,
convertSessionToClientHistory,
uiTelemetryService,
loadConversationRecord,
type Config,
type ConversationRecord,
type ResumedSessionData,
} from '@google/gemini-cli-core';
import {
Expand Down Expand Up @@ -61,10 +60,12 @@ export const useSessionBrowser = (
const originalFilePath = path.join(chatsDir, fileName);

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

// Use the old session's ID to continue it.
const existingSessionId = conversation.sessionId;
Expand Down
37 changes: 22 additions & 15 deletions packages/cli/src/utils/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type Storage,
type ConversationRecord,
type MessageRecord,
loadConversationRecord,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
Expand Down Expand Up @@ -250,23 +251,27 @@ export const getAllSessionFiles = async (
try {
const files = await fs.readdir(chatsDir);
const sessionFiles = files
.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'))
.filter(
Comment thread
spencer426 marked this conversation as resolved.
(f) =>
f.startsWith(SESSION_FILE_PREFIX) &&
(f.endsWith('.json') || f.endsWith('.jsonl')),
)
.sort(); // Sort by filename, which includes timestamp

const sessionPromises = sessionFiles.map(
async (file): Promise<SessionFileEntry> => {
const filePath = path.join(chatsDir, file);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const content: ConversationRecord = JSON.parse(
await fs.readFile(filePath, 'utf8'),
);
const content = await loadConversationRecord(filePath, {
metadataOnly: !options.includeFullContent,
});
if (!content) {
return { fileName: file, sessionInfo: null };
}

// Validate required fields
if (
!content.sessionId ||
!content.messages ||
!Array.isArray(content.messages) ||
!content.startTime ||
!content.lastUpdated
) {
Expand All @@ -275,7 +280,7 @@ export const getAllSessionFiles = async (
}

// Skip sessions that only contain system messages (info, error, warning)
if (!hasUserOrAssistantMessage(content.messages)) {
if (!content.hasUserOrAssistantMessage) {
return { fileName: file, sessionInfo: null };
}

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

const firstUserMessage = extractFirstUserMessage(content.messages);
const firstUserMessage = content.firstUserMessage
? cleanMessage(content.firstUserMessage)
: extractFirstUserMessage(content.messages);
const isCurrentSession = currentSessionId
? file.includes(currentSessionId.slice(0, 8))
: false;
Expand All @@ -310,11 +317,11 @@ export const getAllSessionFiles = async (

const sessionInfo: SessionInfo = {
id: content.sessionId,
file: file.replace('.json', ''),
file: file.replace(/\.jsonl?$/, ''),
fileName: file,
startTime: content.startTime,
lastUpdated: content.lastUpdated,
messageCount: content.messages.length,
messageCount: content.messageCount ?? content.messages.length,
displayName: content.summary
? stripUnsafeCharacters(content.summary)
: firstUserMessage,
Expand Down Expand Up @@ -505,10 +512,10 @@ export class SessionSelector {
const sessionPath = path.join(chatsDir, sessionInfo.fileName);

try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const sessionData: ConversationRecord = JSON.parse(
await fs.readFile(sessionPath, 'utf8'),
);
const sessionData = await loadConversationRecord(sessionPath);
if (!sessionData) {
throw new Error('Failed to load session data');
}

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

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/agents/local-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ vi.mock('../core/geminiChat.js', () => ({
CHUNK: 'chunk',
},
GeminiChat: vi.fn().mockImplementation(() => ({
initialize: vi.fn(),
sendMessageStream: mockSendMessageStream,
getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]),
setHistory: mockSetHistory,
Expand Down Expand Up @@ -433,6 +434,7 @@ describe('LocalAgentExecutor', () => {
MockedGeminiChat.mockImplementation(
() =>
({
initialize: vi.fn(),
sendMessageStream: mockSendMessageStream,
setSystemInstruction: mockSetSystemInstruction,
getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]),
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/agents/local-executor.ts
Comment thread
spencer426 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -1026,15 +1026,16 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
: undefined;

try {
return new GeminiChat(
const chat = new GeminiChat(
this.executionContext,
systemInstruction,
[{ functionDeclarations: tools }],
startHistory,
undefined,
undefined,
'subagent',
);
await chat.initialize(undefined, 'subagent');
return chat;
} catch (e: unknown) {
await reportError(
e,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/config/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,9 @@ export class Storage {
const chatsDir = path.join(this.getProjectTempDir(), 'chats');
try {
const files = await fs.promises.readdir(chatsDir);
const jsonFiles = files.filter((f) => f.endsWith('.json'));
const jsonFiles = files.filter(
(f) => f.endsWith('.json') || f.endsWith('.jsonl'),
);

const sessions = await Promise.all(
jsonFiles.map(async (file) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ vi.mock('node:fs', () => {
writeFileSync: vi.fn((path: string, data: string) => {
mockFileSystem.set(path, data);
}),
appendFileSync: vi.fn((path: string, data: string) => {
const current = mockFileSystem.get(path) || '';
mockFileSystem.set(path, current + data);
}),
readFileSync: vi.fn((path: string) => {
if (mockFileSystem.has(path)) {
return mockFileSystem.get(path);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ export class GeminiClient {
try {
const systemMemory = this.config.getSystemInstructionMemory();
const systemInstruction = getCoreSystemPrompt(this.config, systemMemory);
return new GeminiChat(
const chat = new GeminiChat(
this.config,
systemInstruction,
tools,
Expand All @@ -392,6 +392,8 @@ export class GeminiClient {
return [{ functionDeclarations: toolDeclarations }];
},
);
await chat.initialize(resumedSessionData, 'main');
return chat;
} catch (error) {
await reportError(
error,
Expand Down
28 changes: 18 additions & 10 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ vi.mock('node:fs', () => {
writeFileSync: vi.fn((path: string, data: string) => {
mockFileSystem.set(path, data);
}),
appendFileSync: vi.fn((path: string, data: string) => {
const current = mockFileSystem.get(path) || '';
mockFileSystem.set(path, current + data);
}),
readFileSync: vi.fn((path: string) => {
if (mockFileSystem.has(path)) {
return mockFileSystem.get(path);
Expand Down Expand Up @@ -1082,8 +1086,10 @@ describe('GeminiChat', () => {
);

const { default: fs } = await import('node:fs');
const writeFileSync = vi.mocked(fs.writeFileSync);
const writeCountBefore = writeFileSync.mock.calls.length;
const appendFileSync = vi.mocked(fs.appendFileSync);
const writeCountBefore = appendFileSync.mock.calls.length;

await chat.initialize();

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

const newWrites = writeFileSync.mock.calls.slice(writeCountBefore);
const newWrites = appendFileSync.mock.calls.slice(writeCountBefore);
expect(newWrites.length).toBeGreaterThan(0);

const lastWriteData = JSON.parse(
newWrites[newWrites.length - 1][1] as string,
) as { messages: Array<{ type: string }> };
const geminiWrite = newWrites.find((w) => {
try {
const data = JSON.parse(w[1] as string);
return data.type === 'gemini';
} catch {
return false;
}
});

const geminiMessages = lastWriteData.messages.filter(
(m) => m.type === 'gemini',
);
expect(geminiMessages.length).toBeGreaterThan(0);
expect(geminiWrite).toBeDefined();
});
});

Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,16 +256,21 @@ export class GeminiChat {
private history: Content[] = [],
resumedSessionData?: ResumedSessionData,
private readonly onModelChanged?: (modelId: string) => Promise<Tool[]>,
kind: 'main' | 'subagent' = 'main',
) {
validateHistory(history);
this.chatRecordingService = new ChatRecordingService(context);
this.chatRecordingService.initialize(resumedSessionData, kind);
this.lastPromptTokenCount = estimateTokenCountSync(
this.history.flatMap((c) => c.parts || []),
);
}

async initialize(
resumedSessionData?: ResumedSessionData,
kind: 'main' | 'subagent' = 'main',
) {
await this.chatRecordingService.initialize(resumedSessionData, kind);
}

setSystemInstruction(sysInstr: string) {
this.systemInstruction = sysInstr;
}
Expand Down
Loading
Loading