Compare commits

...

5 Commits

4 changed files with 450 additions and 0 deletions
@@ -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)}`,
);
}
}
}
+1
Submodule tools-refactor added at 6f1970df81