Add [tag] to /save and /resume (#916)
This commit is contained in:
parent
d6b6d5976d
commit
8e0d5076d6
|
@ -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;
|
||||||
|
|
|
@ -426,23 +426,96 @@ describe('Logger', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('saveCheckpoint', () => {
|
||||||
|
const conversation: Content[] = [
|
||||||
|
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||||
|
{ 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', () => {
|
describe('loadCheckpoint', () => {
|
||||||
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' }] },
|
];
|
||||||
];
|
|
||||||
|
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 () => {
|
||||||
|
|
|
@ -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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue