feat(core): migrate chat recording to JSONL streaming (#23749)

This commit is contained in:
Spencer
2026-04-09 17:13:55 -04:00
committed by GitHub
parent 45100f7c0e
commit f744913584
15 changed files with 906 additions and 665 deletions
@@ -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';
}