diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 0cbace59e..dbdab8de4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -64,6 +64,7 @@ import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; import { Session } from './session/Session.js'; import { formatAcpModelId } from '../utils/acpModelUtils.js'; +import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; const debugLogger = createDebugLogger('ACP_AGENT'); @@ -214,8 +215,14 @@ class QwenAgent implements Agent { } async loadSession(params: LoadSessionRequest): Promise { - const sessionService = new SessionService(params.cwd); - const exists = await sessionService.sessionExists(params.sessionId); + const exists = await runWithAcpRuntimeOutputDir( + this.settings, + params.cwd, + async () => { + const sessionService = new SessionService(params.cwd); + return sessionService.sessionExists(params.sessionId); + }, + ); if (!exists) { throw RequestError.invalidParams( undefined, @@ -256,10 +263,12 @@ class QwenAgent implements Agent { params: ListSessionsRequest, ): Promise { const cwd = params.cwd || process.cwd(); - const sessionService = new SessionService(cwd); const numericCursor = params.cursor ? Number(params.cursor) : undefined; - const result = await sessionService.listSessions({ - cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, + const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => { + const sessionService = new SessionService(cwd); + return sessionService.listSessions({ + cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, + }); }); const sessions: SessionInfo[] = result.items.map((item) => ({ diff --git a/packages/cli/src/acp-integration/runtimeOutputDirContext.test.ts b/packages/cli/src/acp-integration/runtimeOutputDirContext.test.ts new file mode 100644 index 000000000..e1068a74c --- /dev/null +++ b/packages/cli/src/acp-integration/runtimeOutputDirContext.test.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { Storage } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js'; + +describe('runWithAcpRuntimeOutputDir', () => { + beforeEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + it('uses the merged runtimeOutputDir relative to cwd within the async context', async () => { + const cwd = path.resolve('workspace', 'project-a'); + const settings = { + merged: { + advanced: { + runtimeOutputDir: '.qwen-runtime', + }, + }, + } as LoadedSettings; + + await runWithAcpRuntimeOutputDir(settings, cwd, async () => { + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen-runtime')); + }); + + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); +}); diff --git a/packages/cli/src/acp-integration/runtimeOutputDirContext.ts b/packages/cli/src/acp-integration/runtimeOutputDirContext.ts new file mode 100644 index 000000000..7387e5994 --- /dev/null +++ b/packages/cli/src/acp-integration/runtimeOutputDirContext.ts @@ -0,0 +1,14 @@ +import { Storage } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; + +export function runWithAcpRuntimeOutputDir( + settings: LoadedSettings, + cwd: string, + fn: () => T, +): T { + return Storage.runWithRuntimeBaseDir( + settings.merged.advanced?.runtimeOutputDir, + cwd, + fn, + ); +} diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 346537409..9715d765c 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -58,6 +58,7 @@ describe('Session', () => { switchModel: switchModelSpy, getModel: vi.fn().mockImplementation(() => currentModel), getSessionId: vi.fn().mockReturnValue('test-session-id'), + getWorkingDir: vi.fn().mockReturnValue(process.cwd()), getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue(undefined), @@ -241,5 +242,38 @@ describe('Session', () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it('runs prompt inside runtime output dir context', async () => { + const runtimeDir = path.resolve('runtime', 'from-settings'); + core.Storage.setRuntimeBaseDir(runtimeDir); + session = new Session( + 'test-session-id', + mockChat, + mockConfig, + mockClient, + mockSettings, + ); + const runWithRuntimeBaseDirSpy = vi.spyOn( + core.Storage, + 'runWithRuntimeBaseDir', + ); + + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue((async function* () {})()); + + const promptRequest: PromptRequest = { + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }; + + await session.prompt(promptRequest); + + expect(runWithRuntimeBaseDirSpy).toHaveBeenCalledWith( + runtimeDir, + process.cwd(), + expect.any(Function), + ); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 226871608..29da32b3e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -34,6 +34,7 @@ import { TodoWriteTool, ExitPlanModeTool, readManyFiles, + Storage, ToolNames, } from '@qwen-code/qwen-code-core'; @@ -100,6 +101,7 @@ export class Session implements SessionContext { */ private pendingPromptCompletion: Promise | null = null; private turn: number = 0; + private readonly runtimeBaseDir: string; // Modular components private readonly historyReplayer: HistoryReplayer; @@ -118,6 +120,7 @@ export class Session implements SessionContext { private readonly settings: LoadedSettings, ) { this.sessionId = id; + this.runtimeBaseDir = Storage.getRuntimeBaseDir(); // Initialize modular components with this session as context this.toolCallEmitter = new ToolCallEmitter(this); @@ -189,150 +192,170 @@ export class Session implements SessionContext { params: PromptRequest, pendingSend: AbortController, ): Promise { - // Increment turn counter for each user prompt - this.turn += 1; + return Storage.runWithRuntimeBaseDir( + this.runtimeBaseDir, + this.config.getWorkingDir(), + async () => { + // Increment turn counter for each user prompt + this.turn += 1; - const chat = this.chat; - const promptId = this.config.getSessionId() + '########' + this.turn; + const chat = this.chat; + const promptId = this.config.getSessionId() + '########' + this.turn; - // Extract text from all text blocks to construct the full prompt text for logging - const promptText = params.prompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '); + // Extract text from all text blocks to construct the full prompt text for logging + const promptText = params.prompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '); - // Log user prompt - logUserPrompt( - this.config, - new UserPromptEvent( - promptText.length, - promptId, - this.config.getContentGeneratorConfig()?.authType, - promptText, - ), - ); - - // record user message for session management - this.config.getChatRecordingService()?.recordUserMessage(promptText); - - // Check if the input contains a slash command - // Extract text from the first text block if present - const firstTextBlock = params.prompt.find((block) => block.type === 'text'); - const inputText = firstTextBlock?.text || ''; - - let parts: Part[] | null; - - if (isSlashCommand(inputText)) { - // Handle slash command - uses default allowed commands (init, summary, compress) - const slashCommandResult = await handleSlashCommand( - inputText, - pendingSend, - this.config, - this.settings, - ); - - parts = await this.#processSlashCommandResult( - slashCommandResult, - params.prompt, - ); - - // If parts is null, the command was fully handled (e.g., /summary completed) - // Return early without sending to the model - if (parts === null) { - return { stopReason: 'end_turn' }; - } - } else { - // Normal processing for non-slash commands - parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); - } - - let nextMessage: Content | null = { role: 'user', parts }; - - while (nextMessage !== null) { - if (pendingSend.signal.aborted) { - chat.addHistory(nextMessage); - return { stopReason: 'cancelled' }; - } - - const functionCalls: FunctionCall[] = []; - let usageMetadata: GenerateContentResponseUsageMetadata | null = null; - const streamStartTime = Date.now(); - - try { - const responseStream = await chat.sendMessageStream( - this.config.getModel(), - { - message: nextMessage?.parts ?? [], - config: { - abortSignal: pendingSend.signal, - }, - }, - promptId, + // Log user prompt + logUserPrompt( + this.config, + new UserPromptEvent( + promptText.length, + promptId, + this.config.getContentGeneratorConfig()?.authType, + promptText, + ), ); - nextMessage = null; - for await (const resp of responseStream) { + // record user message for session management + this.config.getChatRecordingService()?.recordUserMessage(promptText); + + // Check if the input contains a slash command + // Extract text from the first text block if present + const firstTextBlock = params.prompt.find( + (block) => block.type === 'text', + ); + const inputText = firstTextBlock?.text || ''; + + let parts: Part[] | null; + + if (isSlashCommand(inputText)) { + // Handle slash command - uses default allowed commands (init, summary, compress) + const slashCommandResult = await handleSlashCommand( + inputText, + pendingSend, + this.config, + this.settings, + ); + + parts = await this.#processSlashCommandResult( + slashCommandResult, + params.prompt, + ); + + // If parts is null, the command was fully handled (e.g., /summary completed) + // Return early without sending to the model + if (parts === null) { + return { stopReason: 'end_turn' }; + } + } else { + // Normal processing for non-slash commands + parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + } + + let nextMessage: Content | null = { role: 'user', parts }; + + while (nextMessage !== null) { if (pendingSend.signal.aborted) { + chat.addHistory(nextMessage); return { stopReason: 'cancelled' }; } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.candidates && - resp.value.candidates.length > 0 - ) { - const candidate = resp.value.candidates[0]; - for (const part of candidate.content?.parts ?? []) { - if (!part.text) { - continue; + const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = null; + const streamStartTime = Date.now(); + + try { + const responseStream = await chat.sendMessageStream( + this.config.getModel(), + { + message: nextMessage?.parts ?? [], + config: { + abortSignal: pendingSend.signal, + }, + }, + promptId, + ); + nextMessage = null; + + for await (const resp of responseStream) { + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; } - this.messageEmitter.emitMessage( - part.text, - 'assistant', - part.thought, + if ( + resp.type === StreamEventType.CHUNK && + resp.value.candidates && + resp.value.candidates.length > 0 + ) { + const candidate = resp.value.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) { + continue; + } + + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); + } + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.usageMetadata + ) { + usageMetadata = resp.value.usageMetadata; + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.functionCalls + ) { + functionCalls.push(...resp.value.functionCalls); + } + } + } catch (error) { + if (getErrorStatus(error) === 429) { + throw new RequestError( + 429, + 'Rate limit exceeded. Try again later.', ); } + + throw error; } - if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { - usageMetadata = resp.value.usageMetadata; + if (usageMetadata) { + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, + ); } - if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { - functionCalls.push(...resp.value.functionCalls); + if (functionCalls.length > 0) { + const toolResponseParts: Part[] = []; + + for (const fc of functionCalls) { + const response = await this.runTool( + pendingSend.signal, + promptId, + fc, + ); + toolResponseParts.push(...response); + } + + nextMessage = { role: 'user', parts: toolResponseParts }; } } - } catch (error) { - if (getErrorStatus(error) === 429) { - throw new RequestError(429, 'Rate limit exceeded. Try again later.'); - } - - throw error; - } - - if (usageMetadata) { - const durationMs = Date.now() - streamStartTime; - await this.messageEmitter.emitUsageMetadata( - usageMetadata, - '', - durationMs, - ); - } - - if (functionCalls.length > 0) { - const toolResponseParts: Part[] = []; - - for (const fc of functionCalls) { - const response = await this.runTool(pendingSend.signal, promptId, fc); - toolResponseParts.push(...response); - } - - nextMessage = { role: 'user', parts: toolResponseParts }; - } - } - - return { stopReason: 'end_turn' }; + return { stopReason: 'end_turn' }; + }, + ); } async sendUpdate(update: SessionUpdate): Promise { diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 207ecb8dd..d1b2bd3f8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -14,6 +14,7 @@ import { DEFAULT_QWEN_MODEL, OutputFormat, NativeLspService, + Storage, } from '@qwen-code/qwen-code-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import type { Settings } from './settings.js'; @@ -2439,3 +2440,79 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); }); + +describe('loadCliConfig runtimeOutputDir', () => { + const originalArgv = process.argv; + const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + process.argv = ['node', 'script.js']; + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + process.argv = originalArgv; + Storage.setRuntimeBaseDir(null); + if (originalRuntimeEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should set runtime base dir from settings with absolute path', async () => { + const runtimeDir = path.resolve('custom', 'runtime'); + const argv = await parseArguments(); + const settings: Settings = { + advanced: { runtimeOutputDir: runtimeDir }, + }; + await loadCliConfig(settings, argv); + expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir); + }); + + it('should resolve relative runtimeOutputDir against cwd', async () => { + const argv = await parseArguments(); + const settings: Settings = { + advanced: { runtimeOutputDir: '.qwen' }, + }; + const cwd = path.resolve('workspace', 'my-project'); + await loadCliConfig(settings, argv, cwd); + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen')); + }); + + it('should not set runtime base dir when runtimeOutputDir is absent', async () => { + const argv = await parseArguments(); + const settings: Settings = {}; + await loadCliConfig(settings, argv); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('should let QWEN_RUNTIME_DIR env var take priority over settings', async () => { + const envDir = path.resolve('from-env'); + const settingsDir = path.resolve('from-settings'); + process.env['QWEN_RUNTIME_DIR'] = envDir; + const argv = await parseArguments(); + const settings: Settings = { + advanced: { runtimeOutputDir: settingsDir }, + }; + await loadCliConfig(settings, argv); + // getRuntimeBaseDir checks env var first at call time + expect(Storage.getRuntimeBaseDir()).toBe(envDir); + }); + + it('should reset runtime base dir on subsequent load when runtimeOutputDir is absent', async () => { + const argv = await parseArguments(); + const firstRuntimeDir = path.resolve('first', 'runtime'); + await loadCliConfig( + { advanced: { runtimeOutputDir: firstRuntimeDir } }, + argv, + ); + expect(Storage.getRuntimeBaseDir()).toBe(firstRuntimeDir); + + await loadCliConfig({}, argv); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d1b8fbf86..78ef3dde0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -708,6 +708,11 @@ export async function loadCliConfig( ): Promise { const debugMode = isDebugMode(argv); + // Set runtime output directory from settings (env var QWEN_RUNTIME_DIR + // is auto-detected inside getRuntimeBaseDir() at each call site). + // Pass cwd so that relative paths like ".qwen" resolve per-project. + Storage.setRuntimeBaseDir(settings.advanced?.runtimeOutputDir, cwd); + const ideMode = settings.ide?.enabled ?? false; const folderTrust = settings.security?.folderTrust?.enabled ?? false; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c97b41f86..379ea2168 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1263,6 +1263,17 @@ const SETTINGS_SCHEMA = { description: 'Configuration for the bug report command.', showInDialog: false, }, + runtimeOutputDir: { + type: 'string', + label: 'Runtime Output Directory', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). ' + + 'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.', + showInDialog: false, + }, tavilyApiKey: { type: 'string', label: 'Tavily API Key (Deprecated)', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 477aecd7e..b28ed2591 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -326,8 +326,15 @@ export async function main() { } } - // Handle --resume without a session ID by showing the session picker + // Handle --resume without a session ID by showing the session picker. + // Set the runtime output dir early so the picker can find sessions stored + // under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will + // be called again inside loadCliConfig). if (argv.resume === '') { + Storage.setRuntimeBaseDir( + settings.merged.advanced?.runtimeOutputDir, + process.cwd(), + ); const selectedSessionId = await showResumeSessionPicker(); if (!selectedSessionId) { // User cancelled or no sessions available diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.test.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.test.ts new file mode 100644 index 000000000..7c8e8dedb --- /dev/null +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { Storage, type Config } from '@qwen-code/qwen-code-core'; +import { StaticInsightGenerator } from './StaticInsightGenerator.js'; + +vi.mock('fs/promises', () => ({ + default: { + mkdir: vi.fn(), + access: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + symlink: vi.fn(), + copyFile: vi.fn(), + }, +})); + +describe('StaticInsightGenerator', () => { + const mockedFs = vi.mocked(fs); + const mockConfig = {} as Config; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-05T12:34:56.000Z')); + Storage.setRuntimeBaseDir(path.resolve('runtime-output')); + vi.clearAllMocks(); + + mockedFs.mkdir.mockResolvedValue(undefined); + mockedFs.access.mockRejectedValue(new Error('not found')); + mockedFs.writeFile.mockResolvedValue(undefined); + mockedFs.unlink.mockRejectedValue(new Error('not found')); + mockedFs.symlink.mockResolvedValue(undefined); + mockedFs.copyFile.mockResolvedValue(undefined); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('writes insights under runtime output directory', async () => { + const generator = new StaticInsightGenerator(mockConfig); + const generateInsights = vi.fn().mockResolvedValue({}); + const renderInsightHTML = vi.fn().mockResolvedValue('ok'); + + ( + generator as unknown as { + dataProcessor: { generateInsights: typeof generateInsights }; + } + ).dataProcessor = { generateInsights }; + ( + generator as unknown as { + templateRenderer: { renderInsightHTML: typeof renderInsightHTML }; + } + ).templateRenderer = { renderInsightHTML }; + + const projectsDir = path.resolve( + 'workspace', + 'project-a', + '.qwen', + 'projects', + ); + const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights'); + const facetsDir = path.join(outputDir, 'facets'); + const expectedOutputPath = path.join(outputDir, 'insight-2026-03-05.html'); + + const outputPath = await generator.generateStaticInsight(projectsDir); + + expect(mockedFs.mkdir).toHaveBeenCalledWith(outputDir, { recursive: true }); + expect(mockedFs.mkdir).toHaveBeenCalledWith(facetsDir, { recursive: true }); + expect(generateInsights).toHaveBeenCalledWith( + projectsDir, + facetsDir, + undefined, + ); + expect(renderInsightHTML).toHaveBeenCalledWith({}); + expect(mockedFs.writeFile).toHaveBeenCalledWith( + expectedOutputPath, + 'ok', + 'utf-8', + ); + expect(outputPath).toBe(expectedOutputPath); + }); +}); diff --git a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts index 6d0c661cc..51fca7e99 100644 --- a/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts +++ b/packages/cli/src/services/insight/generators/StaticInsightGenerator.ts @@ -6,7 +6,6 @@ import fs from 'fs/promises'; import path from 'path'; -import os from 'os'; import { DataProcessor } from './DataProcessor.js'; import { TemplateRenderer } from './TemplateRenderer.js'; import type { @@ -14,7 +13,7 @@ import type { InsightProgressCallback, } from '../types/StaticInsightTypes.js'; -import { updateSymlink, type Config } from '@qwen-code/qwen-code-core'; +import { updateSymlink, Storage, type Config } from '@qwen-code/qwen-code-core'; export class StaticInsightGenerator { private dataProcessor: DataProcessor; @@ -27,7 +26,7 @@ export class StaticInsightGenerator { // Ensure the output directory exists private async ensureOutputDirectory(): Promise { - const outputDir = path.join(os.homedir(), '.qwen', 'insights'); + const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights'); await fs.mkdir(outputDir, { recursive: true }); return outputDir; } diff --git a/packages/cli/src/ui/commands/insightCommand.test.ts b/packages/cli/src/ui/commands/insightCommand.test.ts new file mode 100644 index 000000000..159dd1747 --- /dev/null +++ b/packages/cli/src/ui/commands/insightCommand.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import path from 'path'; +import open from 'open'; +import { Storage } from '@qwen-code/qwen-code-core'; +import { insightCommand } from './insightCommand.js'; +import type { CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +const mockGenerateStaticInsight = vi.fn(); + +vi.mock('../../services/insight/generators/StaticInsightGenerator.js', () => ({ + StaticInsightGenerator: vi.fn(() => ({ + generateStaticInsight: mockGenerateStaticInsight, + })), +})); + +vi.mock('open', () => ({ + default: vi.fn(), +})); + +describe('insightCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + Storage.setRuntimeBaseDir(path.resolve('runtime-output')); + mockGenerateStaticInsight.mockResolvedValue( + path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'), + ); + vi.mocked(open).mockResolvedValue(undefined as never); + + mockContext = createMockCommandContext({ + services: { + config: {} as CommandContext['services']['config'], + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + vi.restoreAllMocks(); + }); + + it('uses runtime base dir to locate projects directory', async () => { + if (!insightCommand.action) { + throw new Error('insight command must have action'); + } + + await insightCommand.action(mockContext, ''); + + expect(mockGenerateStaticInsight).toHaveBeenCalledWith( + path.join(Storage.getRuntimeBaseDir(), 'projects'), + expect.any(Function), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts index 1693254bb..de28381b3 100644 --- a/packages/cli/src/ui/commands/insightCommand.ts +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -10,9 +10,8 @@ import { MessageType } from '../types.js'; import type { HistoryItemInsightProgress } from '../types.js'; import { t } from '../../i18n/index.js'; import { join } from 'path'; -import os from 'os'; import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { createDebugLogger, Storage } from '@qwen-code/qwen-code-core'; import open from 'open'; const logger = createDebugLogger('DataProcessor'); @@ -29,7 +28,7 @@ export const insightCommand: SlashCommand = { try { context.ui.setDebugMessage(t('Generating insights...')); - const projectsDir = join(os.homedir(), '.qwen', 'projects'); + const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects'); if (!context.services.config) { throw new Error('Config service is not available'); } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 36cc1b258..a32b9fbde 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { Storage } from './storage.js'; @@ -40,3 +40,321 @@ describe('Storage – additional helpers', () => { expect(Storage.getMcpOAuthTokensPath()).toBe(expected); }); }); + +describe('Storage – getRuntimeBaseDir / setRuntimeBaseDir', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + // Reset state before each test + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + // Restore original env + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('defaults to getGlobalQwenDir() when nothing is configured', () => { + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('uses setRuntimeBaseDir value when set with absolute path', () => { + const runtimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(runtimeDir); + expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir); + }); + + it('env var QWEN_RUNTIME_DIR takes priority over setRuntimeBaseDir', () => { + const settingsDir = path.resolve('from-settings'); + const envDir = path.resolve('from-env'); + Storage.setRuntimeBaseDir(settingsDir); + process.env['QWEN_RUNTIME_DIR'] = envDir; + expect(Storage.getRuntimeBaseDir()).toBe(envDir); + }); + + it('expands tilde (~) in setRuntimeBaseDir', () => { + Storage.setRuntimeBaseDir('~/custom-runtime'); + const expected = path.join(os.homedir(), 'custom-runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('expands Windows-style tilde paths in setRuntimeBaseDir', () => { + Storage.setRuntimeBaseDir('~\\custom-runtime'); + const expected = path.join(os.homedir(), 'custom-runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('expands tilde (~) in QWEN_RUNTIME_DIR env var', () => { + process.env['QWEN_RUNTIME_DIR'] = '~/env-runtime'; + const expected = path.join(os.homedir(), 'env-runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resolves relative paths in setRuntimeBaseDir using process.cwd by default', () => { + Storage.setRuntimeBaseDir('relative/path'); + const expected = path.resolve('relative/path'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resolves relative paths in setRuntimeBaseDir using explicit cwd', () => { + const cwd = path.resolve('workspace', 'projectA'); + Storage.setRuntimeBaseDir('.qwen', cwd); + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen')); + }); + + it('ignores cwd when path is absolute', () => { + const absolutePath = path.resolve('absolute', 'path'); + const cwd = path.resolve('workspace', 'projectA'); + Storage.setRuntimeBaseDir(absolutePath, cwd); + expect(Storage.getRuntimeBaseDir()).toBe(absolutePath); + }); + + it('ignores cwd when path starts with tilde', () => { + Storage.setRuntimeBaseDir( + '~/runtime', + path.resolve('workspace', 'projectA'), + ); + const expected = path.join(os.homedir(), 'runtime'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resolves relative paths in QWEN_RUNTIME_DIR env var', () => { + process.env['QWEN_RUNTIME_DIR'] = 'relative/env-path'; + const expected = path.resolve('relative/env-path'); + expect(Storage.getRuntimeBaseDir()).toBe(expected); + }); + + it('resets to default when setRuntimeBaseDir is called with null', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getRuntimeBaseDir()).toBe(customDir); + + Storage.setRuntimeBaseDir(null); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('resets to default when setRuntimeBaseDir is called with undefined', () => { + Storage.setRuntimeBaseDir(path.resolve('custom')); + Storage.setRuntimeBaseDir(undefined); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('resets to default when setRuntimeBaseDir is called with empty string', () => { + Storage.setRuntimeBaseDir(path.resolve('custom')); + Storage.setRuntimeBaseDir(''); + expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir()); + }); + + it('handles bare tilde (~) as home directory', () => { + Storage.setRuntimeBaseDir('~'); + expect(Storage.getRuntimeBaseDir()).toBe(os.homedir()); + }); +}); + +describe('Storage – runtime path methods use getRuntimeBaseDir', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('getGlobalTempDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getGlobalTempDir()).toBe(path.join(customDir, 'tmp')); + }); + + it('getGlobalDebugDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getGlobalDebugDir()).toBe(path.join(customDir, 'debug')); + }); + + it('getDebugLogPath uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getDebugLogPath('session-123')).toBe( + path.join(customDir, 'debug', 'session-123.txt'), + ); + }); + + it('getGlobalIdeDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + expect(Storage.getGlobalIdeDir()).toBe(path.join(customDir, 'ide')); + }); + + it('getProjectDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getProjectDir()).toContain(path.join(customDir, 'projects')); + }); + + it('getHistoryDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getHistoryDir()).toContain(path.join(customDir, 'history')); + }); + + it('getProjectTempDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getProjectTempDir()).toContain(path.join(customDir, 'tmp')); + }); + + it('getProjectTempCheckpointsDir uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getProjectTempCheckpointsDir()).toContain( + path.join(customDir, 'tmp'), + ); + expect(storage.getProjectTempCheckpointsDir()).toMatch(/checkpoints$/); + }); + + it('getHistoryFilePath uses custom runtime base dir', () => { + const customDir = path.resolve('custom'); + Storage.setRuntimeBaseDir(customDir); + const storage = new Storage('/tmp/project'); + expect(storage.getHistoryFilePath()).toContain(path.join(customDir, 'tmp')); + expect(storage.getHistoryFilePath()).toMatch(/shell_history$/); + }); +}); + +describe('Storage – config paths remain at ~/.qwen regardless of runtime dir', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + const globalQwenDir = Storage.getGlobalQwenDir(); + + beforeEach(() => { + Storage.setRuntimeBaseDir(path.resolve('custom-runtime')); + process.env['QWEN_RUNTIME_DIR'] = path.resolve('env-runtime'); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('getGlobalSettingsPath still uses ~/.qwen', () => { + expect(Storage.getGlobalSettingsPath()).toBe( + path.join(globalQwenDir, 'settings.json'), + ); + }); + + it('getInstallationIdPath still uses ~/.qwen', () => { + expect(Storage.getInstallationIdPath()).toBe( + path.join(globalQwenDir, 'installation_id'), + ); + }); + + it('getGoogleAccountsPath still uses ~/.qwen', () => { + expect(Storage.getGoogleAccountsPath()).toBe( + path.join(globalQwenDir, 'google_accounts.json'), + ); + }); + + it('getMcpOAuthTokensPath still uses ~/.qwen', () => { + expect(Storage.getMcpOAuthTokensPath()).toBe( + path.join(globalQwenDir, 'mcp-oauth-tokens.json'), + ); + }); + + it('getOAuthCredsPath still uses ~/.qwen', () => { + expect(Storage.getOAuthCredsPath()).toBe( + path.join(globalQwenDir, 'oauth_creds.json'), + ); + }); + + it('getUserCommandsDir still uses ~/.qwen', () => { + expect(Storage.getUserCommandsDir()).toBe( + path.join(globalQwenDir, 'commands'), + ); + }); + + it('getGlobalMemoryFilePath still uses ~/.qwen', () => { + expect(Storage.getGlobalMemoryFilePath()).toBe( + path.join(globalQwenDir, 'memory.md'), + ); + }); + + it('getGlobalBinDir still uses ~/.qwen', () => { + expect(Storage.getGlobalBinDir()).toBe(path.join(globalQwenDir, 'bin')); + }); + + it('getUserSkillsDirs still includes ~/.qwen/skills', () => { + const storage = new Storage('/tmp/project'); + const skillsDirs = storage.getUserSkillsDirs(); + expect( + skillsDirs.some((dir) => dir === path.join(globalQwenDir, 'skills')), + ).toBe(true); + }); +}); + +describe('Storage – runtime base dir async context isolation', () => { + const originalEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + }); + + it('uses contextual runtime dir inside runWithRuntimeBaseDir', async () => { + Storage.setRuntimeBaseDir(path.resolve('global-runtime')); + const cwd = path.resolve('workspace', 'project-a'); + + await Storage.runWithRuntimeBaseDir('.qwen', cwd, async () => { + expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen')); + }); + }); + + it('keeps concurrent contexts isolated', async () => { + const cwdA = path.resolve('workspace', 'a'); + const cwdB = path.resolve('workspace', 'b'); + + const runA = Storage.runWithRuntimeBaseDir('.qwen-a', cwdA, async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return Storage.getRuntimeBaseDir(); + }); + + const runB = Storage.runWithRuntimeBaseDir('.qwen-b', cwdB, async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return Storage.getRuntimeBaseDir(); + }); + + const [a, b] = await Promise.all([runA, runB]); + expect(a).toBe(path.join(cwdA, '.qwen-a')); + expect(b).toBe(path.join(cwdB, '.qwen-b')); + }); +}); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 211c499a9..e29cefa62 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -7,6 +7,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { getProjectHash, sanitizeCwd } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; @@ -23,10 +24,99 @@ const ARENA_DIR_NAME = 'arena'; export class Storage { private readonly targetDir: string; + /** + * Custom runtime output base directory set via settings. + * When null, falls back to getGlobalQwenDir(). + */ + private static runtimeBaseDir: string | null = null; + private static readonly runtimeBaseDirContext = new AsyncLocalStorage< + string | null + >(); + constructor(targetDir: string) { this.targetDir = targetDir; } + private static resolveRuntimeBaseDir( + dir: string | null | undefined, + cwd?: string, + ): string | null { + if (!dir) { + return null; + } + + let resolved = dir; + if ( + resolved === '~' || + resolved.startsWith('~/') || + resolved.startsWith('~\\') + ) { + const relativeSegments = + resolved === '~' + ? [] + : resolved + .slice(2) + .split(/[/\\]+/) + .filter(Boolean); + resolved = path.join(os.homedir(), ...relativeSegments); + } + if (!path.isAbsolute(resolved)) { + resolved = cwd ? path.resolve(cwd, resolved) : path.resolve(resolved); + } + return resolved; + } + + /** + * Sets the custom runtime output base directory. + * Handles tilde (~) expansion and resolves relative paths to absolute. + * Pass null/undefined/empty string to reset to default (getGlobalQwenDir()). + * @param dir - The directory path, or null/undefined to reset + * @param cwd - Base directory for resolving relative paths (defaults to process.cwd()). + * Pass the project root so that relative values like ".qwen" resolve + * per-project, enabling a single global config to work across all projects. + */ + static setRuntimeBaseDir(dir: string | null | undefined, cwd?: string): void { + Storage.runtimeBaseDir = Storage.resolveRuntimeBaseDir(dir, cwd); + } + + /** + * Runs function execution in an async context with a specific runtime output dir. + * This is used to isolate runtime output paths between concurrent sessions. + */ + static runWithRuntimeBaseDir( + dir: string | null | undefined, + cwd: string | undefined, + fn: () => T, + ): T { + const resolved = Storage.resolveRuntimeBaseDir(dir, cwd); + return Storage.runtimeBaseDirContext.run(resolved, fn); + } + + /** + * Returns the base directory for all runtime output (temp files, debug logs, + * session data, todos, insights, etc.). + * + * Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir() + * @returns Absolute path to the runtime output base directory + */ + static getRuntimeBaseDir(): string { + const envDir = process.env['QWEN_RUNTIME_DIR']; + if (envDir) { + return ( + Storage.resolveRuntimeBaseDir(envDir) ?? Storage.getGlobalQwenDir() + ); + } + + const contextualDir = Storage.runtimeBaseDirContext.getStore(); + if (contextualDir !== undefined) { + return contextualDir ?? Storage.getGlobalQwenDir(); + } + if (Storage.runtimeBaseDir) { + return Storage.runtimeBaseDir; + } + return Storage.getGlobalQwenDir(); + } + static getGlobalQwenDir(): string { const homeDir = os.homedir(); if (!homeDir) { @@ -60,11 +150,11 @@ export class Storage { } static getGlobalTempDir(): string { - return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME); + return path.join(Storage.getRuntimeBaseDir(), TMP_DIR_NAME); } static getGlobalDebugDir(): string { - return path.join(Storage.getGlobalQwenDir(), DEBUG_DIR_NAME); + return path.join(Storage.getRuntimeBaseDir(), DEBUG_DIR_NAME); } static getDebugLogPath(sessionId: string): string { @@ -72,7 +162,7 @@ export class Storage { } static getGlobalIdeDir(): string { - return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME); + return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME); } static getGlobalBinDir(): string { @@ -89,7 +179,10 @@ export class Storage { getProjectDir(): string { const projectId = sanitizeCwd(this.getProjectRoot()); - const projectsDir = path.join(Storage.getGlobalQwenDir(), PROJECT_DIR_NAME); + const projectsDir = path.join( + Storage.getRuntimeBaseDir(), + PROJECT_DIR_NAME, + ); return path.join(projectsDir, projectId); } @@ -114,7 +207,7 @@ export class Storage { getHistoryDir(): string { const hash = getProjectHash(this.getProjectRoot()); - const historyDir = path.join(Storage.getGlobalQwenDir(), 'history'); + const historyDir = path.join(Storage.getRuntimeBaseDir(), 'history'); const targetDir = path.join(historyDir, hash); return targetDir; } diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 4af6d9f9b..655449068 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -8,14 +8,10 @@ import type { ToolEditConfirmationDetails, ToolResult, ToolCallConfirmationDetails, - - ToolConfirmationOutcome} from './tools.js'; -import type { PermissionDecision } from '../permissions/types.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind + ToolConfirmationOutcome, } from './tools.js'; +import type { PermissionDecision } from '../permissions/types.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { FunctionDeclaration } from '@google/genai'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; diff --git a/packages/core/src/tools/todoWrite.test.ts b/packages/core/src/tools/todoWrite.test.ts index cd21a55b7..cb096a60c 100644 --- a/packages/core/src/tools/todoWrite.test.ts +++ b/packages/core/src/tools/todoWrite.test.ts @@ -6,10 +6,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { TodoWriteParams, TodoItem } from './todoWrite.js'; -import { TodoWriteTool } from './todoWrite.js'; +import { TodoWriteTool, listTodoSessions } from './todoWrite.js'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; +import * as path from 'node:path'; import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; // Mock fs modules vi.mock('fs/promises'); @@ -302,3 +304,120 @@ describe('TodoWriteTool', () => { }); }); }); + +describe('TodoWriteTool – runtime output directory', () => { + let tool: TodoWriteTool; + let mockAbortSignal: AbortSignal; + let mockConfig: Config; + const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR']; + + beforeEach(() => { + mockConfig = { + getSessionId: () => 'runtime-session', + } as Config; + tool = new TodoWriteTool(mockConfig); + mockAbortSignal = new AbortController().signal; + Storage.setRuntimeBaseDir(null); + delete process.env['QWEN_RUNTIME_DIR']; + vi.clearAllMocks(); + }); + + afterEach(() => { + Storage.setRuntimeBaseDir(null); + if (originalRuntimeEnv !== undefined) { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv; + } else { + delete process.env['QWEN_RUNTIME_DIR']; + } + vi.restoreAllMocks(); + }); + + it('should write todos to custom runtime dir when setRuntimeBaseDir is set', async () => { + const customRuntimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(customRuntimeDir); + + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const invocation = tool.build(params); + await invocation.execute(mockAbortSignal); + + // Verify the file path starts with the custom runtime dir + const writePath = mockFs.writeFile.mock.calls[0]?.[0] as string; + expect(writePath).toContain(path.join(customRuntimeDir, 'todos')); + expect(writePath).toContain('runtime-session.json'); + }); + + it('should write todos to env var dir when QWEN_RUNTIME_DIR is set', async () => { + const envRuntimeDir = path.resolve('env', 'runtime'); + process.env['QWEN_RUNTIME_DIR'] = envRuntimeDir; + + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const invocation = tool.build(params); + await invocation.execute(mockAbortSignal); + + const writePath = mockFs.writeFile.mock.calls[0]?.[0] as string; + expect(writePath).toContain(path.join(envRuntimeDir, 'todos')); + }); + + it('should use default ~/.qwen path when no custom dir is configured', async () => { + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task 1', status: 'pending' }], + }; + + mockFs.readFile.mockRejectedValue({ code: 'ENOENT' }); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const invocation = tool.build(params); + await invocation.execute(mockAbortSignal); + + const writePath = mockFs.writeFile.mock.calls[0]?.[0] as string; + expect(writePath).toContain(path.join('.qwen', 'todos')); + }); + + it('should check file existence in custom runtime dir for getDescription', () => { + const customRuntimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(customRuntimeDir); + mockFsSync.existsSync.mockReturnValue(false); + + const params: TodoWriteParams = { + todos: [{ id: '1', content: 'Task', status: 'pending' }], + }; + const invocation = tool.build(params); + + // Verify existsSync was called with a path under the custom dir + const checkedPath = mockFsSync.existsSync.mock.calls[0]?.[0] as string; + expect(checkedPath).toContain(path.join(customRuntimeDir, 'todos')); + expect(invocation.getDescription()).toBe('Create todos'); + }); + + it('should list todo sessions from custom runtime dir', async () => { + const customRuntimeDir = path.resolve('custom', 'runtime'); + Storage.setRuntimeBaseDir(customRuntimeDir); + mockFs.readdir.mockResolvedValue([ + 'a.json', + 'b.json', + 'README.md', + ] as never); + + const sessions = await listTodoSessions(); + + expect(mockFs.readdir).toHaveBeenCalledWith( + path.join(customRuntimeDir, 'todos'), + ); + expect(sessions).toEqual(['a', 'b']); + }); +}); diff --git a/packages/core/src/tools/todoWrite.ts b/packages/core/src/tools/todoWrite.ts index 2cdbafb51..bd689316b 100644 --- a/packages/core/src/tools/todoWrite.ts +++ b/packages/core/src/tools/todoWrite.ts @@ -10,10 +10,9 @@ import type { FunctionDeclaration } from '@google/genai'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as path from 'path'; -import * as process from 'process'; -import { QWEN_DIR } from '../utils/paths.js'; import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -247,9 +246,7 @@ When in doubt, use this tool. Being proactive with task management demonstrates const TODO_SUBDIR = 'todos'; function getTodoFilePath(sessionId?: string): string { - const homeDir = - process.env['HOME'] || process.env['USERPROFILE'] || process.cwd(); - const todoDir = path.join(homeDir, QWEN_DIR, TODO_SUBDIR); + const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR); // Use sessionId if provided, otherwise fall back to 'default' const filename = `${sessionId || 'default'}.json`; @@ -399,9 +396,7 @@ export async function readTodosForSession( */ export async function listTodoSessions(): Promise { try { - const homeDir = - process.env['HOME'] || process.env['USERPROFILE'] || process.cwd(); - const todoDir = path.join(homeDir, QWEN_DIR, TODO_SUBDIR); + const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR); const files = await fs.readdir(todoDir); return files .filter((file: string) => file.endsWith('.json')) diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 6dc846c43..c0d04aed6 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -15,14 +15,10 @@ import type { ToolInvocation, ToolResult, ToolConfirmationPayload, - - ToolConfirmationOutcome} from './tools.js'; -import type { PermissionDecision } from '../permissions/types.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind + ToolConfirmationOutcome, } from './tools.js'; +import type { PermissionDecision } from '../permissions/types.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; import { ToolNames, ToolDisplayNames } from './tool-names.js'; import { createDebugLogger, type DebugLogger } from '../utils/debugLogger.js'; diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index 038f5d169..142dd880f 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - ToolConfirmationOutcome} from '../tools.js'; +import type { ToolConfirmationOutcome } from '../tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, @@ -13,7 +12,7 @@ import { type ToolInvocation, type ToolCallConfirmationDetails, type ToolInfoConfirmationDetails, - type ToolConfirmationPayload + type ToolConfirmationPayload, } from '../tools.js'; import type { PermissionDecision } from '../../permissions/types.js'; import { ToolErrorType } from '../tool-error.js'; diff --git a/packages/core/src/utils/debugLogger.test.ts b/packages/core/src/utils/debugLogger.test.ts index 8549359c0..e585dfdfa 100644 --- a/packages/core/src/utils/debugLogger.test.ts +++ b/packages/core/src/utils/debugLogger.test.ts @@ -40,6 +40,7 @@ describe('debugLogger', () => { beforeEach(() => { process.env['QWEN_DEBUG_LOG_FILE'] = '1'; + Storage.setRuntimeBaseDir(null); vi.clearAllMocks(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-24T10:30:00.000Z')); @@ -50,6 +51,7 @@ describe('debugLogger', () => { afterEach(() => { vi.useRealTimers(); setDebugLogSession(null); + Storage.setRuntimeBaseDir(null); if (previousDebugLogFileEnv === undefined) { delete process.env['QWEN_DEBUG_LOG_FILE']; } else { @@ -115,6 +117,27 @@ describe('debugLogger', () => { expect(calls[3]?.[1]).toContain('[ERROR]'); }); + it('creates a new debug directory after the runtime base dir changes', async () => { + Storage.setRuntimeBaseDir(path.resolve('runtime-a')); + const logger = createDebugLogger(); + logger.debug('first'); + await vi.runAllTimersAsync(); + + Storage.setRuntimeBaseDir(path.resolve('runtime-b')); + logger.debug('second'); + await vi.runAllTimersAsync(); + + const mkdirCalls = vi.mocked(fs.mkdir).mock.calls; + expect(mkdirCalls).toContainEqual([ + path.join(path.resolve('runtime-a'), 'debug'), + { recursive: true }, + ]); + expect(mkdirCalls).toContainEqual([ + path.join(path.resolve('runtime-b'), 'debug'), + { recursive: true }, + ]); + }); + it('formats multiple arguments', async () => { const logger = createDebugLogger(); logger.debug('Count:', 42, 'items'); diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts index 356028a2f..bc92a2685 100644 --- a/packages/core/src/utils/debugLogger.ts +++ b/packages/core/src/utils/debugLogger.ts @@ -25,6 +25,7 @@ export interface DebugLogger { } let ensureDebugDirPromise: Promise | null = null; +let ensuredDebugDirPath: string | null = null; let hasWriteFailure = false; let globalSession: DebugLogSession | null = null; const sessionContext = new AsyncLocalStorage(); @@ -41,13 +42,16 @@ function getActiveSession(): DebugLogSession | null { } function ensureDebugDirExists(): Promise { - if (!ensureDebugDirPromise) { + const debugDirPath = Storage.getGlobalDebugDir(); + if (!ensureDebugDirPromise || ensuredDebugDirPath !== debugDirPath) { + ensuredDebugDirPath = debugDirPath; ensureDebugDirPromise = fs - .mkdir(Storage.getGlobalDebugDir(), { recursive: true }) + .mkdir(debugDirPath, { recursive: true }) .then(() => undefined) .catch(() => { hasWriteFailure = true; ensureDebugDirPromise = null; + ensuredDebugDirPath = null; }); } return ensureDebugDirPromise ?? Promise.resolve(); @@ -115,6 +119,7 @@ export function isDebugLoggingDegraded(): boolean { export function resetDebugLoggingState(): void { hasWriteFailure = false; ensureDebugDirPromise = null; + ensuredDebugDirPath = null; } const DEBUG_LATEST_ALIAS = 'latest'; diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 61734faaf..8e5725ae0 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -580,6 +580,10 @@ "type": "object", "additionalProperties": true }, + "runtimeOutputDir": { + "description": "Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.", + "type": "string" + }, "tavilyApiKey": { "description": "⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.", "type": "string"