Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/services/chatRecordingTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface ConversationRecord {
lastUpdated: string;
messages: MessageRecord[];
summary?: string;
/** Raw memory notes extracted from the session (markdown) */
memoryScratchpad?: string;
/** Workspace directories added during the session via /dir add */
directories?: string[];
/** The kind of conversation (main agent or subagent) */
Expand Down
331 changes: 331 additions & 0 deletions packages/core/src/services/sessionSummaryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SessionSummaryService } from './sessionSummaryService.js';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import type { MessageRecord } from './chatRecordingService.js';
import type { GenerateContentResponse } from '@google/genai';
import { CoreToolCallStatus } from '../scheduler/types.js';

describe('SessionSummaryService', () => {
let service: SessionSummaryService;
Expand Down Expand Up @@ -940,3 +941,333 @@ describe('SessionSummaryService', () => {
});
});
});

describe('SessionSummaryService - generateMemoryExtraction', () => {
let service: SessionSummaryService;
let mockBaseLlmClient: BaseLlmClient;
let mockGenerateContent: ReturnType<typeof vi.fn>;

const sampleExtraction = `# Fix auth token refresh bug

cwd: ~/projects/my-app
outcome: success
keywords: tokenManager, 401, race condition

## What was done
Debugged intermittent 401 errors caused by concurrent token refresh calls.

## How the user works
- Prefers seeing a proposed fix before any edits are made

## What we learned
- Token refresh lives in src/auth/tokenManager.ts`;

beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();

mockGenerateContent = vi.fn().mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: sampleExtraction }],
},
},
],
} as unknown as GenerateContentResponse);

mockBaseLlmClient = {
generateContent: mockGenerateContent,
} as unknown as BaseLlmClient;

service = new SessionSummaryService(mockBaseLlmClient);
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('should return summary parsed from heading and full scratchpad', async () => {
const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Fix the auth token refresh bug' }],
},
{
id: '2',
timestamp: '2026-01-01T00:01:00Z',
type: 'gemini',
content: [{ text: 'I found a race condition in tokenManager.ts' }],
},
];

const result = await service.generateMemoryExtraction({ messages });

expect(result).not.toBeNull();
expect(result!.summary).toBe('Fix auth token refresh bug');
expect(result!.memoryScratchpad).toBe(sampleExtraction);
});

it('should use promptId session-memory-extraction', async () => {
const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Hello' }],
},
];

await service.generateMemoryExtraction({ messages });

expect(mockGenerateContent).toHaveBeenCalledWith(
expect.objectContaining({
promptId: 'session-memory-extraction',
}),
);
});

it('should fall back to first line when no heading found', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: 'No heading here\n\nSome content' }],
},
},
],
} as unknown as GenerateContentResponse);

const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Hello' }],
},
];

const result = await service.generateMemoryExtraction({ messages });

expect(result).not.toBeNull();
expect(result!.summary).toBe('No heading here');
});

it('should return null for empty messages', async () => {
const result = await service.generateMemoryExtraction({ messages: [] });

expect(result).toBeNull();
expect(mockGenerateContent).not.toHaveBeenCalled();
});

it('should return null when LLM returns empty text', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: '' }],
},
},
],
} as unknown as GenerateContentResponse);

const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Hello' }],
},
];

const result = await service.generateMemoryExtraction({ messages });

expect(result).toBeNull();
});

it('should return null on timeout', async () => {
mockGenerateContent.mockImplementation(
({ abortSignal }) =>
new Promise((resolve, reject) => {
const timeoutId = setTimeout(
() =>
resolve({
candidates: [{ content: { parts: [{ text: 'Too late' }] } }],
}),
60000,
);

abortSignal?.addEventListener(
'abort',
() => {
clearTimeout(timeoutId);
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
reject(abortError);
},
{ once: true },
);
}),
);

const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Hello' }],
},
];

const resultPromise = service.generateMemoryExtraction({
messages,
timeout: 100,
});

await vi.advanceTimersByTimeAsync(100);

const result = await resultPromise;
expect(result).toBeNull();
});

it('should return null on API error', async () => {
mockGenerateContent.mockRejectedValue(new Error('API Error'));

const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Hello' }],
},
];

const result = await service.generateMemoryExtraction({ messages });

expect(result).toBeNull();
});

it('should use larger message window than generateSummary', async () => {
const messages: MessageRecord[] = Array.from({ length: 60 }, (_, i) => ({
id: `${i}`,
timestamp: '2026-01-01T00:00:00Z',
type: i % 2 === 0 ? ('user' as const) : ('gemini' as const),
content: [{ text: `Message ${i}` }],
}));

await service.generateMemoryExtraction({ messages });

expect(mockGenerateContent).toHaveBeenCalledTimes(1);
const callArgs = mockGenerateContent.mock.calls[0][0];
const promptText = callArgs.contents[0].parts[0].text;

// Should include 50 messages (25 first + 25 last), not 20
const messageCount = (promptText.match(/Message \d+/g) || []).length;
expect(messageCount).toBe(50);
});

it('should truncate messages at 2000 chars instead of 500', async () => {
const longMessage = 'x'.repeat(2500);
const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: longMessage }],
},
];

await service.generateMemoryExtraction({ messages });

const callArgs = mockGenerateContent.mock.calls[0][0];
const promptText = callArgs.contents[0].parts[0].text;

// Should contain 2000 x's + '...' but not the full 2500
expect(promptText).toContain('x'.repeat(2000));
expect(promptText).toContain('...');
expect(promptText).not.toContain('x'.repeat(2001));
});

it('should remove quotes from parsed summary', async () => {
mockGenerateContent.mockResolvedValue({
candidates: [
{
content: {
parts: [{ text: '# "Fix the bug"\n\nSome content' }],
},
},
],
} as unknown as GenerateContentResponse);

const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Fix the bug' }],
},
];

const result = await service.generateMemoryExtraction({ messages });

expect(result).not.toBeNull();
expect(result!.summary).toBe('Fix the bug');
});

it('should include recorded tool calls when gemini content is empty', async () => {
const messages: MessageRecord[] = [
{
id: '1',
timestamp: '2026-01-01T00:00:00Z',
type: 'user',
content: [{ text: 'Figure out why session memory is missing context' }],
},
{
id: '2',
timestamp: '2026-01-01T00:01:00Z',
type: 'gemini',
content: [{ text: '' }],
toolCalls: [
{
id: 'tool-1',
name: 'run_shell_command',
args: {
command:
'cat packages/core/src/services/sessionSummaryService.ts',
},
result: [
{
functionResponse: {
id: 'tool-1',
name: 'run_shell_command',
response: {
output:
'packages/core/src/services/sessionSummaryService.ts',
},
},
},
],
status: CoreToolCallStatus.Success,
timestamp: '2026-01-01T00:01:05Z',
},
],
},
];

await service.generateMemoryExtraction({ messages });

const callArgs = mockGenerateContent.mock.calls[0][0];
const promptText = callArgs.contents[0].parts[0].text as string;

expect(promptText).toContain('Tool: run_shell_command');
expect(promptText).toContain(
'"command":"cat packages/core/src/services/sessionSummaryService.ts"',
);
expect(promptText).toContain(
'packages/core/src/services/sessionSummaryService.ts',
);
});
});
Loading
Loading