logConversation

loadConversation

/resume

clean up for review
This commit is contained in:
Seth Troisi 2025-06-10 20:24:48 +00:00
parent d79dafc577
commit 36f58a34b4
4 changed files with 197 additions and 5 deletions

View File

@ -11,10 +11,11 @@ import process from 'node:process';
import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { import {
Config, Config,
MCPServerStatus, Logger,
getMCPServerStatus,
getMCPDiscoveryState,
MCPDiscoveryState, MCPDiscoveryState,
MCPServerStatus,
getMCPDiscoveryState,
getMCPServerStatus,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; import { Message, MessageType, HistoryItemWithoutId } from '../types.js';
import { useSessionStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
@ -487,6 +488,76 @@ Add any other context about the problem here.
})(); })();
}, },
}, },
{
name: 'save',
description: 'save conversation checkpoint',
action: async (_mainCommand, _subCommand, _args) => {
const logger = new Logger();
await logger.initialize();
const chat = await config?.getGeminiClient()?.getChat();
const history = chat?.getHistory() || [];
if (history.length > 0) {
logger.saveCheckpoint(chat?.getHistory() || []);
} else {
addMessage({
type: MessageType.INFO,
content: 'No conversation found to save.',
timestamp: new Date(),
});
return;
}
},
},
{
name: 'resume',
description: 'resume from last conversation checkpoint',
action: async (_mainCommand, _subCommand, _args) => {
const logger = new Logger();
await logger.initialize();
const conversation = await logger.loadCheckpoint();
if (conversation.length === 0) {
addMessage({
type: MessageType.INFO,
content: 'No saved conversation found.',
timestamp: new Date(),
});
return;
}
const chat = await config?.getGeminiClient()?.getChat();
clearItems();
let i = 0;
const rolemap: { [key: string]: MessageType } = {
user: MessageType.USER,
model: MessageType.GEMINI,
};
for (const item of conversation) {
i += 1;
const text =
item.parts
?.filter((m) => !!m.text)
.map((m) => m.text)
.join('') || '';
if (i <= 2) {
// Skip system prompt back and forth.
continue;
}
if (!text) {
// Parsing Part[] back to various non-text output not yet implemented.
continue;
}
addItem(
{
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
text,
} as HistoryItemWithoutId,
i,
);
chat?.addHistory(item);
}
console.clear();
refreshStatic();
},
},
{ {
name: 'quit', name: 'quit',
altName: 'exit', altName: 'exit',

View File

@ -131,7 +131,7 @@ export enum MessageType {
USER = 'user', USER = 'user',
ABOUT = 'about', ABOUT = 'about',
STATS = 'stats', STATS = 'stats',
// Add GEMINI if needed by other commands GEMINI = 'gemini',
} }
// Simplified message structure for internal feedback // Simplified message structure for internal feedback

View File

@ -16,10 +16,17 @@ import {
import { Logger, MessageSenderType, LogEntry } from './logger.js'; import { Logger, MessageSenderType, LogEntry } from './logger.js';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { Content } from '@google/genai';
const GEMINI_DIR = '.gemini'; const GEMINI_DIR = '.gemini';
const LOG_FILE_NAME = 'logs.json'; const LOG_FILE_NAME = 'logs.json';
const CHECKPOINT_FILE_NAME = 'checkpoint.json';
const TEST_LOG_FILE_PATH = path.join(process.cwd(), GEMINI_DIR, LOG_FILE_NAME); const TEST_LOG_FILE_PATH = path.join(process.cwd(), GEMINI_DIR, LOG_FILE_NAME);
const TEST_CHECKPOINT_FILE_PATH = path.join(
process.cwd(),
GEMINI_DIR,
CHECKPOINT_FILE_NAME,
);
async function cleanupLogFile() { async function cleanupLogFile() {
try { try {
@ -29,11 +36,22 @@ async function cleanupLogFile() {
// Other errors during unlink are ignored for cleanup purposes // Other errors during unlink are ignored for cleanup purposes
} }
} }
try {
await fs.unlink(TEST_CHECKPOINT_FILE_PATH);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
// Other errors during unlink are ignored for cleanup purposes
}
}
try { try {
const geminiDirPath = path.join(process.cwd(), GEMINI_DIR); const geminiDirPath = path.join(process.cwd(), GEMINI_DIR);
const dirContents = await fs.readdir(geminiDirPath); const dirContents = await fs.readdir(geminiDirPath);
for (const file of dirContents) { for (const file of dirContents) {
if (file.startsWith(LOG_FILE_NAME + '.') && file.endsWith('.bak')) { if (
(file.startsWith(LOG_FILE_NAME + '.') ||
file.startsWith(CHECKPOINT_FILE_NAME + '.')) &&
file.endsWith('.bak')
) {
try { try {
await fs.unlink(path.join(geminiDirPath, file)); await fs.unlink(path.join(geminiDirPath, file));
} catch (_e) { } catch (_e) {
@ -408,6 +426,53 @@ describe('Logger', () => {
}); });
}); });
describe('loadCheckpoint', () => {
it('should load and parse a valid checkpoint file', async () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
await fs.writeFile(
TEST_CHECKPOINT_FILE_PATH,
JSON.stringify(conversation),
);
const loadedCheckpoint = await logger.loadCheckpoint();
expect(loadedCheckpoint).toEqual(conversation);
});
it('should return an empty array if the checkpoint file does not exist', async () => {
const loadedCheckpoint = await logger.loadCheckpoint();
expect(loadedCheckpoint).toEqual([]);
});
it('should return an empty array if the file contains invalid JSON', async () => {
await fs.writeFile(TEST_CHECKPOINT_FILE_PATH, 'invalid json');
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const loadedCheckpoint = await logger.loadCheckpoint();
expect(loadedCheckpoint).toEqual([]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to read or parse checkpoint file'),
expect.any(SyntaxError),
);
consoleErrorSpy.mockRestore();
});
it('should return an empty array if logger is not initialized', async () => {
const uninitializedLogger = new Logger();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const loadedCheckpoint = await uninitializedLogger.loadCheckpoint();
expect(loadedCheckpoint).toEqual([]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
);
consoleErrorSpy.mockRestore();
});
});
describe('close', () => { describe('close', () => {
it('should reset logger state', async () => { it('should reset logger state', async () => {
await logger.logMessage(MessageSenderType.USER, 'A message'); await logger.logMessage(MessageSenderType.USER, 'A message');

View File

@ -6,9 +6,11 @@
import path from 'node:path'; import path from 'node:path';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { Content } from '@google/genai';
const GEMINI_DIR = '.gemini'; const GEMINI_DIR = '.gemini';
const LOG_FILE_NAME = 'logs.json'; const LOG_FILE_NAME = 'logs.json';
const CHECKPOINT_FILE_NAME = 'checkpoint.json';
export enum MessageSenderType { export enum MessageSenderType {
USER = 'user', USER = 'user',
@ -24,6 +26,7 @@ export interface LogEntry {
export class Logger { export class Logger {
private logFilePath: string | undefined; private logFilePath: string | undefined;
private checkpointFilePath: string | undefined;
private sessionId: number | undefined; private sessionId: number | undefined;
private messageId = 0; // Instance-specific counter for the next messageId private messageId = 0; // Instance-specific counter for the next messageId
private initialized = false; private initialized = false;
@ -92,6 +95,7 @@ export class Logger {
this.sessionId = Math.floor(Date.now() / 1000); this.sessionId = Math.floor(Date.now() / 1000);
const geminiDir = path.resolve(process.cwd(), GEMINI_DIR); const geminiDir = path.resolve(process.cwd(), GEMINI_DIR);
this.logFilePath = path.join(geminiDir, LOG_FILE_NAME); this.logFilePath = path.join(geminiDir, LOG_FILE_NAME);
this.checkpointFilePath = path.join(geminiDir, CHECKPOINT_FILE_NAME);
try { try {
await fs.mkdir(geminiDir, { recursive: true }); await fs.mkdir(geminiDir, { recursive: true });
@ -229,9 +233,61 @@ export class Logger {
} }
} }
async saveCheckpoint(conversation: Content[]): Promise<void> {
if (!this.initialized || !this.checkpointFilePath) {
console.error(
'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.',
);
return;
}
try {
await fs.writeFile(
this.checkpointFilePath,
JSON.stringify(conversation, null),
'utf-8',
);
} catch (error) {
console.error('Error writing to checkpoint file:', error);
}
}
async loadCheckpoint(): Promise<Content[]> {
if (!this.initialized || !this.checkpointFilePath) {
console.error(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
);
return [];
}
try {
const fileContent = await fs.readFile(this.checkpointFilePath, 'utf-8');
const parsedContent = JSON.parse(fileContent);
if (!Array.isArray(parsedContent)) {
console.warn(
`Checkpoint file at ${this.checkpointFilePath} is not a valid JSON array. Returning empty checkpoint.`,
);
return [];
}
return parsedContent as Content[];
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
// File doesn't exist, which is fine. Return empty array.
return [];
}
console.error(
`Failed to read or parse checkpoint file ${this.checkpointFilePath}:`,
error,
);
return [];
}
}
close(): void { close(): void {
this.initialized = false; this.initialized = false;
this.logFilePath = undefined; this.logFilePath = undefined;
this.checkpointFilePath = undefined;
this.logs = []; this.logs = [];
this.sessionId = undefined; this.sessionId = undefined;
this.messageId = 0; this.messageId = 0;