mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 16:20:57 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6eca7f663 | |||
| 0569a3dba5 | |||
| d872e9dcfd | |||
| 4e60c886d2 | |||
| 64e19fd60f |
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HookRunner } from './hookRunner.js';
|
||||
import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
SessionEndReason,
|
||||
type BuiltinHookConfig,
|
||||
type SessionEndInput,
|
||||
} from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
describe('HookRunner (Builtin Hooks)', () => {
|
||||
let hookRunner: HookRunner;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockConfig = {
|
||||
sanitizationConfig: {},
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
getConversation: vi.fn().mockReturnValue({ messages: [] }),
|
||||
getConversationFilePath: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/transcript.json'),
|
||||
}),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work'),
|
||||
} as unknown as Config;
|
||||
|
||||
hookRunner = new HookRunner(mockConfig);
|
||||
});
|
||||
|
||||
it('should execute session-learnings builtin hook on SessionEnd', async () => {
|
||||
const hookConfig: BuiltinHookConfig = {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
name: 'test-learnings',
|
||||
};
|
||||
|
||||
const input: SessionEndInput = {
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/tmp/transcript.json',
|
||||
cwd: '/work',
|
||||
hook_event_name: HookEventName.SessionEnd,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: SessionEndReason.Exit,
|
||||
};
|
||||
|
||||
// Spy on the service
|
||||
const serviceSpy = vi
|
||||
.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookRunner as any).sessionLearningsService,
|
||||
'generateAndSaveLearnings',
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await hookRunner.executeHook(
|
||||
hookConfig,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(serviceSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not execute session-learnings if reason is not exit/logout', async () => {
|
||||
const hookConfig: BuiltinHookConfig = {
|
||||
type: HookType.Builtin,
|
||||
builtin_id: 'session-learnings',
|
||||
};
|
||||
|
||||
const input: SessionEndInput = {
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/tmp/transcript.json',
|
||||
cwd: '/work',
|
||||
hook_event_name: HookEventName.SessionEnd,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: SessionEndReason.Clear,
|
||||
};
|
||||
|
||||
const serviceSpy = vi.spyOn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(hookRunner as any).sessionLearningsService,
|
||||
'generateAndSaveLearnings',
|
||||
);
|
||||
|
||||
const result = await hookRunner.executeHook(
|
||||
hookConfig,
|
||||
HookEventName.SessionEnd,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(serviceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionLearningsService } from './sessionLearningsService.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
describe('SessionLearningsService', () => {
|
||||
let service: SessionLearningsService;
|
||||
let mockConfig: unknown;
|
||||
let mockRecordingService: any;
|
||||
let mockGeminiClient: any;
|
||||
let mockContentGenerator: any;
|
||||
let mockGenerateContent: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGenerateContent = vi.fn().mockImplementation((_params, promptId) => {
|
||||
if (promptId === 'session-learnings-generation') {
|
||||
return Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: '# Session Learnings\nSummary text here.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
} else if (promptId === 'session-summary-generation') {
|
||||
return Promise.resolve({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Mock Session Title' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected promptId: ${promptId}`));
|
||||
});
|
||||
|
||||
mockContentGenerator = {
|
||||
generateContent: mockGenerateContent,
|
||||
};
|
||||
|
||||
mockRecordingService = {
|
||||
getConversation: vi.fn().mockReturnValue({
|
||||
messages: [
|
||||
{ type: 'user', content: [{ text: 'Question' }] },
|
||||
{ type: 'gemini', content: [{ text: 'Answer' }] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mockGeminiClient = {
|
||||
getChatRecordingService: () => mockRecordingService,
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
isSessionLearningsEnabled: vi.fn().mockReturnValue(true),
|
||||
getSessionLearningsOutputPath: vi.fn().mockReturnValue(undefined),
|
||||
getGeminiClient: () => mockGeminiClient,
|
||||
getContentGenerator: () => mockContentGenerator,
|
||||
getWorkingDir: () => '/mock/cwd',
|
||||
getActiveModel: () => 'gemini-1.5-flash',
|
||||
getModel: () => 'gemini-1.5-flash',
|
||||
isInteractive: () => true,
|
||||
setActiveModel: vi.fn(),
|
||||
getUserTier: () => 'free',
|
||||
getContentGeneratorConfig: () => ({ authType: 'apiKey' }),
|
||||
getModelAvailabilityService: () => ({
|
||||
selectFirstAvailable: (models: string[]) => ({
|
||||
selectedModel: models[0],
|
||||
}),
|
||||
consumeStickyAttempt: vi.fn(),
|
||||
markHealthy: vi.fn(),
|
||||
}),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi
|
||||
.fn()
|
||||
.mockReturnValue({ model: 'gemini-1.5-flash', config: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
service = new SessionLearningsService(mockConfig as Config);
|
||||
|
||||
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined);
|
||||
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should generate and save learnings with descriptive filename', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).toHaveBeenCalledTimes(2);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/mock/cwd', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom output path if configured', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||
'custom/path',
|
||||
);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(
|
||||
path.join('/mock/cwd', 'custom/path'),
|
||||
{ recursive: true },
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join(
|
||||
'/mock/cwd',
|
||||
'custom/path',
|
||||
`learnings-Mock-Session-Title-${dateStr}.md`,
|
||||
),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use absolute output path if configured', async () => {
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
(mockConfig as any).getSessionLearningsOutputPath.mockReturnValue(
|
||||
'/absolute/path',
|
||||
);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith('/absolute/path', {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/absolute/path', `learnings-Mock-Session-Title-${dateStr}.md`),
|
||||
'# Session Learnings\nSummary text here.',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not generate learnings if disabled', async () => {
|
||||
(mockConfig as any).isSessionLearningsEnabled.mockReturnValue(false);
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not generate learnings if not enough messages', async () => {
|
||||
mockRecordingService.getConversation.mockReturnValue({
|
||||
messages: [{ type: 'user', content: [{ text: 'Single message' }] }],
|
||||
});
|
||||
|
||||
await service.generateAndSaveLearnings();
|
||||
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockGenerateContent.mockRejectedValue(new Error('LLM Error'));
|
||||
|
||||
// Should not throw
|
||||
await expect(service.generateAndSaveLearnings()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../config/config.js';
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { SessionSummaryService } from './sessionSummaryService.js';
|
||||
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const MIN_MESSAGES = 2;
|
||||
const TIMEOUT_MS = 60000; // Increased timeout for potentially larger context
|
||||
|
||||
const LEARNINGS_PROMPT = `It's time to pause on this development. Looking back at what you have done so far:
|
||||
Prepare a summary of the problem you were trying to solve, the analysis synthesized, and information you would need to implement this request if you were to start again
|
||||
Don't focus on unnecessary details - keep the abstraction at a level that allows a senior engineer for example, to take it from you.
|
||||
Do focus on gotchas, explored paths that didn't go anywhere with a why, and what you'd do differently.
|
||||
Also note down other issues you might have found as future project ideas.
|
||||
|
||||
Conversation transcript follows:
|
||||
---
|
||||
{transcript}
|
||||
---
|
||||
|
||||
Provide your response in Markdown format.`;
|
||||
|
||||
/**
|
||||
* Service to generate and save session learnings summaries.
|
||||
*/
|
||||
export class SessionLearningsService {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
/**
|
||||
* Generates a summary of the session learnings and saves it to a file.
|
||||
*/
|
||||
async generateAndSaveLearnings(): Promise<void> {
|
||||
try {
|
||||
// Check if enabled in settings
|
||||
if (!this.config.isSessionLearningsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
const recordingService = geminiClient.getChatRecordingService();
|
||||
|
||||
if (!recordingService) {
|
||||
debugLogger.debug('[SessionLearnings] Recording service not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = recordingService.getConversation();
|
||||
|
||||
if (!conversation || conversation.messages.length < MIN_MESSAGES) {
|
||||
debugLogger.debug(
|
||||
`[SessionLearnings] Skipping summary, not enough messages (${conversation?.messages.length || 0})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare transcript (no max messages, no max length)
|
||||
const transcript = conversation.messages
|
||||
.map((msg) => {
|
||||
const role = msg.type === 'user' ? 'User' : 'Assistant';
|
||||
const content = partListUnionToString(msg.content);
|
||||
return `[${role}]: ${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = LEARNINGS_PROMPT.replace('{transcript}', transcript);
|
||||
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
debugLogger.debug('[SessionLearnings] Content generator not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, this.config);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
];
|
||||
|
||||
debugLogger.debug('[SessionLearnings] Generating summary...');
|
||||
const response = await baseLlmClient.generateContent({
|
||||
modelConfigKey: { model: 'summarizer-default' },
|
||||
contents,
|
||||
abortSignal: abortController.signal,
|
||||
promptId: 'session-learnings-generation',
|
||||
});
|
||||
|
||||
const summaryText = getResponseText(response);
|
||||
if (!summaryText) {
|
||||
debugLogger.warn(
|
||||
'[SessionLearnings] Failed to generate summary (empty response)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate descriptive filename
|
||||
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||
const sessionTitle = await summaryService.generateSummary({
|
||||
messages: conversation.messages,
|
||||
});
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const sanitizedTitle = sessionTitle
|
||||
? sanitizeFilenamePart(sessionTitle.trim().replace(/\s+/g, '-'))
|
||||
: 'untitled';
|
||||
|
||||
const fileName = `learnings-${sanitizedTitle}-${dateStr}.md`;
|
||||
|
||||
// Determine output directory
|
||||
const configOutputPath = this.config.getSessionLearningsOutputPath();
|
||||
let outputDir = this.config.getWorkingDir();
|
||||
|
||||
if (configOutputPath) {
|
||||
if (path.isAbsolute(configOutputPath)) {
|
||||
outputDir = configOutputPath;
|
||||
} else {
|
||||
outputDir = path.join(
|
||||
this.config.getWorkingDir(),
|
||||
configOutputPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
await fs.writeFile(filePath, summaryText, 'utf-8');
|
||||
debugLogger.log(
|
||||
`[SessionLearnings] Saved session learnings to ${filePath}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`[SessionLearnings] Error generating learnings: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule tools-refactor added at 6f1970df81
Reference in New Issue
Block a user