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

@@ -23,19 +23,6 @@ import {
} from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
existsSync: vi.fn().mockReturnValue(true),
statSync: vi.fn().mockReturnValue({
isDirectory: vi.fn().mockReturnValue(true),
}),
realpathSync: vi.fn((path) => path),
};
});
import { ShellTool } from '../tools/shell.js';
import { ReadFileTool } from '../tools/read-file.js';
import { GrepTool } from '../tools/grep.js';
@@ -54,9 +41,9 @@ function createToolMock(toolName: string) {
return ToolMock;
}
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mocked = {
...actual,
existsSync: vi.fn().mockReturnValue(true),
statSync: vi.fn().mockReturnValue({
@@ -64,6 +51,10 @@ vi.mock('fs', async (importOriginal) => {
}),
realpathSync: vi.fn((path) => path),
};
return {
...mocked,
default: mocked, // Required for ESM default imports (import fs from 'node:fs')
};
});
// Mock dependencies that might be called during Config construction or createServerConfig
@@ -197,7 +188,6 @@ describe('Server Config (config.ts)', () => {
const USER_MEMORY = 'Test User Memory';
const TELEMETRY_SETTINGS = { enabled: false };
const EMBEDDING_MODEL = 'gemini-embedding';
const SESSION_ID = 'test-session-id';
const baseParams: ConfigParameters = {
cwd: '/tmp',
embeddingModel: EMBEDDING_MODEL,
@@ -208,7 +198,6 @@ describe('Server Config (config.ts)', () => {
fullContext: FULL_CONTEXT,
userMemory: USER_MEMORY,
telemetry: TELEMETRY_SETTINGS,
sessionId: SESSION_ID,
model: MODEL,
usageStatisticsEnabled: false,
};
@@ -217,7 +206,7 @@ describe('Server Config (config.ts)', () => {
// Reset mocks if necessary
vi.clearAllMocks();
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
() => undefined,
async () => undefined,
);
});
@@ -476,7 +465,7 @@ describe('Server Config (config.ts)', () => {
...baseParams,
usageStatisticsEnabled: true,
});
await config.refreshAuth(AuthType.USE_GEMINI);
await config.initialize();
expect(QwenLogger.prototype.logStartSessionEvent).toHaveBeenCalledOnce();
});
@@ -956,7 +945,6 @@ describe('Server Config (config.ts)', () => {
describe('setApprovalMode with folder trust', () => {
const baseParams: ConfigParameters = {
sessionId: 'test',
targetDir: '.',
debugMode: false,
model: 'test-model',
@@ -987,7 +975,6 @@ describe('setApprovalMode with folder trust', () => {
it('should NOT throw an error when setting PLAN mode in an untrusted folder', () => {
const config = new Config({
sessionId: 'test',
targetDir: '.',
debugMode: false,
model: 'test-model',
@@ -1168,7 +1155,6 @@ describe('BaseLlmClient Lifecycle', () => {
const USER_MEMORY = 'Test User Memory';
const TELEMETRY_SETTINGS = { enabled: false };
const EMBEDDING_MODEL = 'gemini-embedding';
const SESSION_ID = 'test-session-id';
const baseParams: ConfigParameters = {
cwd: '/tmp',
embeddingModel: EMBEDDING_MODEL,
@@ -1179,7 +1165,6 @@ describe('BaseLlmClient Lifecycle', () => {
fullContext: FULL_CONTEXT,
userMemory: USER_MEMORY,
telemetry: TELEMETRY_SETTINGS,
sessionId: SESSION_ID,
model: MODEL,
usageStatisticsEnabled: false,
};

View File

@@ -69,7 +69,7 @@ import {
DEFAULT_OTLP_ENDPOINT,
DEFAULT_TELEMETRY_TARGET,
initializeTelemetry,
logCliConfiguration,
logStartSession,
logRipgrepFallback,
RipgrepFallbackEvent,
StartSessionEvent,
@@ -93,6 +93,12 @@ import {
import { DEFAULT_QWEN_EMBEDDING_MODEL, DEFAULT_QWEN_MODEL } from './models.js';
import { Storage } from './storage.js';
import { DEFAULT_DASHSCOPE_BASE_URL } from '../core/openaiContentGenerator/constants.js';
import { ChatRecordingService } from '../services/chatRecordingService.js';
import {
SessionService,
type ResumedSessionData,
} from '../services/sessionService.js';
import { randomUUID } from 'node:crypto';
// Re-export types
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
@@ -110,6 +116,42 @@ export enum ApprovalMode {
export const APPROVAL_MODES = Object.values(ApprovalMode);
/**
* Information about an approval mode including display name and description.
*/
export interface ApprovalModeInfo {
id: ApprovalMode;
name: string;
description: string;
}
/**
* Detailed information about each approval mode.
* Used for UI display and protocol responses.
*/
export const APPROVAL_MODE_INFO: Record<ApprovalMode, ApprovalModeInfo> = {
[ApprovalMode.PLAN]: {
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Analyze only, do not modify files or execute commands',
},
[ApprovalMode.DEFAULT]: {
id: ApprovalMode.DEFAULT,
name: 'Default',
description: 'Require approval for file edits or shell commands',
},
[ApprovalMode.AUTO_EDIT]: {
id: ApprovalMode.AUTO_EDIT,
name: 'Auto Edit',
description: 'Automatically approve file edits',
},
[ApprovalMode.YOLO]: {
id: ApprovalMode.YOLO,
name: 'YOLO',
description: 'Automatically approve all tools',
},
};
export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
screenReader?: boolean;
@@ -211,7 +253,8 @@ export interface SandboxConfig {
}
export interface ConfigParameters {
sessionId: string;
sessionId?: string;
sessionData?: ResumedSessionData;
embeddingModel?: string;
sandbox?: SandboxConfig;
targetDir: string;
@@ -315,10 +358,11 @@ function normalizeConfigOutputFormat(
}
export class Config {
private sessionId: string;
private sessionData?: ResumedSessionData;
private toolRegistry!: ToolRegistry;
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
private readonly sessionId: string;
private fileSystemService: FileSystemService;
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
@@ -358,6 +402,8 @@ export class Config {
};
private fileDiscoveryService: FileDiscoveryService | null = null;
private gitService: GitService | undefined = undefined;
private sessionService: SessionService | undefined = undefined;
private chatRecordingService: ChatRecordingService | undefined = undefined;
private readonly checkpointing: boolean;
private readonly proxy: string | undefined;
private readonly cwd: string;
@@ -415,7 +461,8 @@ export class Config {
private readonly useSmartEdit: boolean;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.sessionId = params.sessionId ?? randomUUID();
this.sessionData = params.sessionData;
this.embeddingModel = params.embeddingModel ?? DEFAULT_QWEN_EMBEDDING_MODEL;
this.fileSystemService = new StandardFileSystemService();
this.sandbox = params.sandbox;
@@ -540,6 +587,7 @@ export class Config {
setGlobalDispatcher(new ProxyAgent(this.getProxy() as string));
}
this.geminiClient = new GeminiClient(this);
this.chatRecordingService = new ChatRecordingService(this);
}
/**
@@ -561,6 +609,8 @@ export class Config {
this.toolRegistry = await this.createToolRegistry();
await this.geminiClient.initialize();
logStartSession(this, new StartSessionEvent(this));
}
getContentGenerator(): ContentGenerator {
@@ -606,7 +656,6 @@ export class Config {
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
this,
this.getSessionId(),
isInitialAuth,
);
// Only assign to instance properties after successful initialization
@@ -617,9 +666,6 @@ export class Config {
// Reset the session flag since we're explicitly changing auth and using default model
this.inFallbackMode = false;
// Logging the cli configuration here as the auth related configuration params would have been loaded by this point
logCliConfiguration(this, new StartSessionEvent(this, this.toolRegistry));
}
/**
@@ -646,6 +692,26 @@ export class Config {
return this.sessionId;
}
/**
* Starts a new session and resets session-scoped services.
*/
startNewSession(sessionId?: string): string {
this.sessionId = sessionId ?? randomUUID();
this.sessionData = undefined;
this.chatRecordingService = new ChatRecordingService(this);
if (this.initialized) {
logStartSession(this, new StartSessionEvent(this));
}
return this.sessionId;
}
/**
* Returns the resumed session data if this session was resumed from a previous one.
*/
getResumedSessionData(): ResumedSessionData | undefined {
return this.sessionData;
}
shouldLoadMemoryFromIncludeDirectories(): boolean {
return this.loadMemoryFromIncludeDirectories;
}
@@ -1128,6 +1194,26 @@ export class Config {
return this.gitService;
}
/**
* Returns the chat recording service.
*/
getChatRecordingService(): ChatRecordingService {
if (!this.chatRecordingService) {
this.chatRecordingService = new ChatRecordingService(this);
}
return this.chatRecordingService;
}
/**
* Gets or creates a SessionService for managing chat sessions.
*/
getSessionService(): SessionService {
if (!this.sessionService) {
this.sessionService = new SessionService(this.targetDir);
}
return this.sessionService;
}
getFileExclusions(): FileExclusions {
return this.fileExclusions;
}

View File

@@ -20,7 +20,6 @@ describe('Flash Model Fallback Configuration', () => {
isDirectory: () => true,
} as fs.Stats);
config = new Config({
sessionId: 'test-session',
targetDir: '/test',
debugMode: false,
cwd: '/test',
@@ -44,7 +43,6 @@ describe('Flash Model Fallback Configuration', () => {
it('should only mark as switched if contentGeneratorConfig exists', async () => {
// Create config without initializing contentGeneratorConfig
const newConfig = new Config({
sessionId: 'test-session-2',
targetDir: '/test',
debugMode: false,
cwd: '/test',
@@ -67,7 +65,6 @@ describe('Flash Model Fallback Configuration', () => {
it('should fall back to initial model if contentGeneratorConfig is not available', () => {
// Test with fresh config where contentGeneratorConfig might not be set
const newConfig = new Config({
sessionId: 'test-session-2',
targetDir: '/test',
debugMode: false,
cwd: '/test',

View File

@@ -4,18 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import * as os from 'node:os';
import * as path from 'node:path';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
mkdirSync: vi.fn(),
};
});
import { Storage } from './storage.js';
describe('Storage getGlobalSettingsPath', () => {

View File

@@ -14,6 +14,7 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const OAUTH_FILE = 'oauth_creds.json';
const TMP_DIR_NAME = 'tmp';
const BIN_DIR_NAME = 'bin';
const PROJECT_DIR_NAME = 'projects';
export class Storage {
private readonly targetDir: string;
@@ -66,6 +67,12 @@ export class Storage {
return path.join(this.targetDir, QWEN_DIR);
}
getProjectDir(): string {
const projectId = this.sanitizeCwd(this.getProjectRoot());
const projectsDir = path.join(Storage.getGlobalQwenDir(), PROJECT_DIR_NAME);
return path.join(projectsDir, projectId);
}
getProjectTempDir(): string {
const hash = this.getFilePathHash(this.getProjectRoot());
const tempDir = Storage.getGlobalTempDir();
@@ -117,4 +124,8 @@ export class Storage {
getHistoryFilePath(): string {
return path.join(this.getProjectTempDir(), 'shell_history');
}
private sanitizeCwd(cwd: string): string {
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
}
}