Session-Level Conversation History Management (#1113)

This commit is contained in:
tanzhenxin
2025-12-03 18:04:48 +08:00
committed by GitHub
parent a7abd8d09f
commit 0a75d85ac9
114 changed files with 9257 additions and 4039 deletions

View File

@@ -0,0 +1,721 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from 'vitest';
import { getProjectHash } from '../utils/paths.js';
import {
SessionService,
buildApiHistoryFromConversation,
type ConversationRecord,
} from './sessionService.js';
import { CompressionStatus } from '../core/turn.js';
import type { ChatRecord } from './chatRecordingService.js';
import * as jsonl from '../utils/jsonl-utils.js';
vi.mock('node:path');
vi.mock('../utils/paths.js');
vi.mock('../utils/jsonl-utils.js');
describe('SessionService', () => {
let sessionService: SessionService;
let readdirSyncSpy: MockInstance<typeof fs.readdirSync>;
let statSyncSpy: MockInstance<typeof fs.statSync>;
let unlinkSyncSpy: MockInstance<typeof fs.unlinkSync>;
beforeEach(() => {
vi.mocked(getProjectHash).mockReturnValue('test-project-hash');
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
vi.mocked(path.dirname).mockImplementation((p) => {
const parts = p.split('/');
parts.pop();
return parts.join('/');
});
sessionService = new SessionService('/test/project/root');
readdirSyncSpy = vi.spyOn(fs, 'readdirSync').mockReturnValue([]);
statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation(
() =>
({
mtimeMs: Date.now(),
isFile: () => true,
}) as fs.Stats,
);
unlinkSyncSpy = vi
.spyOn(fs, 'unlinkSync')
.mockImplementation(() => undefined);
// Mock jsonl-utils
vi.mocked(jsonl.read).mockResolvedValue([]);
vi.mocked(jsonl.readLines).mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
// Test session IDs (UUID-like format)
const sessionIdA = '550e8400-e29b-41d4-a716-446655440000';
const sessionIdB = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const sessionIdC = '6ba7b811-9dad-11d1-80b4-00c04fd430c8';
// Test records
const recordA1: ChatRecord = {
uuid: 'a1',
parentUuid: null,
sessionId: sessionIdA,
timestamp: '2024-01-01T00:00:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'hello session a' }] },
cwd: '/test/project/root',
version: '1.0.0',
gitBranch: 'main',
};
const recordB1: ChatRecord = {
uuid: 'b1',
parentUuid: null,
sessionId: sessionIdB,
timestamp: '2024-01-02T00:00:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'hi session b' }] },
cwd: '/test/project/root',
version: '1.0.0',
gitBranch: 'feature',
};
const recordB2: ChatRecord = {
uuid: 'b2',
parentUuid: 'b1',
sessionId: sessionIdB,
timestamp: '2024-01-02T02:00:00Z',
type: 'assistant',
message: { role: 'model', parts: [{ text: 'hey back' }] },
cwd: '/test/project/root',
version: '1.0.0',
};
describe('listSessions', () => {
it('should return empty list when no sessions exist', async () => {
readdirSyncSpy.mockReturnValue([]);
const result = await sessionService.listSessions();
expect(result.items).toHaveLength(0);
expect(result.hasMore).toBe(false);
expect(result.nextCursor).toBeUndefined();
});
it('should return empty list when chats directory does not exist', async () => {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
readdirSyncSpy.mockImplementation(() => {
throw error;
});
const result = await sessionService.listSessions();
expect(result.items).toHaveLength(0);
expect(result.hasMore).toBe(false);
});
it('should list sessions sorted by mtime descending', async () => {
const now = Date.now();
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
`${sessionIdB}.jsonl`,
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockImplementation((filePath: fs.PathLike) => {
const path = filePath.toString();
return {
mtimeMs: path.includes(sessionIdB) ? now : now - 10000,
isFile: () => true,
} as fs.Stats;
});
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes(sessionIdA)) {
return [recordA1];
}
return [recordB1];
},
);
const result = await sessionService.listSessions();
expect(result.items).toHaveLength(2);
// sessionIdB should be first (more recent mtime)
expect(result.items[0].sessionId).toBe(sessionIdB);
expect(result.items[1].sessionId).toBe(sessionIdA);
});
it('should extract prompt text from first record', async () => {
const now = Date.now();
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const result = await sessionService.listSessions();
expect(result.items[0].prompt).toBe('hello session a');
expect(result.items[0].gitBranch).toBe('main');
});
it('should truncate long prompts', async () => {
const longPrompt = 'A'.repeat(300);
const recordWithLongPrompt: ChatRecord = {
...recordA1,
message: { role: 'user', parts: [{ text: longPrompt }] },
};
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockReturnValue({
mtimeMs: Date.now(),
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.readLines).mockResolvedValue([recordWithLongPrompt]);
const result = await sessionService.listSessions();
expect(result.items[0].prompt.length).toBe(203); // 200 + '...'
expect(result.items[0].prompt.endsWith('...')).toBe(true);
});
it('should paginate with size parameter', async () => {
const now = Date.now();
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
`${sessionIdB}.jsonl`,
`${sessionIdC}.jsonl`,
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockImplementation((filePath: fs.PathLike) => {
const path = filePath.toString();
let mtime = now;
if (path.includes(sessionIdB)) mtime = now - 1000;
if (path.includes(sessionIdA)) mtime = now - 2000;
return {
mtimeMs: mtime,
isFile: () => true,
} as fs.Stats;
});
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes(sessionIdC)) {
return [{ ...recordA1, sessionId: sessionIdC }];
}
if (filePath.includes(sessionIdB)) {
return [recordB1];
}
return [recordA1];
},
);
const result = await sessionService.listSessions({ size: 2 });
expect(result.items).toHaveLength(2);
expect(result.items[0].sessionId).toBe(sessionIdC); // newest
expect(result.items[1].sessionId).toBe(sessionIdB);
expect(result.hasMore).toBe(true);
expect(result.nextCursor).toBeDefined();
});
it('should paginate with cursor parameter', async () => {
const now = Date.now();
const oldMtime = now - 2000;
const cursorMtime = now - 1000;
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
`${sessionIdB}.jsonl`,
`${sessionIdC}.jsonl`,
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockImplementation((filePath: fs.PathLike) => {
const path = filePath.toString();
let mtime = now;
if (path.includes(sessionIdB)) mtime = cursorMtime;
if (path.includes(sessionIdA)) mtime = oldMtime;
return {
mtimeMs: mtime,
isFile: () => true,
} as fs.Stats;
});
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
// Get items older than cursor (cursorMtime)
const result = await sessionService.listSessions({ cursor: cursorMtime });
expect(result.items).toHaveLength(1);
expect(result.items[0].sessionId).toBe(sessionIdA);
expect(result.hasMore).toBe(false);
});
it('should skip files from different projects', async () => {
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockReturnValue({
mtimeMs: Date.now(),
isFile: () => true,
} as fs.Stats);
// This record is from a different cwd (different project)
const differentProjectRecord: ChatRecord = {
...recordA1,
cwd: '/different/project',
};
vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]);
vi.mocked(getProjectHash).mockImplementation((cwd: string) =>
cwd === '/test/project/root'
? 'test-project-hash'
: 'other-project-hash',
);
const result = await sessionService.listSessions();
expect(result.items).toHaveLength(0);
});
it('should skip files that do not match session file pattern', async () => {
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`, // valid
'not-a-uuid.jsonl', // invalid pattern
'readme.txt', // not jsonl
'.hidden.jsonl', // hidden file
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockReturnValue({
mtimeMs: Date.now(),
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const result = await sessionService.listSessions();
// Only the valid UUID pattern file should be processed
expect(result.items).toHaveLength(1);
expect(result.items[0].sessionId).toBe(sessionIdA);
});
});
describe('loadSession', () => {
it('should load a session by id and reconstruct history', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.read).mockResolvedValue([recordB1, recordB2]);
const loaded = await sessionService.loadSession(sessionIdB);
expect(loaded?.conversation.sessionId).toBe(sessionIdB);
expect(loaded?.conversation.messages).toHaveLength(2);
expect(loaded?.conversation.messages[0].uuid).toBe('b1');
expect(loaded?.conversation.messages[1].uuid).toBe('b2');
expect(loaded?.lastCompletedUuid).toBe('b2');
});
it('should return undefined when session file is empty', async () => {
vi.mocked(jsonl.read).mockResolvedValue([]);
const loaded = await sessionService.loadSession('nonexistent');
expect(loaded).toBeUndefined();
});
it('should return undefined when session belongs to different project', async () => {
const now = Date.now();
statSyncSpy.mockReturnValue({
mtimeMs: now,
isFile: () => true,
} as fs.Stats);
const differentProjectRecord: ChatRecord = {
...recordA1,
cwd: '/different/project',
};
vi.mocked(jsonl.read).mockResolvedValue([differentProjectRecord]);
vi.mocked(getProjectHash).mockImplementation((cwd: string) =>
cwd === '/test/project/root'
? 'test-project-hash'
: 'other-project-hash',
);
const loaded = await sessionService.loadSession(sessionIdA);
expect(loaded).toBeUndefined();
});
it('should reconstruct tree-structured history correctly', async () => {
const records: ChatRecord[] = [
{
uuid: 'r1',
parentUuid: null,
sessionId: 'test',
timestamp: '2024-01-01T00:00:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'First' }] },
cwd: '/test/project/root',
version: '1.0.0',
},
{
uuid: 'r2',
parentUuid: 'r1',
sessionId: 'test',
timestamp: '2024-01-01T00:01:00Z',
type: 'assistant',
message: { role: 'model', parts: [{ text: 'Second' }] },
cwd: '/test/project/root',
version: '1.0.0',
},
{
uuid: 'r3',
parentUuid: 'r2',
sessionId: 'test',
timestamp: '2024-01-01T00:02:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'Third' }] },
cwd: '/test/project/root',
version: '1.0.0',
},
];
statSyncSpy.mockReturnValue({
mtimeMs: Date.now(),
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.read).mockResolvedValue(records);
const loaded = await sessionService.loadSession('test');
expect(loaded?.conversation.messages).toHaveLength(3);
expect(loaded?.conversation.messages.map((m) => m.uuid)).toEqual([
'r1',
'r2',
'r3',
]);
});
it('should aggregate multiple records with same uuid', async () => {
const records: ChatRecord[] = [
{
uuid: 'u1',
parentUuid: null,
sessionId: 'test',
timestamp: '2024-01-01T00:00:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'Hello' }] },
cwd: '/test/project/root',
version: '1.0.0',
},
// Multiple records for same assistant message
{
uuid: 'a1',
parentUuid: 'u1',
sessionId: 'test',
timestamp: '2024-01-01T00:01:00Z',
type: 'assistant',
message: {
role: 'model',
parts: [{ thought: true, text: 'Thinking...' }],
},
cwd: '/test/project/root',
version: '1.0.0',
},
{
uuid: 'a1',
parentUuid: 'u1',
sessionId: 'test',
timestamp: '2024-01-01T00:01:01Z',
type: 'assistant',
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
cachedContentTokenCount: 0,
totalTokenCount: 30,
},
cwd: '/test/project/root',
version: '1.0.0',
},
{
uuid: 'a1',
parentUuid: 'u1',
sessionId: 'test',
timestamp: '2024-01-01T00:01:02Z',
type: 'assistant',
message: { role: 'model', parts: [{ text: 'Response' }] },
model: 'gemini-pro',
cwd: '/test/project/root',
version: '1.0.0',
},
];
statSyncSpy.mockReturnValue({
mtimeMs: Date.now(),
isFile: () => true,
} as fs.Stats);
vi.mocked(jsonl.read).mockResolvedValue(records);
const loaded = await sessionService.loadSession('test');
expect(loaded?.conversation.messages).toHaveLength(2);
const assistantMsg = loaded?.conversation.messages[1];
expect(assistantMsg?.uuid).toBe('a1');
expect(assistantMsg?.message?.parts).toHaveLength(2);
expect(assistantMsg?.usageMetadata?.totalTokenCount).toBe(30);
expect(assistantMsg?.model).toBe('gemini-pro');
});
});
describe('removeSession', () => {
it('should remove session file', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const result = await sessionService.removeSession(sessionIdA);
expect(result).toBe(true);
expect(unlinkSyncSpy).toHaveBeenCalled();
});
it('should return false when session does not exist', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([]);
const result = await sessionService.removeSession(
'00000000-0000-0000-0000-000000000000',
);
expect(result).toBe(false);
expect(unlinkSyncSpy).not.toHaveBeenCalled();
});
it('should return false for session from different project', async () => {
const differentProjectRecord: ChatRecord = {
...recordA1,
cwd: '/different/project',
};
vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]);
vi.mocked(getProjectHash).mockImplementation((cwd: string) =>
cwd === '/test/project/root'
? 'test-project-hash'
: 'other-project-hash',
);
const result = await sessionService.removeSession(sessionIdA);
expect(result).toBe(false);
expect(unlinkSyncSpy).not.toHaveBeenCalled();
});
it('should handle file not found error', async () => {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.mocked(jsonl.readLines).mockRejectedValue(error);
const result = await sessionService.removeSession(
'00000000-0000-0000-0000-000000000000',
);
expect(result).toBe(false);
});
});
describe('loadLastSession', () => {
it('should return the most recent session (same as getLatestSession)', async () => {
const now = Date.now();
readdirSyncSpy.mockReturnValue([
`${sessionIdA}.jsonl`,
`${sessionIdB}.jsonl`,
] as unknown as Array<fs.Dirent<Buffer>>);
statSyncSpy.mockImplementation((filePath: fs.PathLike) => {
const path = filePath.toString();
return {
mtimeMs: path.includes(sessionIdB) ? now : now - 10000,
isFile: () => true,
} as fs.Stats;
});
vi.mocked(jsonl.readLines).mockImplementation(
async (filePath: string) => {
if (filePath.includes(sessionIdB)) {
return [recordB1];
}
return [recordA1];
},
);
vi.mocked(jsonl.read).mockResolvedValue([recordB1, recordB2]);
const latest = await sessionService.loadLastSession();
expect(latest?.conversation.sessionId).toBe(sessionIdB);
});
it('should return undefined when no sessions exist', async () => {
readdirSyncSpy.mockReturnValue([]);
const latest = await sessionService.loadLastSession();
expect(latest).toBeUndefined();
});
});
describe('sessionExists', () => {
it('should return true for existing session', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
const exists = await sessionService.sessionExists(sessionIdA);
expect(exists).toBe(true);
});
it('should return false for non-existing session', async () => {
vi.mocked(jsonl.readLines).mockResolvedValue([]);
const exists = await sessionService.sessionExists(
'00000000-0000-0000-0000-000000000000',
);
expect(exists).toBe(false);
});
it('should return false for session from different project', async () => {
const differentProjectRecord: ChatRecord = {
...recordA1,
cwd: '/different/project',
};
vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]);
vi.mocked(getProjectHash).mockImplementation((cwd: string) =>
cwd === '/test/project/root'
? 'test-project-hash'
: 'other-project-hash',
);
const exists = await sessionService.sessionExists(sessionIdA);
expect(exists).toBe(false);
});
});
describe('buildApiHistoryFromConversation', () => {
it('should return linear messages when no compression checkpoint exists', () => {
const assistantA1: ChatRecord = {
...recordB2,
sessionId: sessionIdA,
parentUuid: recordA1.uuid,
};
const conversation: ConversationRecord = {
sessionId: sessionIdA,
projectHash: 'test-project-hash',
startTime: '2024-01-01T00:00:00Z',
lastUpdated: '2024-01-01T00:00:00Z',
messages: [recordA1, assistantA1],
};
const history = buildApiHistoryFromConversation(conversation);
expect(history).toEqual([recordA1.message, assistantA1.message]);
});
it('should use compressedHistory snapshot and append subsequent records after compression', () => {
const compressionRecord: ChatRecord = {
uuid: 'c1',
parentUuid: 'b2',
sessionId: sessionIdA,
timestamp: '2024-01-02T03:00:00Z',
type: 'system',
subtype: 'chat_compression',
cwd: '/test/project/root',
version: '1.0.0',
gitBranch: 'main',
systemPayload: {
info: {
originalTokenCount: 100,
newTokenCount: 50,
compressionStatus: CompressionStatus.COMPRESSED,
},
compressedHistory: [
{ role: 'user', parts: [{ text: 'summary' }] },
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the additional context!' }],
},
recordB2.message!,
],
},
};
const postCompressionRecord: ChatRecord = {
uuid: 'c2',
parentUuid: 'c1',
sessionId: sessionIdA,
timestamp: '2024-01-02T04:00:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'new question' }] },
cwd: '/test/project/root',
version: '1.0.0',
gitBranch: 'main',
};
const conversation: ConversationRecord = {
sessionId: sessionIdA,
projectHash: 'test-project-hash',
startTime: '2024-01-01T00:00:00Z',
lastUpdated: '2024-01-02T04:00:00Z',
messages: [
recordA1,
recordB2,
compressionRecord,
postCompressionRecord,
],
};
const history = buildApiHistoryFromConversation(conversation);
expect(history).toEqual([
{ role: 'user', parts: [{ text: 'summary' }] },
{
role: 'model',
parts: [{ text: 'Got it. Thanks for the additional context!' }],
},
recordB2.message,
postCompressionRecord.message,
]);
});
});
});