Add [tag] to /save and /resume (#916)

This commit is contained in:
Seth Troisi 2025-06-10 16:58:39 -07:00 committed by GitHub
parent d6b6d5976d
commit 8e0d5076d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 122 additions and 36 deletions

View File

@ -490,35 +490,42 @@ Add any other context about the problem here.
}, },
{ {
name: 'save', name: 'save',
description: 'save conversation checkpoint', description: 'save conversation checkpoint. Usage: /save [tag]',
action: async (_mainCommand, _subCommand, _args) => { action: async (_mainCommand, subCommand, _args) => {
const tag = (subCommand || '').trim();
const logger = new Logger(); const logger = new Logger();
await logger.initialize(); await logger.initialize();
const chat = await config?.getGeminiClient()?.getChat(); const chat = await config?.getGeminiClient()?.getChat();
const history = chat?.getHistory() || []; const history = chat?.getHistory() || [];
if (history.length > 0) { if (history.length > 0) {
logger.saveCheckpoint(chat?.getHistory() || []); await logger.saveCheckpoint(chat?.getHistory() || [], tag);
addMessage({
type: MessageType.INFO,
content: `Conversation checkpoint saved${tag ? ' with tag: ' + tag : ''}.`,
timestamp: new Date(),
});
} else { } else {
addMessage({ addMessage({
type: MessageType.INFO, type: MessageType.INFO,
content: 'No conversation found to save.', content: 'No conversation found to save.',
timestamp: new Date(), timestamp: new Date(),
}); });
return;
} }
}, },
}, },
{ {
name: 'resume', name: 'resume',
description: 'resume from last conversation checkpoint', description:
action: async (_mainCommand, _subCommand, _args) => { 'resume from conversation checkpoint. Usage: /resume [tag]',
action: async (_mainCommand, subCommand, _args) => {
const tag = (subCommand || '').trim();
const logger = new Logger(); const logger = new Logger();
await logger.initialize(); await logger.initialize();
const conversation = await logger.loadCheckpoint(); const conversation = await logger.loadCheckpoint(tag);
if (conversation.length === 0) { if (conversation.length === 0) {
addMessage({ addMessage({
type: MessageType.INFO, type: MessageType.INFO,
content: 'No saved conversation found.', content: `No saved checkpoint found${tag ? ' with tag: ' + tag : ''}.`,
timestamp: new Date(), timestamp: new Date(),
}); });
return; return;

View File

@ -426,23 +426,96 @@ describe('Logger', () => {
}); });
}); });
describe('loadCheckpoint', () => { describe('saveCheckpoint', () => {
it('should load and parse a valid checkpoint file', async () => {
const conversation: Content[] = [ const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] }, { role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] }, { role: 'model', parts: [{ text: 'Hi there' }] },
]; ];
it('should save a checkpoint to the default file when no tag is provided', async () => {
await logger.saveCheckpoint(conversation);
const fileContent = await fs.readFile(TEST_CHECKPOINT_FILE_PATH, 'utf-8');
expect(JSON.parse(fileContent)).toEqual(conversation);
});
it('should save a checkpoint to a tagged file when a tag is provided', async () => {
const tag = 'my-test-tag';
await logger.saveCheckpoint(conversation, tag);
const taggedFilePath = path.join(
process.cwd(),
GEMINI_DIR,
`${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`,
);
const fileContent = await fs.readFile(taggedFilePath, 'utf-8');
expect(JSON.parse(fileContent)).toEqual(conversation);
// cleanup
await fs.unlink(taggedFilePath);
});
it('should not throw if logger is not initialized', async () => {
const uninitializedLogger = new Logger();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(
uninitializedLogger.saveCheckpoint(conversation),
).resolves.not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.',
);
consoleErrorSpy.mockRestore();
});
});
describe('loadCheckpoint', () => {
const conversation: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
beforeEach(async () => {
// Create a default checkpoint for some tests
await fs.writeFile( await fs.writeFile(
TEST_CHECKPOINT_FILE_PATH, TEST_CHECKPOINT_FILE_PATH,
JSON.stringify(conversation), 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 () => { it('should load from the default checkpoint file when no tag is provided', async () => {
const loadedCheckpoint = await logger.loadCheckpoint(); const loaded = await logger.loadCheckpoint();
expect(loadedCheckpoint).toEqual([]); expect(loaded).toEqual(conversation);
});
it('should load from a tagged checkpoint file when a tag is provided', async () => {
const tag = 'my-load-tag';
const taggedConversation = [
...conversation,
{ role: 'user', parts: [{ text: 'Another message' }] },
];
const taggedFilePath = path.join(
process.cwd(),
GEMINI_DIR,
`${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`,
);
await fs.writeFile(taggedFilePath, JSON.stringify(taggedConversation));
const loaded = await logger.loadCheckpoint(tag);
expect(loaded).toEqual(taggedConversation);
// cleanup
await fs.unlink(taggedFilePath);
});
it('should return an empty array if a tagged checkpoint file does not exist', async () => {
const loaded = await logger.loadCheckpoint('non-existent-tag');
expect(loaded).toEqual([]);
});
it('should return an empty array if the default checkpoint file does not exist', async () => {
await fs.unlink(TEST_CHECKPOINT_FILE_PATH); // Ensure it's gone
const loaded = await logger.loadCheckpoint();
expect(loaded).toEqual([]);
}); });
it('should return an empty array if the file contains invalid JSON', async () => { it('should return an empty array if the file contains invalid JSON', async () => {

View File

@ -25,6 +25,7 @@ export interface LogEntry {
} }
export class Logger { export class Logger {
private geminiDir: string | undefined;
private logFilePath: string | undefined; private logFilePath: string | undefined;
private checkpointFilePath: string | undefined; private checkpointFilePath: string | undefined;
private sessionId: number | undefined; private sessionId: number | undefined;
@ -93,12 +94,12 @@ export class Logger {
return; return;
} }
this.sessionId = Math.floor(Date.now() / 1000); this.sessionId = Math.floor(Date.now() / 1000);
const geminiDir = path.resolve(process.cwd(), GEMINI_DIR); this.geminiDir = path.resolve(process.cwd(), GEMINI_DIR);
this.logFilePath = path.join(geminiDir, LOG_FILE_NAME); this.logFilePath = path.join(this.geminiDir, LOG_FILE_NAME);
this.checkpointFilePath = path.join(geminiDir, CHECKPOINT_FILE_NAME); this.checkpointFilePath = path.join(this.geminiDir, CHECKPOINT_FILE_NAME);
try { try {
await fs.mkdir(geminiDir, { recursive: true }); await fs.mkdir(this.geminiDir, { recursive: true });
let fileExisted = true; let fileExisted = true;
try { try {
await fs.access(this.logFilePath); await fs.access(this.logFilePath);
@ -233,26 +234,32 @@ export class Logger {
} }
} }
async saveCheckpoint(conversation: Content[]): Promise<void> { _checkpointPath(tag: string | undefined): string {
if (!this.checkpointFilePath || !this.geminiDir) {
throw new Error('Checkpoint file path not set.');
}
if (!tag) {
return this.checkpointFilePath;
}
return path.join(this.geminiDir, `checkpoint-${tag}.json`);
}
async saveCheckpoint(conversation: Content[], tag?: string): Promise<void> {
if (!this.initialized || !this.checkpointFilePath) { if (!this.initialized || !this.checkpointFilePath) {
console.error( console.error(
'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.', 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.',
); );
return; return;
} }
const path = this._checkpointPath(tag);
try { try {
await fs.writeFile( await fs.writeFile(path, JSON.stringify(conversation, null), 'utf-8');
this.checkpointFilePath,
JSON.stringify(conversation, null),
'utf-8',
);
} catch (error) { } catch (error) {
console.error('Error writing to checkpoint file:', error); console.error('Error writing to checkpoint file:', error);
} }
} }
async loadCheckpoint(): Promise<Content[]> { async loadCheckpoint(tag?: string): Promise<Content[]> {
if (!this.initialized || !this.checkpointFilePath) { if (!this.initialized || !this.checkpointFilePath) {
console.error( console.error(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
@ -260,12 +267,14 @@ export class Logger {
return []; return [];
} }
const path = this._checkpointPath(tag);
try { try {
const fileContent = await fs.readFile(this.checkpointFilePath, 'utf-8'); const fileContent = await fs.readFile(path, 'utf-8');
const parsedContent = JSON.parse(fileContent); const parsedContent = JSON.parse(fileContent);
if (!Array.isArray(parsedContent)) { if (!Array.isArray(parsedContent)) {
console.warn( console.warn(
`Checkpoint file at ${this.checkpointFilePath} is not a valid JSON array. Returning empty checkpoint.`, `Checkpoint file at ${path} is not a valid JSON array. Returning empty checkpoint.`,
); );
return []; return [];
} }
@ -276,10 +285,7 @@ export class Logger {
// File doesn't exist, which is fine. Return empty array. // File doesn't exist, which is fine. Return empty array.
return []; return [];
} }
console.error( console.error(`Failed to read or parse checkpoint file ${path}:`, error);
`Failed to read or parse checkpoint file ${this.checkpointFilePath}:`,
error,
);
return []; return [];
} }
} }