mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat(core): migrate chat recording to JSONL streaming (#23749)
This commit is contained in:
@@ -11,7 +11,6 @@ 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 {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
type ConversationRecord,
|
||||
type MessageRecord,
|
||||
CoreToolCallStatus,
|
||||
loadConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
@@ -46,6 +46,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
clear: vi.fn(),
|
||||
hydrate: vi.fn(),
|
||||
},
|
||||
loadConversationRecord: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
@@ -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',
|
||||
@@ -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),
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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(
|
||||
(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
|
||||
) {
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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)})`;
|
||||
|
||||
|
||||
@@ -141,6 +141,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,
|
||||
@@ -434,6 +435,7 @@ describe('LocalAgentExecutor', () => {
|
||||
MockedGeminiChat.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
initialize: vi.fn(),
|
||||
sendMessageStream: mockSendMessageStream,
|
||||
setSystemInstruction: mockSetSystemInstruction,
|
||||
getHistory: vi.fn((_curated?: boolean) => [...mockChatHistory]),
|
||||
|
||||
@@ -1021,15 +1021,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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -392,6 +392,8 @@ export class GeminiClient {
|
||||
return [{ functionDeclarations: toolDeclarations }];
|
||||
},
|
||||
);
|
||||
await chat.initialize(resumedSessionData, 'main');
|
||||
return chat;
|
||||
} catch (error) {
|
||||
await reportError(
|
||||
error,
|
||||
|
||||
@@ -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);
|
||||
@@ -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' },
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,42 @@
|
||||
*/
|
||||
|
||||
import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import * as fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const fsModule = {
|
||||
...actual,
|
||||
mkdirSync: vi.fn(actual.mkdirSync),
|
||||
appendFileSync: vi.fn(actual.appendFileSync),
|
||||
writeFileSync: vi.fn(actual.writeFileSync),
|
||||
readFileSync: vi.fn(actual.readFileSync),
|
||||
unlinkSync: vi.fn(actual.unlinkSync),
|
||||
existsSync: vi.fn(actual.existsSync),
|
||||
readdirSync: vi.fn(actual.readdirSync),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
stat: vi.fn(actual.promises.stat),
|
||||
readFile: vi.fn(actual.promises.readFile),
|
||||
unlink: vi.fn(actual.promises.unlink),
|
||||
readdir: vi.fn(actual.promises.readdir),
|
||||
open: vi.fn(actual.promises.open),
|
||||
rm: vi.fn(actual.promises.rm),
|
||||
mkdir: vi.fn(actual.promises.mkdir),
|
||||
writeFile: vi.fn(actual.promises.writeFile),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...fsModule,
|
||||
default: fsModule,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
ChatRecordingService,
|
||||
loadConversationRecord,
|
||||
type ConversationRecord,
|
||||
type ToolCallRecord,
|
||||
type MessageRecord,
|
||||
@@ -21,9 +52,11 @@ import type { Config } from '../config/config.js';
|
||||
import { getProjectHash } from '../utils/paths.js';
|
||||
|
||||
vi.mock('../utils/paths.js');
|
||||
vi.mock('node:crypto', () => {
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:crypto')>();
|
||||
let count = 0;
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: vi.fn(() => `test-uuid-${count++}`),
|
||||
createHash: vi.fn(() => ({
|
||||
update: vi.fn(() => ({
|
||||
@@ -38,6 +71,9 @@ describe('ChatRecordingService', () => {
|
||||
let mockConfig: Config;
|
||||
let testTempDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
testTempDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'chat-recording-test-'),
|
||||
@@ -89,8 +125,8 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should create a new session if none is provided', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should create a new session if none is provided', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'ping',
|
||||
@@ -101,11 +137,11 @@ describe('ChatRecordingService', () => {
|
||||
expect(fs.existsSync(chatsDir)).toBe(true);
|
||||
const files = fs.readdirSync(chatsDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toMatch(/^session-.*-test-ses\.json$/);
|
||||
expect(files[0]).toMatch(/^session-.*-test-ses\.jsonl$/);
|
||||
});
|
||||
|
||||
it('should include the conversation kind when specified', () => {
|
||||
chatRecordingService.initialize(undefined, 'subagent');
|
||||
it('should include the conversation kind when specified', async () => {
|
||||
await chatRecordingService.initialize(undefined, 'subagent');
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'ping',
|
||||
@@ -113,13 +149,13 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.kind).toBe('subagent');
|
||||
});
|
||||
|
||||
it('should create a subdirectory for subagents if parentSessionId is present', () => {
|
||||
it('should create a subdirectory for subagents if parentSessionId is present', async () => {
|
||||
const parentSessionId = 'test-parent-uuid';
|
||||
Object.defineProperty(mockConfig, 'parentSessionId', {
|
||||
value: parentSessionId,
|
||||
@@ -127,7 +163,7 @@ describe('ChatRecordingService', () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
chatRecordingService.initialize(undefined, 'subagent');
|
||||
await chatRecordingService.initialize(undefined, 'subagent');
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'ping',
|
||||
@@ -140,19 +176,19 @@ describe('ChatRecordingService', () => {
|
||||
|
||||
const files = fs.readdirSync(subagentDir);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toBe('test-session-id.json');
|
||||
expect(files[0]).toBe('test-session-id.jsonl');
|
||||
});
|
||||
|
||||
it('should inherit workspace directories for subagents during initialization', () => {
|
||||
it('should inherit workspace directories for subagents during initialization', async () => {
|
||||
const mockDirectories = ['/project/dir1', '/project/dir2'];
|
||||
vi.mocked(mockConfig.getWorkspaceContext).mockReturnValue({
|
||||
getDirectories: vi.fn().mockReturnValue(mockDirectories),
|
||||
} as unknown as WorkspaceContext);
|
||||
|
||||
// Initialize as a subagent
|
||||
chatRecordingService.initialize(undefined, 'subagent');
|
||||
await chatRecordingService.initialize(undefined, 'subagent');
|
||||
|
||||
// Recording a message triggers the disk write (deferred until then)
|
||||
// Recording a message triggers the disk write
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'ping',
|
||||
@@ -160,43 +196,53 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
|
||||
expect(conversation.kind).toBe('subagent');
|
||||
expect(conversation.directories).toEqual(mockDirectories);
|
||||
});
|
||||
|
||||
it('should resume from an existing session if provided', () => {
|
||||
it('should resume from an existing session if provided', async () => {
|
||||
const chatsDir = path.join(testTempDir, 'chats');
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const sessionFile = path.join(chatsDir, 'session.json');
|
||||
const sessionFile = path.join(chatsDir, 'session.jsonl');
|
||||
const initialData = {
|
||||
sessionId: 'old-session-id',
|
||||
projectHash: 'test-project-hash',
|
||||
messages: [],
|
||||
};
|
||||
fs.writeFileSync(sessionFile, JSON.stringify(initialData));
|
||||
fs.writeFileSync(
|
||||
sessionFile,
|
||||
JSON.stringify({ ...initialData, messages: undefined }) +
|
||||
'\n' +
|
||||
(initialData.messages || [])
|
||||
.map((m: unknown) => JSON.stringify(m))
|
||||
.join('\n') +
|
||||
'\n',
|
||||
);
|
||||
|
||||
chatRecordingService.initialize({
|
||||
await chatRecordingService.initialize({
|
||||
filePath: sessionFile,
|
||||
conversation: {
|
||||
sessionId: 'old-session-id',
|
||||
} as ConversationRecord,
|
||||
});
|
||||
|
||||
const conversation = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.sessionId).toBe('old-session-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordMessage', () => {
|
||||
beforeEach(() => {
|
||||
chatRecordingService.initialize();
|
||||
beforeEach(async () => {
|
||||
await chatRecordingService.initialize();
|
||||
});
|
||||
|
||||
it('should record a new message', () => {
|
||||
it('should record a new message', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'Hello',
|
||||
@@ -205,9 +251,9 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
|
||||
expect(conversation.messages).toHaveLength(1);
|
||||
expect(conversation.messages[0].content).toBe('Hello');
|
||||
@@ -215,7 +261,7 @@ describe('ChatRecordingService', () => {
|
||||
expect(conversation.messages[0].type).toBe('user');
|
||||
});
|
||||
|
||||
it('should create separate messages when recording multiple messages', () => {
|
||||
it('should create separate messages when recording multiple messages', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'World',
|
||||
@@ -223,17 +269,17 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.messages).toHaveLength(1);
|
||||
expect(conversation.messages[0].content).toBe('World');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordThought', () => {
|
||||
it('should queue a thought', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should queue a thought', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
chatRecordingService.recordThought({
|
||||
subject: 'Thinking',
|
||||
description: 'Thinking...',
|
||||
@@ -246,11 +292,11 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
describe('recordMessageTokens', () => {
|
||||
beforeEach(() => {
|
||||
chatRecordingService.initialize();
|
||||
beforeEach(async () => {
|
||||
await chatRecordingService.initialize();
|
||||
});
|
||||
|
||||
it('should update the last message with token info', () => {
|
||||
it('should update the last message with token info', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
content: 'Response',
|
||||
@@ -265,9 +311,9 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
const geminiMsg = conversation.messages[0] as MessageRecord & {
|
||||
type: 'gemini';
|
||||
};
|
||||
@@ -281,7 +327,7 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue token info if the last message already has tokens', () => {
|
||||
it('should queue token info if the last message already has tokens', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
content: 'Response',
|
||||
@@ -313,11 +359,11 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not write to disk when queuing tokens (no last gemini message)', () => {
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
it('should not write to disk when queuing tokens (no last gemini message)', async () => {
|
||||
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
||||
|
||||
// Clear spy call count after initialize writes the initial file
|
||||
writeFileSyncSpy.mockClear();
|
||||
appendFileSyncSpy.mockClear();
|
||||
|
||||
// No gemini message recorded yet, so tokens should only be queued
|
||||
chatRecordingService.recordMessageTokens({
|
||||
@@ -328,7 +374,7 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
// writeFileSync should NOT have been called since we only queued
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalled();
|
||||
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
||||
|
||||
// @ts-expect-error private property
|
||||
expect(chatRecordingService.queuedTokens).toEqual({
|
||||
@@ -339,11 +385,9 @@ describe('ChatRecordingService', () => {
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
});
|
||||
|
||||
writeFileSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not write to disk when queuing tokens (last message already has tokens)', () => {
|
||||
it('should not write to disk when queuing tokens (last message already has tokens)', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
content: 'Response',
|
||||
@@ -358,8 +402,8 @@ describe('ChatRecordingService', () => {
|
||||
cachedContentTokenCount: 0,
|
||||
});
|
||||
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
writeFileSyncSpy.mockClear();
|
||||
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
||||
appendFileSyncSpy.mockClear();
|
||||
|
||||
// Second call should only queue, NOT write to disk
|
||||
chatRecordingService.recordMessageTokens({
|
||||
@@ -369,18 +413,17 @@ describe('ChatRecordingService', () => {
|
||||
cachedContentTokenCount: 0,
|
||||
});
|
||||
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalled();
|
||||
writeFileSyncSpy.mockRestore();
|
||||
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use in-memory cache and not re-read from disk on subsequent operations', () => {
|
||||
it('should use in-memory cache and not re-read from disk on subsequent operations', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
content: 'Response',
|
||||
model: 'gemini-pro',
|
||||
});
|
||||
|
||||
const readFileSyncSpy = vi.spyOn(fs, 'readFileSync');
|
||||
const readFileSyncSpy = vi.mocked(fs.readFileSync);
|
||||
readFileSyncSpy.mockClear();
|
||||
|
||||
// These operations should all use the in-memory cache
|
||||
@@ -401,16 +444,15 @@ describe('ChatRecordingService', () => {
|
||||
|
||||
// readFileSync should NOT have been called since we use the in-memory cache
|
||||
expect(readFileSyncSpy).not.toHaveBeenCalled();
|
||||
readFileSyncSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordToolCalls', () => {
|
||||
beforeEach(() => {
|
||||
chatRecordingService.initialize();
|
||||
beforeEach(async () => {
|
||||
await chatRecordingService.initialize();
|
||||
});
|
||||
|
||||
it('should add new tool calls to the last message', () => {
|
||||
it('should add new tool calls to the last message', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
content: '',
|
||||
@@ -427,9 +469,9 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
const geminiMsg = conversation.messages[0] as MessageRecord & {
|
||||
type: 'gemini';
|
||||
};
|
||||
@@ -437,7 +479,7 @@ describe('ChatRecordingService', () => {
|
||||
expect(geminiMsg.toolCalls![0].name).toBe('testTool');
|
||||
});
|
||||
|
||||
it('should preserve dynamic description and NOT overwrite with generic one', () => {
|
||||
it('should preserve dynamic description and NOT overwrite with generic one', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
content: '',
|
||||
@@ -457,9 +499,9 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
const geminiMsg = conversation.messages[0] as MessageRecord & {
|
||||
type: 'gemini';
|
||||
};
|
||||
@@ -467,7 +509,7 @@ describe('ChatRecordingService', () => {
|
||||
expect(geminiMsg.toolCalls![0].description).toBe(dynamicDescription);
|
||||
});
|
||||
|
||||
it('should create a new message if the last message is not from gemini', () => {
|
||||
it('should create a new message if the last message is not from gemini', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'call a tool',
|
||||
@@ -484,9 +526,9 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.messages).toHaveLength(2);
|
||||
expect(conversation.messages[1].type).toBe('gemini');
|
||||
expect(
|
||||
@@ -513,9 +555,9 @@ describe('ChatRecordingService', () => {
|
||||
// Create main session file with timestamp
|
||||
const sessionFile = path.join(
|
||||
chatsDir,
|
||||
`session-2023-01-01T00-00-${shortId}.json`,
|
||||
`session-2023-01-01T00-00-${shortId}.jsonl`,
|
||||
);
|
||||
fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }));
|
||||
fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }) + '\n');
|
||||
|
||||
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
|
||||
fs.writeFileSync(logFile, '{}');
|
||||
@@ -547,20 +589,21 @@ describe('ChatRecordingService', () => {
|
||||
// Create parent session file
|
||||
const parentFile = path.join(
|
||||
chatsDir,
|
||||
`session-2023-01-01T00-00-${shortId}.json`,
|
||||
`session-2023-01-01T00-00-${shortId}.jsonl`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
parentFile,
|
||||
JSON.stringify({ sessionId: parentSessionId }),
|
||||
JSON.stringify({ sessionId: parentSessionId }) + '\n',
|
||||
);
|
||||
|
||||
// Create subagent session file in subdirectory
|
||||
const subagentDir = path.join(chatsDir, parentSessionId);
|
||||
fs.mkdirSync(subagentDir, { recursive: true });
|
||||
const subagentFile = path.join(subagentDir, `${subagentSessionId}.json`);
|
||||
const subagentFile = path.join(subagentDir, `${subagentSessionId}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
subagentFile,
|
||||
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }),
|
||||
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }) +
|
||||
'\n',
|
||||
);
|
||||
|
||||
// Create logs for both
|
||||
@@ -609,21 +652,22 @@ describe('ChatRecordingService', () => {
|
||||
// Create parent session file
|
||||
const parentFile = path.join(
|
||||
chatsDir,
|
||||
`session-2023-01-01T00-00-${shortId}.json`,
|
||||
`session-2023-01-01T00-00-${shortId}.jsonl`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
parentFile,
|
||||
JSON.stringify({ sessionId: parentSessionId }),
|
||||
JSON.stringify({ sessionId: parentSessionId }) + '\n',
|
||||
);
|
||||
|
||||
// Create legacy subagent session file (flat in chatsDir)
|
||||
const subagentFile = path.join(
|
||||
chatsDir,
|
||||
`session-2023-01-01T00-01-${shortId}.json`,
|
||||
`session-2023-01-01T00-01-${shortId}.jsonl`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
subagentFile,
|
||||
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }),
|
||||
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }) +
|
||||
'\n',
|
||||
);
|
||||
|
||||
// Call with parent sessionId
|
||||
@@ -643,8 +687,8 @@ describe('ChatRecordingService', () => {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
const basename = `session-2023-01-01T00-00-${shortId}`;
|
||||
const sessionFile = path.join(chatsDir, `${basename}.json`);
|
||||
fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }));
|
||||
const sessionFile = path.join(chatsDir, `${basename}.jsonl`);
|
||||
fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }) + '\n');
|
||||
|
||||
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
|
||||
fs.writeFileSync(logFile, '{}');
|
||||
@@ -664,11 +708,11 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
describe('recordDirectories', () => {
|
||||
beforeEach(() => {
|
||||
chatRecordingService.initialize();
|
||||
beforeEach(async () => {
|
||||
await chatRecordingService.initialize();
|
||||
});
|
||||
|
||||
it('should save directories to the conversation', () => {
|
||||
it('should save directories to the conversation', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'ping',
|
||||
@@ -680,16 +724,16 @@ describe('ChatRecordingService', () => {
|
||||
]);
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.directories).toEqual([
|
||||
'/path/to/dir1',
|
||||
'/path/to/dir2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should overwrite existing directories', () => {
|
||||
it('should overwrite existing directories', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'ping',
|
||||
@@ -699,16 +743,16 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.recordDirectories(['/new/dir1', '/new/dir2']);
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.directories).toEqual(['/new/dir1', '/new/dir2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewindTo', () => {
|
||||
it('should rewind the conversation to a specific message ID', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should rewind the conversation to a specific message ID', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
// Record some messages
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
@@ -727,9 +771,9 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
let conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
let conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
const secondMsgId = conversation.messages[1].id;
|
||||
|
||||
const result = chatRecordingService.rewindTo(secondMsgId);
|
||||
@@ -738,14 +782,14 @@ describe('ChatRecordingService', () => {
|
||||
expect(result!.messages).toHaveLength(1);
|
||||
expect(result!.messages[0].content).toBe('msg1');
|
||||
|
||||
conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
expect(conversation.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return the original conversation if the message ID is not found', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should return the original conversation if the message ID is not found', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
content: 'msg1',
|
||||
@@ -760,33 +804,31 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
describe('ENOSPC (disk full) graceful degradation - issue #16266', () => {
|
||||
it('should disable recording and not throw when ENOSPC occurs during initialize', () => {
|
||||
it('should disable recording and not throw when ENOSPC occurs during initialize', async () => {
|
||||
const enospcError = new Error('ENOSPC: no space left on device');
|
||||
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
||||
|
||||
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => {
|
||||
const mkdirSyncSpy = vi.mocked(fs.mkdirSync).mockImplementation(() => {
|
||||
throw enospcError;
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => chatRecordingService.initialize()).not.toThrow();
|
||||
await expect(chatRecordingService.initialize()).resolves.not.toThrow();
|
||||
|
||||
// Recording should be disabled (conversationFile set to null)
|
||||
expect(chatRecordingService.getConversationFilePath()).toBeNull();
|
||||
mkdirSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should disable recording and not throw when ENOSPC occurs during writeConversation', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should disable recording and not throw when ENOSPC occurs during writeConversation', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
|
||||
const enospcError = new Error('ENOSPC: no space left on device');
|
||||
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
||||
|
||||
const writeFileSyncSpy = vi
|
||||
.spyOn(fs, 'writeFileSync')
|
||||
.mockImplementation(() => {
|
||||
throw enospcError;
|
||||
});
|
||||
vi.mocked(fs.appendFileSync).mockImplementation(() => {
|
||||
throw enospcError;
|
||||
});
|
||||
|
||||
// Should not throw when recording a message
|
||||
expect(() =>
|
||||
@@ -799,17 +841,16 @@ describe('ChatRecordingService', () => {
|
||||
|
||||
// Recording should be disabled (conversationFile set to null)
|
||||
expect(chatRecordingService.getConversationFilePath()).toBeNull();
|
||||
writeFileSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip recording operations when recording is disabled', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should skip recording operations when recording is disabled', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
|
||||
const enospcError = new Error('ENOSPC: no space left on device');
|
||||
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
||||
|
||||
const writeFileSyncSpy = vi
|
||||
.spyOn(fs, 'writeFileSync')
|
||||
const appendFileSyncSpy = vi
|
||||
.mocked(fs.appendFileSync)
|
||||
.mockImplementationOnce(() => {
|
||||
throw enospcError;
|
||||
});
|
||||
@@ -821,7 +862,7 @@ describe('ChatRecordingService', () => {
|
||||
});
|
||||
|
||||
// Reset mock to track subsequent calls
|
||||
writeFileSyncSpy.mockClear();
|
||||
appendFileSyncSpy.mockClear();
|
||||
|
||||
// Subsequent calls should be no-ops (not call writeFileSync)
|
||||
chatRecordingService.recordMessage({
|
||||
@@ -838,21 +879,18 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.saveSummary('Test summary');
|
||||
|
||||
// writeFileSync should not have been called for any of these
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalled();
|
||||
writeFileSyncSpy.mockRestore();
|
||||
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null from getConversation when recording is disabled', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should return null from getConversation when recording is disabled', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
|
||||
const enospcError = new Error('ENOSPC: no space left on device');
|
||||
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
||||
|
||||
const writeFileSyncSpy = vi
|
||||
.spyOn(fs, 'writeFileSync')
|
||||
.mockImplementation(() => {
|
||||
throw enospcError;
|
||||
});
|
||||
vi.mocked(fs.appendFileSync).mockImplementation(() => {
|
||||
throw enospcError;
|
||||
});
|
||||
|
||||
// Trigger ENOSPC
|
||||
chatRecordingService.recordMessage({
|
||||
@@ -864,20 +902,17 @@ describe('ChatRecordingService', () => {
|
||||
// getConversation should return null when disabled
|
||||
expect(chatRecordingService.getConversation()).toBeNull();
|
||||
expect(chatRecordingService.getConversationFilePath()).toBeNull();
|
||||
writeFileSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should still throw for non-ENOSPC errors', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should still throw for non-ENOSPC errors', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
|
||||
const otherError = new Error('Permission denied');
|
||||
(otherError as NodeJS.ErrnoException).code = 'EACCES';
|
||||
|
||||
const writeFileSyncSpy = vi
|
||||
.spyOn(fs, 'writeFileSync')
|
||||
.mockImplementation(() => {
|
||||
throw otherError;
|
||||
});
|
||||
vi.mocked(fs.appendFileSync).mockImplementation(() => {
|
||||
throw otherError;
|
||||
});
|
||||
|
||||
// Should throw for non-ENOSPC errors
|
||||
expect(() =>
|
||||
@@ -890,16 +925,15 @@ describe('ChatRecordingService', () => {
|
||||
|
||||
// Recording should NOT be disabled for non-ENOSPC errors (file path still exists)
|
||||
expect(chatRecordingService.getConversationFilePath()).not.toBeNull();
|
||||
writeFileSyncSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMessagesFromHistory', () => {
|
||||
beforeEach(() => {
|
||||
chatRecordingService.initialize();
|
||||
beforeEach(async () => {
|
||||
await chatRecordingService.initialize();
|
||||
});
|
||||
|
||||
it('should update tool results from API history (masking sync)', () => {
|
||||
it('should update tool results from API history (masking sync)', async () => {
|
||||
// 1. Record an initial message and tool call
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
@@ -949,9 +983,9 @@ describe('ChatRecordingService', () => {
|
||||
|
||||
// 4. Verify disk content
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
|
||||
const geminiMsg = conversation.messages[0];
|
||||
if (geminiMsg.type !== 'gemini')
|
||||
@@ -968,8 +1002,8 @@ describe('ChatRecordingService', () => {
|
||||
output: maskedSnippet,
|
||||
});
|
||||
});
|
||||
it('should preserve multi-modal sibling parts during sync', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should preserve multi-modal sibling parts during sync', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
const callId = 'multi-modal-call';
|
||||
const originalResult: Part[] = [
|
||||
{
|
||||
@@ -1019,9 +1053,9 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.updateMessagesFromHistory(history);
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
|
||||
const lastMsg = conversation.messages[0] as MessageRecord & {
|
||||
type: 'gemini';
|
||||
@@ -1035,8 +1069,8 @@ describe('ChatRecordingService', () => {
|
||||
expect(result[1].inlineData!.mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle parts appearing BEFORE the functionResponse in a content block', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should handle parts appearing BEFORE the functionResponse in a content block', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
const callId = 'prefix-part-call';
|
||||
|
||||
chatRecordingService.recordMessage({
|
||||
@@ -1075,9 +1109,9 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.updateMessagesFromHistory(history);
|
||||
|
||||
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
||||
const conversation = JSON.parse(
|
||||
fs.readFileSync(sessionFile, 'utf8'),
|
||||
) as ConversationRecord;
|
||||
const conversation = (await loadConversationRecord(
|
||||
sessionFile,
|
||||
)) as ConversationRecord;
|
||||
|
||||
const lastMsg = conversation.messages[0] as MessageRecord & {
|
||||
type: 'gemini';
|
||||
@@ -1088,15 +1122,15 @@ describe('ChatRecordingService', () => {
|
||||
expect(result[1].functionResponse!.id).toBe(callId);
|
||||
});
|
||||
|
||||
it('should not write to disk when no tool calls match', () => {
|
||||
it('should not write to disk when no tool calls match', async () => {
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'gemini',
|
||||
content: 'Response with no tool calls',
|
||||
model: 'gemini-pro',
|
||||
});
|
||||
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
writeFileSyncSpy.mockClear();
|
||||
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
||||
appendFileSyncSpy.mockClear();
|
||||
|
||||
// History with a tool call ID that doesn't exist in the conversation
|
||||
const history: Content[] = [
|
||||
@@ -1117,17 +1151,16 @@ describe('ChatRecordingService', () => {
|
||||
chatRecordingService.updateMessagesFromHistory(history);
|
||||
|
||||
// No tool calls matched, so writeFileSync should NOT have been called
|
||||
expect(writeFileSyncSpy).not.toHaveBeenCalled();
|
||||
writeFileSyncSpy.mockRestore();
|
||||
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ENOENT (missing directory) handling', () => {
|
||||
it('should ensure directory exists before writing conversation file', () => {
|
||||
chatRecordingService.initialize();
|
||||
it('should ensure directory exists before writing conversation file', async () => {
|
||||
await chatRecordingService.initialize();
|
||||
|
||||
const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync');
|
||||
const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync');
|
||||
const mkdirSyncSpy = vi.mocked(fs.mkdirSync);
|
||||
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
||||
|
||||
chatRecordingService.recordMessage({
|
||||
type: 'user',
|
||||
@@ -1144,13 +1177,12 @@ describe('ChatRecordingService', () => {
|
||||
|
||||
// mkdirSync should be called before writeFileSync
|
||||
const mkdirCallOrder = mkdirSyncSpy.mock.invocationCallOrder;
|
||||
const writeCallOrder = writeFileSyncSpy.mock.invocationCallOrder;
|
||||
const writeCallOrder = appendFileSyncSpy.mock.invocationCallOrder;
|
||||
const lastMkdir = mkdirCallOrder[mkdirCallOrder.length - 1];
|
||||
const lastWrite = writeCallOrder[writeCallOrder.length - 1];
|
||||
expect(lastMkdir).toBeLessThan(lastWrite);
|
||||
|
||||
mkdirSyncSpy.mockRestore();
|
||||
writeFileSyncSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import type { Status } from '../scheduler/types.js';
|
||||
import type { ToolResultDisplay } from '../tools/tools.js';
|
||||
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
|
||||
|
||||
export const SESSION_FILE_PREFIX = 'session-';
|
||||
export const MAX_HISTORY_MESSAGES = 50;
|
||||
export const MAX_TOOL_OUTPUT_SIZE = 50 * 1024; // 50KB
|
||||
|
||||
/**
|
||||
* Token usage summary for a message or conversation.
|
||||
*/
|
||||
export interface TokensSummary {
|
||||
input: number; // promptTokenCount
|
||||
output: number; // candidatesTokenCount
|
||||
cached: number; // cachedContentTokenCount
|
||||
thoughts?: number; // thoughtsTokenCount
|
||||
tool?: number; // toolUsePromptTokenCount
|
||||
total: number; // totalTokenCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Base fields common to all messages.
|
||||
*/
|
||||
export interface BaseMessageRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
content: PartListUnion;
|
||||
displayContent?: PartListUnion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record of a tool call execution within a conversation.
|
||||
*/
|
||||
export interface ToolCallRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: PartListUnion | null;
|
||||
status: Status;
|
||||
timestamp: string;
|
||||
// UI-specific fields for display purposes
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
resultDisplay?: ToolResultDisplay;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message type and message type-specific fields.
|
||||
*/
|
||||
export type ConversationRecordExtra =
|
||||
| {
|
||||
type: 'user' | 'info' | 'error' | 'warning';
|
||||
}
|
||||
| {
|
||||
type: 'gemini';
|
||||
toolCalls?: ToolCallRecord[];
|
||||
thoughts?: Array<ThoughtSummary & { timestamp: string }>;
|
||||
tokens?: TokensSummary | null;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single message record in a conversation.
|
||||
*/
|
||||
export type MessageRecord = BaseMessageRecord & ConversationRecordExtra;
|
||||
|
||||
/**
|
||||
* Complete conversation record stored in session files.
|
||||
*/
|
||||
export interface ConversationRecord {
|
||||
sessionId: string;
|
||||
projectHash: string;
|
||||
startTime: string;
|
||||
lastUpdated: string;
|
||||
messages: MessageRecord[];
|
||||
summary?: string;
|
||||
/** Workspace directories added during the session via /dir add */
|
||||
directories?: string[];
|
||||
/** The kind of conversation (main agent or subagent) */
|
||||
kind?: 'main' | 'subagent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Data structure for resuming an existing session.
|
||||
*/
|
||||
export interface ResumedSessionData {
|
||||
conversation: ConversationRecord;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a ConversationRecord from a JSONL session file.
|
||||
* Returns null if the file is invalid or cannot be read.
|
||||
*/
|
||||
export interface LoadConversationOptions {
|
||||
maxMessages?: number;
|
||||
metadataOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface RewindRecord {
|
||||
$rewindTo: string;
|
||||
}
|
||||
|
||||
export interface MetadataUpdateRecord {
|
||||
$set: Partial<ConversationRecord>;
|
||||
}
|
||||
|
||||
export interface PartialMetadataRecord {
|
||||
sessionId: string;
|
||||
projectHash: string;
|
||||
startTime?: string;
|
||||
lastUpdated?: string;
|
||||
summary?: string;
|
||||
directories?: string[];
|
||||
kind?: 'main' | 'subagent';
|
||||
}
|
||||
@@ -98,8 +98,11 @@ export async function deleteSubagentSessionDirAndArtifactsAsync(
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isFile() && file.name.endsWith('.json')) {
|
||||
const agentId = path.basename(file.name, '.json');
|
||||
if (
|
||||
file.isFile() &&
|
||||
(file.name.endsWith('.json') || file.name.endsWith('.jsonl'))
|
||||
) {
|
||||
const agentId = path.basename(file.name, path.extname(file.name));
|
||||
await deleteSessionArtifactsAsync(agentId, tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createSessionId,
|
||||
type ResumedSessionData,
|
||||
type ConversationRecord,
|
||||
loadConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { GeminiCliSession } from './session.js';
|
||||
@@ -55,9 +56,11 @@ export class GeminiCliAgent {
|
||||
const filesToCheck = candidates.length > 0 ? candidates : sessions;
|
||||
|
||||
for (const sessionFile of filesToCheck) {
|
||||
const loaded = await storage.loadProjectTempFile<ConversationRecord>(
|
||||
const absolutePath = path.join(
|
||||
storage.getProjectTempDir(),
|
||||
sessionFile.filePath,
|
||||
);
|
||||
const loaded = await loadConversationRecord(absolutePath);
|
||||
if (loaded && loaded.sessionId === sessionId) {
|
||||
conversation = loaded;
|
||||
filePath = path.join(storage.getProjectTempDir(), sessionFile.filePath);
|
||||
|
||||
Reference in New Issue
Block a user