mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 01:37:50 +00:00
Session-Level Conversation History Management (#1113)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, '-');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user