2025-08-18 18:39:57 -06:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2025-08-26 00:04:53 +02:00
|
|
|
import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest';
|
2026-04-09 17:13:55 -04:00
|
|
|
import * as fs from 'node:fs';
|
2025-08-18 18:39:57 -06:00
|
|
|
import path from 'node:path';
|
2026-02-06 01:36:42 -05:00
|
|
|
import os from 'node:os';
|
2026-04-09 17:13:55 -04:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-04 05:42:59 +05:30
|
|
|
import {
|
|
|
|
|
ChatRecordingService,
|
2026-04-09 17:13:55 -04:00
|
|
|
loadConversationRecord,
|
2026-03-04 05:42:59 +05:30
|
|
|
type ConversationRecord,
|
|
|
|
|
type ToolCallRecord,
|
|
|
|
|
type MessageRecord,
|
2025-08-18 18:39:57 -06:00
|
|
|
} from './chatRecordingService.js';
|
2026-04-01 11:29:38 -04:00
|
|
|
import type { WorkspaceContext } from '../utils/workspaceContext.js';
|
2026-02-13 11:27:20 -05:00
|
|
|
import { CoreToolCallStatus } from '../scheduler/types.js';
|
2026-02-06 16:22:22 -05:00
|
|
|
import type { Content, Part } from '@google/genai';
|
2025-08-26 00:04:53 +02:00
|
|
|
import type { Config } from '../config/config.js';
|
2025-08-18 18:39:57 -06:00
|
|
|
import { getProjectHash } from '../utils/paths.js';
|
|
|
|
|
|
|
|
|
|
vi.mock('../utils/paths.js');
|
2026-04-09 17:13:55 -04:00
|
|
|
vi.mock('node:crypto', async (importOriginal) => {
|
|
|
|
|
const actual = await importOriginal<typeof import('node:crypto')>();
|
2026-02-06 01:36:42 -05:00
|
|
|
let count = 0;
|
|
|
|
|
return {
|
2026-04-09 17:13:55 -04:00
|
|
|
...actual,
|
2026-02-06 01:36:42 -05:00
|
|
|
randomUUID: vi.fn(() => `test-uuid-${count++}`),
|
|
|
|
|
createHash: vi.fn(() => ({
|
|
|
|
|
update: vi.fn(() => ({
|
|
|
|
|
digest: vi.fn(() => 'mocked-hash'),
|
|
|
|
|
})),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
describe('ChatRecordingService', () => {
|
|
|
|
|
let chatRecordingService: ChatRecordingService;
|
|
|
|
|
let mockConfig: Config;
|
2026-02-06 01:36:42 -05:00
|
|
|
let testTempDir: string;
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
});
|
2026-02-06 01:36:42 -05:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
testTempDir = await fs.promises.mkdtemp(
|
|
|
|
|
path.join(os.tmpdir(), 'chat-recording-test-'),
|
|
|
|
|
);
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
mockConfig = {
|
2026-03-12 18:56:31 -07:00
|
|
|
get config() {
|
|
|
|
|
return this;
|
|
|
|
|
},
|
|
|
|
|
toolRegistry: {
|
|
|
|
|
getTool: vi.fn(),
|
|
|
|
|
},
|
|
|
|
|
promptId: 'test-session-id',
|
2025-08-18 18:39:57 -06:00
|
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
|
|
|
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
2025-08-20 10:55:47 +09:00
|
|
|
storage: {
|
2026-02-06 01:36:42 -05:00
|
|
|
getProjectTempDir: vi.fn().mockReturnValue(testTempDir),
|
2025-08-20 10:55:47 +09:00
|
|
|
},
|
2025-08-18 18:39:57 -06:00
|
|
|
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
|
|
|
|
getDebugMode: vi.fn().mockReturnValue(false),
|
2026-04-01 11:29:38 -04:00
|
|
|
getWorkspaceContext: vi.fn().mockReturnValue({
|
|
|
|
|
getDirectories: vi.fn().mockReturnValue([]),
|
|
|
|
|
}),
|
2025-09-02 23:29:07 -06:00
|
|
|
getToolRegistry: vi.fn().mockReturnValue({
|
|
|
|
|
getTool: vi.fn().mockReturnValue({
|
|
|
|
|
displayName: 'Test Tool',
|
|
|
|
|
description: 'A test tool',
|
|
|
|
|
isOutputMarkdown: false,
|
|
|
|
|
}),
|
|
|
|
|
}),
|
2025-08-18 18:39:57 -06:00
|
|
|
} as unknown as Config;
|
|
|
|
|
|
2026-04-01 11:29:38 -04:00
|
|
|
// Ensure mockConfig.config points to itself for AgentLoopContext parity
|
|
|
|
|
Object.defineProperty(mockConfig, 'config', {
|
|
|
|
|
get() {
|
|
|
|
|
return mockConfig;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-18 18:39:57 -06:00
|
|
|
vi.mocked(getProjectHash).mockReturnValue('test-project-hash');
|
|
|
|
|
chatRecordingService = new ChatRecordingService(mockConfig);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
afterEach(async () => {
|
2025-08-18 18:39:57 -06:00
|
|
|
vi.restoreAllMocks();
|
2026-02-06 01:36:42 -05:00
|
|
|
if (testTempDir) {
|
|
|
|
|
await fs.promises.rm(testTempDir, { recursive: true, force: true });
|
|
|
|
|
}
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('initialize', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should create a new session if none is provided', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'ping',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const chatsDir = path.join(testTempDir, 'chats');
|
|
|
|
|
expect(fs.existsSync(chatsDir)).toBe(true);
|
|
|
|
|
const files = fs.readdirSync(chatsDir);
|
|
|
|
|
expect(files.length).toBeGreaterThan(0);
|
2026-04-09 17:13:55 -04:00
|
|
|
expect(files[0]).toMatch(/^session-.*-test-ses\.jsonl$/);
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should include the conversation kind when specified', async () => {
|
|
|
|
|
await chatRecordingService.initialize(undefined, 'subagent');
|
2026-02-21 12:41:27 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'ping',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-21 12:41:27 -05:00
|
|
|
expect(conversation.kind).toBe('subagent');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should create a subdirectory for subagents if parentSessionId is present', async () => {
|
2026-03-26 23:43:39 -04:00
|
|
|
const parentSessionId = 'test-parent-uuid';
|
|
|
|
|
Object.defineProperty(mockConfig, 'parentSessionId', {
|
|
|
|
|
value: parentSessionId,
|
|
|
|
|
writable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
await chatRecordingService.initialize(undefined, 'subagent');
|
2026-03-26 23:43:39 -04:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'ping',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const chatsDir = path.join(testTempDir, 'chats');
|
|
|
|
|
const subagentDir = path.join(chatsDir, parentSessionId);
|
|
|
|
|
expect(fs.existsSync(subagentDir)).toBe(true);
|
|
|
|
|
|
|
|
|
|
const files = fs.readdirSync(subagentDir);
|
|
|
|
|
expect(files.length).toBeGreaterThan(0);
|
2026-04-09 17:13:55 -04:00
|
|
|
expect(files[0]).toBe('test-session-id.jsonl');
|
2026-03-26 23:43:39 -04:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should inherit workspace directories for subagents during initialization', async () => {
|
2026-04-01 11:29:38 -04:00
|
|
|
const mockDirectories = ['/project/dir1', '/project/dir2'];
|
|
|
|
|
vi.mocked(mockConfig.getWorkspaceContext).mockReturnValue({
|
|
|
|
|
getDirectories: vi.fn().mockReturnValue(mockDirectories),
|
|
|
|
|
} as unknown as WorkspaceContext);
|
|
|
|
|
|
|
|
|
|
// Initialize as a subagent
|
2026-04-09 17:13:55 -04:00
|
|
|
await chatRecordingService.initialize(undefined, 'subagent');
|
2026-04-01 11:29:38 -04:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
// Recording a message triggers the disk write
|
2026-04-01 11:29:38 -04:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'ping',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-04-01 11:29:38 -04:00
|
|
|
|
|
|
|
|
expect(conversation.kind).toBe('subagent');
|
|
|
|
|
expect(conversation.directories).toEqual(mockDirectories);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should resume from an existing session if provided', async () => {
|
2026-02-06 01:36:42 -05:00
|
|
|
const chatsDir = path.join(testTempDir, 'chats');
|
|
|
|
|
fs.mkdirSync(chatsDir, { recursive: true });
|
2026-04-09 17:13:55 -04:00
|
|
|
const sessionFile = path.join(chatsDir, 'session.jsonl');
|
2026-02-06 01:36:42 -05:00
|
|
|
const initialData = {
|
|
|
|
|
sessionId: 'old-session-id',
|
|
|
|
|
projectHash: 'test-project-hash',
|
|
|
|
|
messages: [],
|
|
|
|
|
};
|
2026-04-09 17:13:55 -04:00
|
|
|
fs.writeFileSync(
|
|
|
|
|
sessionFile,
|
|
|
|
|
JSON.stringify({ ...initialData, messages: undefined }) +
|
|
|
|
|
'\n' +
|
|
|
|
|
(initialData.messages || [])
|
|
|
|
|
.map((m: unknown) => JSON.stringify(m))
|
|
|
|
|
.join('\n') +
|
|
|
|
|
'\n',
|
|
|
|
|
);
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
await chatRecordingService.initialize({
|
2026-02-06 01:36:42 -05:00
|
|
|
filePath: sessionFile,
|
2025-08-18 18:39:57 -06:00
|
|
|
conversation: {
|
|
|
|
|
sessionId: 'old-session-id',
|
|
|
|
|
} as ConversationRecord,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 01:36:42 -05:00
|
|
|
expect(conversation.sessionId).toBe('old-session-id');
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('recordMessage', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should record a new message', async () => {
|
2025-09-12 15:57:07 -04:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'Hello',
|
2026-01-30 10:09:27 -08:00
|
|
|
displayContent: 'User Hello',
|
2025-09-12 15:57:07 -04:00
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
2026-02-06 01:36:42 -05:00
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 01:36:42 -05:00
|
|
|
|
2025-08-18 18:39:57 -06:00
|
|
|
expect(conversation.messages).toHaveLength(1);
|
|
|
|
|
expect(conversation.messages[0].content).toBe('Hello');
|
2026-01-30 10:09:27 -08:00
|
|
|
expect(conversation.messages[0].displayContent).toBe('User Hello');
|
2025-08-18 18:39:57 -06:00
|
|
|
expect(conversation.messages[0].type).toBe('user');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should create separate messages when recording multiple messages', async () => {
|
2025-08-18 18:39:57 -06:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
2025-09-02 23:29:07 -06:00
|
|
|
content: 'World',
|
2025-09-12 15:57:07 -04:00
|
|
|
model: 'gemini-pro',
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 01:36:42 -05:00
|
|
|
expect(conversation.messages).toHaveLength(1);
|
|
|
|
|
expect(conversation.messages[0].content).toBe('World');
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('recordThought', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should queue a thought', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2025-08-18 18:39:57 -06:00
|
|
|
chatRecordingService.recordThought({
|
|
|
|
|
subject: 'Thinking',
|
|
|
|
|
description: 'Thinking...',
|
|
|
|
|
});
|
|
|
|
|
// @ts-expect-error private property
|
|
|
|
|
expect(chatRecordingService.queuedThoughts).toHaveLength(1);
|
|
|
|
|
// @ts-expect-error private property
|
|
|
|
|
expect(chatRecordingService.queuedThoughts[0].subject).toBe('Thinking');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('recordMessageTokens', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should update the last message with token info', async () => {
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'Response',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
chatRecordingService.recordMessageTokens({
|
2025-09-02 23:29:07 -06:00
|
|
|
promptTokenCount: 1,
|
|
|
|
|
candidatesTokenCount: 2,
|
|
|
|
|
totalTokenCount: 3,
|
|
|
|
|
cachedContentTokenCount: 0,
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 01:36:42 -05:00
|
|
|
const geminiMsg = conversation.messages[0] as MessageRecord & {
|
|
|
|
|
type: 'gemini';
|
|
|
|
|
};
|
|
|
|
|
expect(geminiMsg.tokens).toEqual({
|
|
|
|
|
input: 1,
|
|
|
|
|
output: 2,
|
|
|
|
|
total: 3,
|
|
|
|
|
cached: 0,
|
|
|
|
|
thoughts: 0,
|
|
|
|
|
tool: 0,
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should queue token info if the last message already has tokens', async () => {
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'Response',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordMessageTokens({
|
|
|
|
|
promptTokenCount: 1,
|
|
|
|
|
candidatesTokenCount: 1,
|
|
|
|
|
totalTokenCount: 2,
|
|
|
|
|
cachedContentTokenCount: 0,
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
chatRecordingService.recordMessageTokens({
|
2025-09-02 23:29:07 -06:00
|
|
|
promptTokenCount: 2,
|
|
|
|
|
candidatesTokenCount: 2,
|
|
|
|
|
totalTokenCount: 4,
|
|
|
|
|
cachedContentTokenCount: 0,
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// @ts-expect-error private property
|
|
|
|
|
expect(chatRecordingService.queuedTokens).toEqual({
|
|
|
|
|
input: 2,
|
|
|
|
|
output: 2,
|
|
|
|
|
total: 4,
|
|
|
|
|
cached: 0,
|
2025-09-02 23:29:07 -06:00
|
|
|
thoughts: 0,
|
|
|
|
|
tool: 0,
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
});
|
2026-03-06 19:45:36 -08:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should not write to disk when queuing tokens (no last gemini message)', async () => {
|
|
|
|
|
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
2026-03-06 19:45:36 -08:00
|
|
|
|
|
|
|
|
// Clear spy call count after initialize writes the initial file
|
2026-04-09 17:13:55 -04:00
|
|
|
appendFileSyncSpy.mockClear();
|
2026-03-06 19:45:36 -08:00
|
|
|
|
|
|
|
|
// No gemini message recorded yet, so tokens should only be queued
|
|
|
|
|
chatRecordingService.recordMessageTokens({
|
|
|
|
|
promptTokenCount: 5,
|
|
|
|
|
candidatesTokenCount: 10,
|
|
|
|
|
totalTokenCount: 15,
|
|
|
|
|
cachedContentTokenCount: 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// writeFileSync should NOT have been called since we only queued
|
2026-04-09 17:13:55 -04:00
|
|
|
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
2026-03-06 19:45:36 -08:00
|
|
|
|
|
|
|
|
// @ts-expect-error private property
|
|
|
|
|
expect(chatRecordingService.queuedTokens).toEqual({
|
|
|
|
|
input: 5,
|
|
|
|
|
output: 10,
|
|
|
|
|
total: 15,
|
|
|
|
|
cached: 0,
|
|
|
|
|
thoughts: 0,
|
|
|
|
|
tool: 0,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should not write to disk when queuing tokens (last message already has tokens)', async () => {
|
2026-03-06 19:45:36 -08:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'Response',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// First recordMessageTokens updates the message and writes to disk
|
|
|
|
|
chatRecordingService.recordMessageTokens({
|
|
|
|
|
promptTokenCount: 1,
|
|
|
|
|
candidatesTokenCount: 1,
|
|
|
|
|
totalTokenCount: 2,
|
|
|
|
|
cachedContentTokenCount: 0,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
|
|
|
|
appendFileSyncSpy.mockClear();
|
2026-03-06 19:45:36 -08:00
|
|
|
|
|
|
|
|
// Second call should only queue, NOT write to disk
|
|
|
|
|
chatRecordingService.recordMessageTokens({
|
|
|
|
|
promptTokenCount: 2,
|
|
|
|
|
candidatesTokenCount: 2,
|
|
|
|
|
totalTokenCount: 4,
|
|
|
|
|
cachedContentTokenCount: 0,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
2026-03-06 19:45:36 -08:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should use in-memory cache and not re-read from disk on subsequent operations', async () => {
|
2026-03-06 19:45:36 -08:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'Response',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const readFileSyncSpy = vi.mocked(fs.readFileSync);
|
2026-03-06 19:45:36 -08:00
|
|
|
readFileSyncSpy.mockClear();
|
|
|
|
|
|
|
|
|
|
// These operations should all use the in-memory cache
|
|
|
|
|
chatRecordingService.recordMessageTokens({
|
|
|
|
|
promptTokenCount: 1,
|
|
|
|
|
candidatesTokenCount: 1,
|
|
|
|
|
totalTokenCount: 2,
|
|
|
|
|
cachedContentTokenCount: 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'Another response',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatRecordingService.saveSummary('Test summary');
|
|
|
|
|
|
|
|
|
|
// readFileSync should NOT have been called since we use the in-memory cache
|
|
|
|
|
expect(readFileSyncSpy).not.toHaveBeenCalled();
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('recordToolCalls', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should add new tool calls to the last message', async () => {
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: '',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
const toolCall: ToolCallRecord = {
|
|
|
|
|
id: 'tool-1',
|
|
|
|
|
name: 'testTool',
|
|
|
|
|
args: {},
|
2026-02-13 11:27:20 -05:00
|
|
|
status: CoreToolCallStatus.AwaitingApproval,
|
2025-08-18 18:39:57 -06:00
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
};
|
2025-09-12 15:57:07 -04:00
|
|
|
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 01:36:42 -05:00
|
|
|
const geminiMsg = conversation.messages[0] as MessageRecord & {
|
|
|
|
|
type: 'gemini';
|
|
|
|
|
};
|
|
|
|
|
expect(geminiMsg.toolCalls).toHaveLength(1);
|
|
|
|
|
expect(geminiMsg.toolCalls![0].name).toBe('testTool');
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should preserve dynamic description and NOT overwrite with generic one', async () => {
|
2026-03-11 15:38:54 -04:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: '',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const dynamicDescription = 'DYNAMIC DESCRIPTION (e.g. Read file foo.txt)';
|
|
|
|
|
const toolCall: ToolCallRecord = {
|
|
|
|
|
id: 'tool-1',
|
|
|
|
|
name: 'testTool',
|
|
|
|
|
args: {},
|
|
|
|
|
status: CoreToolCallStatus.Success,
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
description: dynamicDescription,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
|
|
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-03-11 15:38:54 -04:00
|
|
|
const geminiMsg = conversation.messages[0] as MessageRecord & {
|
|
|
|
|
type: 'gemini';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
expect(geminiMsg.toolCalls![0].description).toBe(dynamicDescription);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should create a new message if the last message is not from gemini', async () => {
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'call a tool',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
|
|
|
|
|
const toolCall: ToolCallRecord = {
|
|
|
|
|
id: 'tool-1',
|
|
|
|
|
name: 'testTool',
|
|
|
|
|
args: {},
|
2026-02-13 11:27:20 -05:00
|
|
|
status: CoreToolCallStatus.AwaitingApproval,
|
2025-08-18 18:39:57 -06:00
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
};
|
2025-09-12 15:57:07 -04:00
|
|
|
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
|
2025-08-18 18:39:57 -06:00
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2025-08-18 18:39:57 -06:00
|
|
|
expect(conversation.messages).toHaveLength(2);
|
2026-02-06 01:36:42 -05:00
|
|
|
expect(conversation.messages[1].type).toBe('gemini');
|
|
|
|
|
expect(
|
|
|
|
|
(conversation.messages[1] as MessageRecord & { type: 'gemini' })
|
|
|
|
|
.toolCalls,
|
|
|
|
|
).toHaveLength(1);
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
2026-04-10 12:47:25 -04:00
|
|
|
|
|
|
|
|
it('should record agentId when provided', async () => {
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: '',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const toolCall: ToolCallRecord = {
|
|
|
|
|
id: 'tool-1',
|
|
|
|
|
name: 'testTool',
|
|
|
|
|
args: {},
|
|
|
|
|
status: CoreToolCallStatus.Success,
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
agentId: 'test-agent-id',
|
|
|
|
|
};
|
|
|
|
|
chatRecordingService.recordToolCalls('gemini-pro', [toolCall]);
|
|
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
|
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
|
|
|
|
const geminiMsg = conversation.messages[0] as MessageRecord & {
|
|
|
|
|
type: 'gemini';
|
|
|
|
|
};
|
|
|
|
|
expect(geminiMsg.toolCalls).toHaveLength(1);
|
|
|
|
|
expect(geminiMsg.toolCalls![0].agentId).toBe('test-agent-id');
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('deleteSession', () => {
|
2026-03-26 23:43:39 -04:00
|
|
|
it('should delete the session file, tool outputs, session directory, and logs if they exist', async () => {
|
2026-03-03 09:11:25 -05:00
|
|
|
const sessionId = 'test-session-id';
|
2026-03-14 16:09:43 -04:00
|
|
|
const shortId = '12345678';
|
2026-02-06 01:36:42 -05:00
|
|
|
const chatsDir = path.join(testTempDir, 'chats');
|
2026-03-03 09:11:25 -05:00
|
|
|
const logsDir = path.join(testTempDir, 'logs');
|
|
|
|
|
const toolOutputsDir = path.join(testTempDir, 'tool-outputs');
|
|
|
|
|
const sessionDir = path.join(testTempDir, sessionId);
|
|
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
fs.mkdirSync(chatsDir, { recursive: true });
|
2026-03-03 09:11:25 -05:00
|
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
|
|
|
fs.mkdirSync(toolOutputsDir, { recursive: true });
|
|
|
|
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
|
|
|
|
2026-03-14 16:09:43 -04:00
|
|
|
// Create main session file with timestamp
|
|
|
|
|
const sessionFile = path.join(
|
|
|
|
|
chatsDir,
|
2026-04-09 17:13:55 -04:00
|
|
|
`session-2023-01-01T00-00-${shortId}.jsonl`,
|
2026-03-14 16:09:43 -04:00
|
|
|
);
|
2026-04-09 17:13:55 -04:00
|
|
|
fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }) + '\n');
|
2026-02-06 01:36:42 -05:00
|
|
|
|
2026-03-03 09:11:25 -05:00
|
|
|
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
|
|
|
|
|
fs.writeFileSync(logFile, '{}');
|
|
|
|
|
|
|
|
|
|
const toolOutputDir = path.join(toolOutputsDir, `session-${sessionId}`);
|
2026-02-06 01:36:42 -05:00
|
|
|
fs.mkdirSync(toolOutputDir, { recursive: true });
|
|
|
|
|
|
2026-03-14 16:09:43 -04:00
|
|
|
// Call with shortId
|
2026-03-26 23:43:39 -04:00
|
|
|
await chatRecordingService.deleteSession(shortId);
|
2026-02-06 01:36:42 -05:00
|
|
|
|
|
|
|
|
expect(fs.existsSync(sessionFile)).toBe(false);
|
2026-03-03 09:11:25 -05:00
|
|
|
expect(fs.existsSync(logFile)).toBe(false);
|
2026-02-06 01:36:42 -05:00
|
|
|
expect(fs.existsSync(toolOutputDir)).toBe(false);
|
2026-03-03 09:11:25 -05:00
|
|
|
expect(fs.existsSync(sessionDir)).toBe(false);
|
2026-02-06 01:36:42 -05:00
|
|
|
});
|
|
|
|
|
|
2026-03-26 23:43:39 -04:00
|
|
|
it('should delete subagent files and their logs when parent is deleted', async () => {
|
2026-03-14 16:09:43 -04:00
|
|
|
const parentSessionId = '12345678-session-id';
|
|
|
|
|
const shortId = '12345678';
|
|
|
|
|
const subagentSessionId = 'subagent-session-id';
|
|
|
|
|
const chatsDir = path.join(testTempDir, 'chats');
|
|
|
|
|
const logsDir = path.join(testTempDir, 'logs');
|
|
|
|
|
const toolOutputsDir = path.join(testTempDir, 'tool-outputs');
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(chatsDir, { recursive: true });
|
|
|
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
|
|
|
fs.mkdirSync(toolOutputsDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Create parent session file
|
|
|
|
|
const parentFile = path.join(
|
|
|
|
|
chatsDir,
|
2026-04-09 17:13:55 -04:00
|
|
|
`session-2023-01-01T00-00-${shortId}.jsonl`,
|
2026-03-14 16:09:43 -04:00
|
|
|
);
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
parentFile,
|
2026-04-09 17:13:55 -04:00
|
|
|
JSON.stringify({ sessionId: parentSessionId }) + '\n',
|
2026-03-14 16:09:43 -04:00
|
|
|
);
|
|
|
|
|
|
2026-03-26 23:43:39 -04:00
|
|
|
// Create subagent session file in subdirectory
|
|
|
|
|
const subagentDir = path.join(chatsDir, parentSessionId);
|
|
|
|
|
fs.mkdirSync(subagentDir, { recursive: true });
|
2026-04-09 17:13:55 -04:00
|
|
|
const subagentFile = path.join(subagentDir, `${subagentSessionId}.jsonl`);
|
2026-03-14 16:09:43 -04:00
|
|
|
fs.writeFileSync(
|
|
|
|
|
subagentFile,
|
2026-04-09 17:13:55 -04:00
|
|
|
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }) +
|
|
|
|
|
'\n',
|
2026-03-14 16:09:43 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Create logs for both
|
|
|
|
|
const parentLog = path.join(logsDir, `session-${parentSessionId}.jsonl`);
|
|
|
|
|
fs.writeFileSync(parentLog, '{}');
|
|
|
|
|
const subagentLog = path.join(
|
|
|
|
|
logsDir,
|
|
|
|
|
`session-${subagentSessionId}.jsonl`,
|
|
|
|
|
);
|
|
|
|
|
fs.writeFileSync(subagentLog, '{}');
|
|
|
|
|
|
|
|
|
|
// Create tool outputs for both
|
|
|
|
|
const parentToolOutputDir = path.join(
|
|
|
|
|
toolOutputsDir,
|
|
|
|
|
`session-${parentSessionId}`,
|
|
|
|
|
);
|
|
|
|
|
fs.mkdirSync(parentToolOutputDir, { recursive: true });
|
|
|
|
|
const subagentToolOutputDir = path.join(
|
|
|
|
|
toolOutputsDir,
|
|
|
|
|
`session-${subagentSessionId}`,
|
|
|
|
|
);
|
|
|
|
|
fs.mkdirSync(subagentToolOutputDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Call with parent sessionId
|
2026-03-26 23:43:39 -04:00
|
|
|
await chatRecordingService.deleteSession(parentSessionId);
|
2026-03-14 16:09:43 -04:00
|
|
|
|
|
|
|
|
expect(fs.existsSync(parentFile)).toBe(false);
|
|
|
|
|
expect(fs.existsSync(subagentFile)).toBe(false);
|
2026-03-26 23:43:39 -04:00
|
|
|
expect(fs.existsSync(subagentDir)).toBe(false); // Subagent directory should be deleted
|
2026-03-14 16:09:43 -04:00
|
|
|
expect(fs.existsSync(parentLog)).toBe(false);
|
|
|
|
|
expect(fs.existsSync(subagentLog)).toBe(false);
|
|
|
|
|
expect(fs.existsSync(parentToolOutputDir)).toBe(false);
|
|
|
|
|
expect(fs.existsSync(subagentToolOutputDir)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 23:43:39 -04:00
|
|
|
it('should delete subagent files and their logs when parent is deleted (legacy flat structure)', async () => {
|
|
|
|
|
const parentSessionId = '12345678-session-id';
|
|
|
|
|
const shortId = '12345678';
|
|
|
|
|
const subagentSessionId = 'subagent-session-id';
|
|
|
|
|
const chatsDir = path.join(testTempDir, 'chats');
|
|
|
|
|
const logsDir = path.join(testTempDir, 'logs');
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(chatsDir, { recursive: true });
|
|
|
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Create parent session file
|
|
|
|
|
const parentFile = path.join(
|
|
|
|
|
chatsDir,
|
2026-04-09 17:13:55 -04:00
|
|
|
`session-2023-01-01T00-00-${shortId}.jsonl`,
|
2026-03-26 23:43:39 -04:00
|
|
|
);
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
parentFile,
|
2026-04-09 17:13:55 -04:00
|
|
|
JSON.stringify({ sessionId: parentSessionId }) + '\n',
|
2026-03-26 23:43:39 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Create legacy subagent session file (flat in chatsDir)
|
|
|
|
|
const subagentFile = path.join(
|
|
|
|
|
chatsDir,
|
2026-04-09 17:13:55 -04:00
|
|
|
`session-2023-01-01T00-01-${shortId}.jsonl`,
|
2026-03-26 23:43:39 -04:00
|
|
|
);
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
subagentFile,
|
2026-04-09 17:13:55 -04:00
|
|
|
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }) +
|
|
|
|
|
'\n',
|
2026-03-26 23:43:39 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Call with parent sessionId
|
|
|
|
|
await chatRecordingService.deleteSession(parentSessionId);
|
|
|
|
|
|
|
|
|
|
expect(fs.existsSync(parentFile)).toBe(false);
|
|
|
|
|
expect(fs.existsSync(subagentFile)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should delete by basename', async () => {
|
2026-03-14 16:09:43 -04:00
|
|
|
const sessionId = 'test-session-id';
|
|
|
|
|
const shortId = '12345678';
|
|
|
|
|
const chatsDir = path.join(testTempDir, 'chats');
|
|
|
|
|
const logsDir = path.join(testTempDir, 'logs');
|
|
|
|
|
|
|
|
|
|
fs.mkdirSync(chatsDir, { recursive: true });
|
|
|
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const basename = `session-2023-01-01T00-00-${shortId}`;
|
2026-04-09 17:13:55 -04:00
|
|
|
const sessionFile = path.join(chatsDir, `${basename}.jsonl`);
|
|
|
|
|
fs.writeFileSync(sessionFile, JSON.stringify({ sessionId }) + '\n');
|
2026-03-14 16:09:43 -04:00
|
|
|
|
|
|
|
|
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
|
|
|
|
|
fs.writeFileSync(logFile, '{}');
|
|
|
|
|
|
|
|
|
|
// Call with basename
|
2026-03-26 23:43:39 -04:00
|
|
|
await chatRecordingService.deleteSession(basename);
|
2026-03-14 16:09:43 -04:00
|
|
|
|
|
|
|
|
expect(fs.existsSync(sessionFile)).toBe(false);
|
|
|
|
|
expect(fs.existsSync(logFile)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-26 23:43:39 -04:00
|
|
|
it('should not throw if session file does not exist', async () => {
|
|
|
|
|
await expect(
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.deleteSession('non-existent'),
|
2026-03-26 23:43:39 -04:00
|
|
|
).resolves.not.toThrow();
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|
|
|
|
|
});
|
2026-01-07 12:10:22 -05:00
|
|
|
|
2026-01-29 00:37:58 +05:30
|
|
|
describe('recordDirectories', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-01-29 00:37:58 +05:30
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should save directories to the conversation', async () => {
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'ping',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
2026-01-29 00:37:58 +05:30
|
|
|
chatRecordingService.recordDirectories([
|
|
|
|
|
'/path/to/dir1',
|
|
|
|
|
'/path/to/dir2',
|
|
|
|
|
]);
|
|
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-01-29 00:37:58 +05:30
|
|
|
expect(conversation.directories).toEqual([
|
|
|
|
|
'/path/to/dir1',
|
|
|
|
|
'/path/to/dir2',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should overwrite existing directories', async () => {
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'ping',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
|
|
|
|
chatRecordingService.recordDirectories(['/old/dir']);
|
2026-01-29 00:37:58 +05:30
|
|
|
chatRecordingService.recordDirectories(['/new/dir1', '/new/dir2']);
|
|
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-01-29 00:37:58 +05:30
|
|
|
expect(conversation.directories).toEqual(['/new/dir1', '/new/dir2']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-07 12:10:22 -05:00
|
|
|
describe('rewindTo', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should rewind the conversation to a specific message ID', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-02-06 01:36:42 -05:00
|
|
|
// Record some messages
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'msg1',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'msg2',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'msg3',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
let conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 01:36:42 -05:00
|
|
|
const secondMsgId = conversation.messages[1].id;
|
2026-01-07 12:10:22 -05:00
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
const result = chatRecordingService.rewindTo(secondMsgId);
|
2026-01-07 12:10:22 -05:00
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
expect(result).not.toBeNull();
|
|
|
|
|
expect(result!.messages).toHaveLength(1);
|
|
|
|
|
expect(result!.messages[0].content).toBe('msg1');
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 01:36:42 -05:00
|
|
|
expect(conversation.messages).toHaveLength(1);
|
2026-01-07 12:10:22 -05:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should return the original conversation if the message ID is not found', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-02-06 01:36:42 -05:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'msg1',
|
|
|
|
|
model: 'm',
|
|
|
|
|
});
|
2026-01-07 12:10:22 -05:00
|
|
|
|
|
|
|
|
const result = chatRecordingService.rewindTo('non-existent');
|
|
|
|
|
|
2026-02-06 01:36:42 -05:00
|
|
|
expect(result).not.toBeNull();
|
|
|
|
|
expect(result!.messages).toHaveLength(1);
|
2026-01-07 12:10:22 -05:00
|
|
|
});
|
|
|
|
|
});
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
describe('ENOSPC (disk full) graceful degradation - issue #16266', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should disable recording and not throw when ENOSPC occurs during initialize', async () => {
|
2026-01-23 18:28:45 +00:00
|
|
|
const enospcError = new Error('ENOSPC: no space left on device');
|
|
|
|
|
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const mkdirSyncSpy = vi.mocked(fs.mkdirSync).mockImplementation(() => {
|
2026-01-23 18:28:45 +00:00
|
|
|
throw enospcError;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should not throw
|
2026-04-09 17:13:55 -04:00
|
|
|
await expect(chatRecordingService.initialize()).resolves.not.toThrow();
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
// Recording should be disabled (conversationFile set to null)
|
|
|
|
|
expect(chatRecordingService.getConversationFilePath()).toBeNull();
|
2026-02-06 01:36:42 -05:00
|
|
|
mkdirSyncSpy.mockRestore();
|
2026-01-23 18:28:45 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should disable recording and not throw when ENOSPC occurs during writeConversation', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
const enospcError = new Error('ENOSPC: no space left on device');
|
|
|
|
|
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
vi.mocked(fs.appendFileSync).mockImplementation(() => {
|
|
|
|
|
throw enospcError;
|
|
|
|
|
});
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
// Should not throw when recording a message
|
|
|
|
|
expect(() =>
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'Hello',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
}),
|
|
|
|
|
).not.toThrow();
|
|
|
|
|
|
|
|
|
|
// Recording should be disabled (conversationFile set to null)
|
|
|
|
|
expect(chatRecordingService.getConversationFilePath()).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should skip recording operations when recording is disabled', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
const enospcError = new Error('ENOSPC: no space left on device');
|
|
|
|
|
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const appendFileSyncSpy = vi
|
|
|
|
|
.mocked(fs.appendFileSync)
|
2026-02-06 01:36:42 -05:00
|
|
|
.mockImplementationOnce(() => {
|
|
|
|
|
throw enospcError;
|
|
|
|
|
});
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'First message',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Reset mock to track subsequent calls
|
2026-04-09 17:13:55 -04:00
|
|
|
appendFileSyncSpy.mockClear();
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
// Subsequent calls should be no-ops (not call writeFileSync)
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'Second message',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordThought({
|
|
|
|
|
subject: 'Test',
|
|
|
|
|
description: 'Test thought',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatRecordingService.saveSummary('Test summary');
|
|
|
|
|
|
|
|
|
|
// writeFileSync should not have been called for any of these
|
2026-04-09 17:13:55 -04:00
|
|
|
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
2026-01-23 18:28:45 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should return null from getConversation when recording is disabled', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
const enospcError = new Error('ENOSPC: no space left on device');
|
|
|
|
|
(enospcError as NodeJS.ErrnoException).code = 'ENOSPC';
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
vi.mocked(fs.appendFileSync).mockImplementation(() => {
|
|
|
|
|
throw enospcError;
|
|
|
|
|
});
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
// Trigger ENOSPC
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'Hello',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// getConversation should return null when disabled
|
|
|
|
|
expect(chatRecordingService.getConversation()).toBeNull();
|
|
|
|
|
expect(chatRecordingService.getConversationFilePath()).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should still throw for non-ENOSPC errors', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
const otherError = new Error('Permission denied');
|
|
|
|
|
(otherError as NodeJS.ErrnoException).code = 'EACCES';
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
vi.mocked(fs.appendFileSync).mockImplementation(() => {
|
|
|
|
|
throw otherError;
|
|
|
|
|
});
|
2026-01-23 18:28:45 +00:00
|
|
|
|
|
|
|
|
// Should throw for non-ENOSPC errors
|
|
|
|
|
expect(() =>
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'Hello',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
}),
|
|
|
|
|
).toThrow('Permission denied');
|
|
|
|
|
|
|
|
|
|
// Recording should NOT be disabled for non-ENOSPC errors (file path still exists)
|
|
|
|
|
expect(chatRecordingService.getConversationFilePath()).not.toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-06 16:22:22 -05:00
|
|
|
|
|
|
|
|
describe('updateMessagesFromHistory', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
beforeEach(async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-02-06 16:22:22 -05:00
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should update tool results from API history (masking sync)', async () => {
|
2026-02-06 16:22:22 -05:00
|
|
|
// 1. Record an initial message and tool call
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'I will list the files.',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const callId = 'tool-call-123';
|
|
|
|
|
const originalResult = [{ text: 'a'.repeat(1000) }];
|
|
|
|
|
chatRecordingService.recordToolCalls('gemini-pro', [
|
|
|
|
|
{
|
|
|
|
|
id: callId,
|
|
|
|
|
name: 'list_files',
|
|
|
|
|
args: { path: '.' },
|
|
|
|
|
result: originalResult,
|
2026-02-13 11:27:20 -05:00
|
|
|
status: CoreToolCallStatus.Success,
|
2026-02-06 16:22:22 -05:00
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 2. Prepare mock history with masked content
|
|
|
|
|
const maskedSnippet =
|
|
|
|
|
'<tool_output_masked>short preview</tool_output_masked>';
|
|
|
|
|
const history: Content[] = [
|
|
|
|
|
{
|
|
|
|
|
role: 'model',
|
|
|
|
|
parts: [
|
|
|
|
|
{ functionCall: { name: 'list_files', args: { path: '.' } } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
role: 'user',
|
|
|
|
|
parts: [
|
|
|
|
|
{
|
|
|
|
|
functionResponse: {
|
|
|
|
|
name: 'list_files',
|
|
|
|
|
id: callId,
|
|
|
|
|
response: { output: maskedSnippet },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 3. Trigger sync
|
|
|
|
|
chatRecordingService.updateMessagesFromHistory(history);
|
|
|
|
|
|
|
|
|
|
// 4. Verify disk content
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 16:22:22 -05:00
|
|
|
|
|
|
|
|
const geminiMsg = conversation.messages[0];
|
|
|
|
|
if (geminiMsg.type !== 'gemini')
|
|
|
|
|
throw new Error('Expected gemini message');
|
|
|
|
|
expect(geminiMsg.toolCalls).toBeDefined();
|
|
|
|
|
expect(geminiMsg.toolCalls![0].id).toBe(callId);
|
|
|
|
|
// The implementation stringifies the response object
|
|
|
|
|
const result = geminiMsg.toolCalls![0].result;
|
|
|
|
|
if (!Array.isArray(result)) throw new Error('Expected array result');
|
|
|
|
|
const firstPart = result[0] as Part;
|
|
|
|
|
expect(firstPart.functionResponse).toBeDefined();
|
|
|
|
|
expect(firstPart.functionResponse!.id).toBe(callId);
|
|
|
|
|
expect(firstPart.functionResponse!.response).toEqual({
|
|
|
|
|
output: maskedSnippet,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should preserve multi-modal sibling parts during sync', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-02-06 16:22:22 -05:00
|
|
|
const callId = 'multi-modal-call';
|
|
|
|
|
const originalResult: Part[] = [
|
|
|
|
|
{
|
|
|
|
|
functionResponse: {
|
|
|
|
|
id: callId,
|
|
|
|
|
name: 'read_file',
|
|
|
|
|
response: { content: '...' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ inlineData: { mimeType: 'image/png', data: 'base64...' } },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: '',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordToolCalls('gemini-pro', [
|
|
|
|
|
{
|
|
|
|
|
id: callId,
|
|
|
|
|
name: 'read_file',
|
|
|
|
|
args: { path: 'image.png' },
|
|
|
|
|
result: originalResult,
|
2026-02-13 11:27:20 -05:00
|
|
|
status: CoreToolCallStatus.Success,
|
2026-02-06 16:22:22 -05:00
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const maskedSnippet = '<masked>';
|
|
|
|
|
const history: Content[] = [
|
|
|
|
|
{
|
|
|
|
|
role: 'user',
|
|
|
|
|
parts: [
|
|
|
|
|
{
|
|
|
|
|
functionResponse: {
|
|
|
|
|
name: 'read_file',
|
|
|
|
|
id: callId,
|
|
|
|
|
response: { output: maskedSnippet },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ inlineData: { mimeType: 'image/png', data: 'base64...' } },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
chatRecordingService.updateMessagesFromHistory(history);
|
|
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 16:22:22 -05:00
|
|
|
|
|
|
|
|
const lastMsg = conversation.messages[0] as MessageRecord & {
|
|
|
|
|
type: 'gemini';
|
|
|
|
|
};
|
|
|
|
|
const result = lastMsg.toolCalls![0].result as Part[];
|
|
|
|
|
expect(result).toHaveLength(2);
|
|
|
|
|
expect(result[0].functionResponse!.response).toEqual({
|
|
|
|
|
output: maskedSnippet,
|
|
|
|
|
});
|
|
|
|
|
expect(result[1].inlineData).toBeDefined();
|
|
|
|
|
expect(result[1].inlineData!.mimeType).toBe('image/png');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should handle parts appearing BEFORE the functionResponse in a content block', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-02-06 16:22:22 -05:00
|
|
|
const callId = 'prefix-part-call';
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: '',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
chatRecordingService.recordToolCalls('gemini-pro', [
|
|
|
|
|
{
|
|
|
|
|
id: callId,
|
|
|
|
|
name: 'read_file',
|
|
|
|
|
args: { path: 'test.txt' },
|
|
|
|
|
result: [],
|
2026-02-13 11:27:20 -05:00
|
|
|
status: CoreToolCallStatus.Success,
|
2026-02-06 16:22:22 -05:00
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const history: Content[] = [
|
|
|
|
|
{
|
|
|
|
|
role: 'user',
|
|
|
|
|
parts: [
|
|
|
|
|
{ text: 'Prefix metadata or text' },
|
|
|
|
|
{
|
|
|
|
|
functionResponse: {
|
|
|
|
|
name: 'read_file',
|
|
|
|
|
id: callId,
|
|
|
|
|
response: { output: 'file content' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
chatRecordingService.updateMessagesFromHistory(history);
|
|
|
|
|
|
|
|
|
|
const sessionFile = chatRecordingService.getConversationFilePath()!;
|
2026-04-09 17:13:55 -04:00
|
|
|
const conversation = (await loadConversationRecord(
|
|
|
|
|
sessionFile,
|
|
|
|
|
)) as ConversationRecord;
|
2026-02-06 16:22:22 -05:00
|
|
|
|
|
|
|
|
const lastMsg = conversation.messages[0] as MessageRecord & {
|
|
|
|
|
type: 'gemini';
|
|
|
|
|
};
|
|
|
|
|
const result = lastMsg.toolCalls![0].result as Part[];
|
|
|
|
|
expect(result).toHaveLength(2);
|
|
|
|
|
expect(result[0].text).toBe('Prefix metadata or text');
|
|
|
|
|
expect(result[1].functionResponse!.id).toBe(callId);
|
|
|
|
|
});
|
2026-03-06 19:45:36 -08:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should not write to disk when no tool calls match', async () => {
|
2026-03-06 19:45:36 -08:00
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'gemini',
|
|
|
|
|
content: 'Response with no tool calls',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
|
|
|
|
appendFileSyncSpy.mockClear();
|
2026-03-06 19:45:36 -08:00
|
|
|
|
|
|
|
|
// History with a tool call ID that doesn't exist in the conversation
|
|
|
|
|
const history: Content[] = [
|
|
|
|
|
{
|
|
|
|
|
role: 'user',
|
|
|
|
|
parts: [
|
|
|
|
|
{
|
|
|
|
|
functionResponse: {
|
|
|
|
|
name: 'read_file',
|
|
|
|
|
id: 'nonexistent-call-id',
|
|
|
|
|
response: { output: 'some content' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
chatRecordingService.updateMessagesFromHistory(history);
|
|
|
|
|
|
|
|
|
|
// No tool calls matched, so writeFileSync should NOT have been called
|
2026-04-09 17:13:55 -04:00
|
|
|
expect(appendFileSyncSpy).not.toHaveBeenCalled();
|
2026-03-06 19:45:36 -08:00
|
|
|
});
|
2026-02-06 16:22:22 -05:00
|
|
|
});
|
2026-02-18 21:13:54 +00:00
|
|
|
|
|
|
|
|
describe('ENOENT (missing directory) handling', () => {
|
2026-04-09 17:13:55 -04:00
|
|
|
it('should ensure directory exists before writing conversation file', async () => {
|
|
|
|
|
await chatRecordingService.initialize();
|
2026-02-18 21:13:54 +00:00
|
|
|
|
2026-04-09 17:13:55 -04:00
|
|
|
const mkdirSyncSpy = vi.mocked(fs.mkdirSync);
|
|
|
|
|
const appendFileSyncSpy = vi.mocked(fs.appendFileSync);
|
2026-02-18 21:13:54 +00:00
|
|
|
|
|
|
|
|
chatRecordingService.recordMessage({
|
|
|
|
|
type: 'user',
|
|
|
|
|
content: 'Hello after dir cleanup',
|
|
|
|
|
model: 'gemini-pro',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// mkdirSync should be called with the parent directory and recursive option
|
|
|
|
|
const conversationFile = chatRecordingService.getConversationFilePath()!;
|
|
|
|
|
expect(mkdirSyncSpy).toHaveBeenCalledWith(
|
|
|
|
|
path.dirname(conversationFile),
|
|
|
|
|
{ recursive: true },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// mkdirSync should be called before writeFileSync
|
|
|
|
|
const mkdirCallOrder = mkdirSyncSpy.mock.invocationCallOrder;
|
2026-04-09 17:13:55 -04:00
|
|
|
const writeCallOrder = appendFileSyncSpy.mock.invocationCallOrder;
|
2026-02-18 21:13:54 +00:00
|
|
|
const lastMkdir = mkdirCallOrder[mkdirCallOrder.length - 1];
|
|
|
|
|
const lastWrite = writeCallOrder[writeCallOrder.length - 1];
|
|
|
|
|
expect(lastMkdir).toBeLessThan(lastWrite);
|
|
|
|
|
|
|
|
|
|
mkdirSyncSpy.mockRestore();
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-08-18 18:39:57 -06:00
|
|
|
});
|