mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(storage): support configurable runtime output directory (#2127)
Some checks failed
Qwen Code CI / Lint (push) Failing after 12s
Qwen Code CI / Test (push) Has been skipped
Qwen Code CI / Test-1 (push) Has been skipped
Qwen Code CI / Test-2 (push) Has been skipped
Qwen Code CI / Test-3 (push) Has been skipped
Qwen Code CI / Test-4 (push) Has been skipped
Qwen Code CI / Test-5 (push) Has been skipped
Qwen Code CI / Test-6 (push) Has been skipped
Qwen Code CI / Test-7 (push) Has been skipped
Qwen Code CI / Test-8 (push) Has been skipped
Qwen Code CI / CodeQL (push) Failing after 6s
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Failing after 5s
Qwen Code CI / Post Coverage Comment (push) Has been skipped
E2E Tests / E2E Test (Linux) - sandbox:none (push) Failing after 10m36s
E2E Tests / E2E Test - macOS (push) Has been cancelled
Some checks failed
Qwen Code CI / Lint (push) Failing after 12s
Qwen Code CI / Test (push) Has been skipped
Qwen Code CI / Test-1 (push) Has been skipped
Qwen Code CI / Test-2 (push) Has been skipped
Qwen Code CI / Test-3 (push) Has been skipped
Qwen Code CI / Test-4 (push) Has been skipped
Qwen Code CI / Test-5 (push) Has been skipped
Qwen Code CI / Test-6 (push) Has been skipped
Qwen Code CI / Test-7 (push) Has been skipped
Qwen Code CI / Test-8 (push) Has been skipped
Qwen Code CI / CodeQL (push) Failing after 6s
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Failing after 5s
Qwen Code CI / Post Coverage Comment (push) Has been skipped
E2E Tests / E2E Test (Linux) - sandbox:none (push) Failing after 10m36s
E2E Tests / E2E Test - macOS (push) Has been cancelled
* feat(storage): support configurable runtime output directory (#2014) Add `advanced.runtimeOutputDir` setting and `QWEN_RUNTIME_DIR` env var to redirect runtime output (temp files, debug logs, session data, todos, insights) to a custom directory while keeping config files at ~/.qwen. - Introduce `Storage.setRuntimeBaseDir()` / `getRuntimeBaseDir()` with tilde expansion and relative path resolution - Add `AsyncLocalStorage`-based `runWithRuntimeBaseDir()` for concurrent session isolation in ACP integration - Update all runtime path methods to use `getRuntimeBaseDir()` instead of `getGlobalQwenDir()` (temp, debug, ide, projects, history dirs) - Config paths (settings, oauth, installation_id, etc.) remain pinned to `~/.qwen` regardless of runtime dir configuration - Add comprehensive tests covering path resolution, env var priority, async context isolation, and config path stability * fix(core/storage): 支持 Windows 风格波浪号路径 扩展 setRuntimeBaseDir 以支持 Windows 风格的波浪号路径 (~\), 使用统一的路径分割逻辑处理 Unix 和 Windows 风格的路径分隔符 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core/debugLogger): runtime base dir 变更时创建新 debug 目录 添加 ensuredDebugDirPath 追踪变量,当 runtime base dir 发生变更时, 确保在新的目录下创建 debug 子目录 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(cli/acp): 支持 ACP runtime output dir 配置 新增 runWithAcpRuntimeOutputDir 辅助函数,在 ACP Agent 的 loadSession 和 listSessions 操作中应用配置的 runtimeOutputDir Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(vscode-ide-companion/acpConnection): 补充 this 别名的使用说明 为 self = this 的用法添加解释性注释,说明在嵌套回调中需要使用 this Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(cli): add runtime output directory configuration support * fix(core): update test to use getUserSkillsDirs method Update storage.test.ts to call getUserSkillsDirs() instead of the non-existent getUserSkillsDir() method. The method was renamed to return an array of skill directories. * fix(core/todoWrite): use path.join for cross-platform path assertion in test Replace hardcoded forward-slash path `.qwen/todos/` with `path.join('.qwen', 'todos')` to fix Windows CI failure where paths use backslashes. Made-with: Cursor --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
87f03cf2e9
commit
fbf5ed57d6
23 changed files with 1088 additions and 171 deletions
|
|
@ -64,6 +64,7 @@ import type { CliArgs } from '../config/config.js';
|
||||||
import { loadCliConfig } from '../config/config.js';
|
import { loadCliConfig } from '../config/config.js';
|
||||||
import { Session } from './session/Session.js';
|
import { Session } from './session/Session.js';
|
||||||
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
||||||
|
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
|
||||||
|
|
||||||
const debugLogger = createDebugLogger('ACP_AGENT');
|
const debugLogger = createDebugLogger('ACP_AGENT');
|
||||||
|
|
||||||
|
|
@ -214,8 +215,14 @@ class QwenAgent implements Agent {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||||
const sessionService = new SessionService(params.cwd);
|
const exists = await runWithAcpRuntimeOutputDir(
|
||||||
const exists = await sessionService.sessionExists(params.sessionId);
|
this.settings,
|
||||||
|
params.cwd,
|
||||||
|
async () => {
|
||||||
|
const sessionService = new SessionService(params.cwd);
|
||||||
|
return sessionService.sessionExists(params.sessionId);
|
||||||
|
},
|
||||||
|
);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
throw RequestError.invalidParams(
|
throw RequestError.invalidParams(
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -256,10 +263,12 @@ class QwenAgent implements Agent {
|
||||||
params: ListSessionsRequest,
|
params: ListSessionsRequest,
|
||||||
): Promise<ListSessionsResponse> {
|
): Promise<ListSessionsResponse> {
|
||||||
const cwd = params.cwd || process.cwd();
|
const cwd = params.cwd || process.cwd();
|
||||||
const sessionService = new SessionService(cwd);
|
|
||||||
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
|
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
|
||||||
const result = await sessionService.listSessions({
|
const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => {
|
||||||
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
|
const sessionService = new SessionService(cwd);
|
||||||
|
return sessionService.listSessions({
|
||||||
|
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessions: SessionInfo[] = result.items.map((item) => ({
|
const sessions: SessionInfo[] = result.items.map((item) => ({
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
14
packages/cli/src/acp-integration/runtimeOutputDirContext.ts
Normal file
14
packages/cli/src/acp-integration/runtimeOutputDirContext.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Storage } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
|
|
||||||
|
export function runWithAcpRuntimeOutputDir<T>(
|
||||||
|
settings: LoadedSettings,
|
||||||
|
cwd: string,
|
||||||
|
fn: () => T,
|
||||||
|
): T {
|
||||||
|
return Storage.runWithRuntimeBaseDir(
|
||||||
|
settings.merged.advanced?.runtimeOutputDir,
|
||||||
|
cwd,
|
||||||
|
fn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,7 @@ describe('Session', () => {
|
||||||
switchModel: switchModelSpy,
|
switchModel: switchModelSpy,
|
||||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||||
|
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
|
||||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
|
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
|
||||||
|
|
@ -241,5 +242,38 @@ describe('Session', () => {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
TodoWriteTool,
|
TodoWriteTool,
|
||||||
ExitPlanModeTool,
|
ExitPlanModeTool,
|
||||||
readManyFiles,
|
readManyFiles,
|
||||||
|
Storage,
|
||||||
ToolNames,
|
ToolNames,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
|
|
@ -100,6 +101,7 @@ export class Session implements SessionContext {
|
||||||
*/
|
*/
|
||||||
private pendingPromptCompletion: Promise<void> | null = null;
|
private pendingPromptCompletion: Promise<void> | null = null;
|
||||||
private turn: number = 0;
|
private turn: number = 0;
|
||||||
|
private readonly runtimeBaseDir: string;
|
||||||
|
|
||||||
// Modular components
|
// Modular components
|
||||||
private readonly historyReplayer: HistoryReplayer;
|
private readonly historyReplayer: HistoryReplayer;
|
||||||
|
|
@ -118,6 +120,7 @@ export class Session implements SessionContext {
|
||||||
private readonly settings: LoadedSettings,
|
private readonly settings: LoadedSettings,
|
||||||
) {
|
) {
|
||||||
this.sessionId = id;
|
this.sessionId = id;
|
||||||
|
this.runtimeBaseDir = Storage.getRuntimeBaseDir();
|
||||||
|
|
||||||
// Initialize modular components with this session as context
|
// Initialize modular components with this session as context
|
||||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||||
|
|
@ -189,150 +192,170 @@ export class Session implements SessionContext {
|
||||||
params: PromptRequest,
|
params: PromptRequest,
|
||||||
pendingSend: AbortController,
|
pendingSend: AbortController,
|
||||||
): Promise<PromptResponse> {
|
): Promise<PromptResponse> {
|
||||||
// Increment turn counter for each user prompt
|
return Storage.runWithRuntimeBaseDir(
|
||||||
this.turn += 1;
|
this.runtimeBaseDir,
|
||||||
|
this.config.getWorkingDir(),
|
||||||
|
async () => {
|
||||||
|
// Increment turn counter for each user prompt
|
||||||
|
this.turn += 1;
|
||||||
|
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
const promptId = this.config.getSessionId() + '########' + this.turn;
|
const promptId = this.config.getSessionId() + '########' + this.turn;
|
||||||
|
|
||||||
// Extract text from all text blocks to construct the full prompt text for logging
|
// Extract text from all text blocks to construct the full prompt text for logging
|
||||||
const promptText = params.prompt
|
const promptText = params.prompt
|
||||||
.filter((block) => block.type === 'text')
|
.filter((block) => block.type === 'text')
|
||||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
// Log user prompt
|
// Log user prompt
|
||||||
logUserPrompt(
|
logUserPrompt(
|
||||||
this.config,
|
this.config,
|
||||||
new UserPromptEvent(
|
new UserPromptEvent(
|
||||||
promptText.length,
|
promptText.length,
|
||||||
promptId,
|
promptId,
|
||||||
this.config.getContentGeneratorConfig()?.authType,
|
this.config.getContentGeneratorConfig()?.authType,
|
||||||
promptText,
|
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,
|
|
||||||
);
|
);
|
||||||
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) {
|
if (pendingSend.signal.aborted) {
|
||||||
|
chat.addHistory(nextMessage);
|
||||||
return { stopReason: 'cancelled' };
|
return { stopReason: 'cancelled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const functionCalls: FunctionCall[] = [];
|
||||||
resp.type === StreamEventType.CHUNK &&
|
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||||
resp.value.candidates &&
|
const streamStartTime = Date.now();
|
||||||
resp.value.candidates.length > 0
|
|
||||||
) {
|
try {
|
||||||
const candidate = resp.value.candidates[0];
|
const responseStream = await chat.sendMessageStream(
|
||||||
for (const part of candidate.content?.parts ?? []) {
|
this.config.getModel(),
|
||||||
if (!part.text) {
|
{
|
||||||
continue;
|
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(
|
if (
|
||||||
part.text,
|
resp.type === StreamEventType.CHUNK &&
|
||||||
'assistant',
|
resp.value.candidates &&
|
||||||
part.thought,
|
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) {
|
if (usageMetadata) {
|
||||||
usageMetadata = resp.value.usageMetadata;
|
const durationMs = Date.now() - streamStartTime;
|
||||||
|
await this.messageEmitter.emitUsageMetadata(
|
||||||
|
usageMetadata,
|
||||||
|
'',
|
||||||
|
durationMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
if (functionCalls.length > 0) {
|
||||||
functionCalls.push(...resp.value.functionCalls);
|
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) {
|
return { stopReason: 'end_turn' };
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendUpdate(update: SessionUpdate): Promise<void> {
|
async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DEFAULT_QWEN_MODEL,
|
DEFAULT_QWEN_MODEL,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
NativeLspService,
|
NativeLspService,
|
||||||
|
Storage,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
|
|
@ -2439,3 +2440,79 @@ describe('Telemetry configuration via environment variables', () => {
|
||||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -708,6 +708,11 @@ export async function loadCliConfig(
|
||||||
): Promise<Config> {
|
): Promise<Config> {
|
||||||
const debugMode = isDebugMode(argv);
|
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 ideMode = settings.ide?.enabled ?? false;
|
||||||
|
|
||||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||||
|
|
|
||||||
|
|
@ -1263,6 +1263,17 @@ const SETTINGS_SCHEMA = {
|
||||||
description: 'Configuration for the bug report command.',
|
description: 'Configuration for the bug report command.',
|
||||||
showInDialog: false,
|
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: {
|
tavilyApiKey: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
label: 'Tavily API Key (Deprecated)',
|
label: 'Tavily API Key (Deprecated)',
|
||||||
|
|
|
||||||
|
|
@ -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 === '') {
|
if (argv.resume === '') {
|
||||||
|
Storage.setRuntimeBaseDir(
|
||||||
|
settings.merged.advanced?.runtimeOutputDir,
|
||||||
|
process.cwd(),
|
||||||
|
);
|
||||||
const selectedSessionId = await showResumeSessionPicker();
|
const selectedSessionId = await showResumeSessionPicker();
|
||||||
if (!selectedSessionId) {
|
if (!selectedSessionId) {
|
||||||
// User cancelled or no sessions available
|
// User cancelled or no sessions available
|
||||||
|
|
|
||||||
|
|
@ -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('<html>ok</html>');
|
||||||
|
|
||||||
|
(
|
||||||
|
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,
|
||||||
|
'<html>ok</html>',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
expect(outputPath).toBe(expectedOutputPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
|
||||||
import { DataProcessor } from './DataProcessor.js';
|
import { DataProcessor } from './DataProcessor.js';
|
||||||
import { TemplateRenderer } from './TemplateRenderer.js';
|
import { TemplateRenderer } from './TemplateRenderer.js';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -14,7 +13,7 @@ import type {
|
||||||
InsightProgressCallback,
|
InsightProgressCallback,
|
||||||
} from '../types/StaticInsightTypes.js';
|
} 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 {
|
export class StaticInsightGenerator {
|
||||||
private dataProcessor: DataProcessor;
|
private dataProcessor: DataProcessor;
|
||||||
|
|
@ -27,7 +26,7 @@ export class StaticInsightGenerator {
|
||||||
|
|
||||||
// Ensure the output directory exists
|
// Ensure the output directory exists
|
||||||
private async ensureOutputDirectory(): Promise<string> {
|
private async ensureOutputDirectory(): Promise<string> {
|
||||||
const outputDir = path.join(os.homedir(), '.qwen', 'insights');
|
const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights');
|
||||||
await fs.mkdir(outputDir, { recursive: true });
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
return outputDir;
|
return outputDir;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
packages/cli/src/ui/commands/insightCommand.test.ts
Normal file
66
packages/cli/src/ui/commands/insightCommand.test.ts
Normal file
|
|
@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,9 +10,8 @@ import { MessageType } from '../types.js';
|
||||||
import type { HistoryItemInsightProgress } from '../types.js';
|
import type { HistoryItemInsightProgress } from '../types.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import os from 'os';
|
|
||||||
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
|
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';
|
import open from 'open';
|
||||||
|
|
||||||
const logger = createDebugLogger('DataProcessor');
|
const logger = createDebugLogger('DataProcessor');
|
||||||
|
|
@ -29,7 +28,7 @@ export const insightCommand: SlashCommand = {
|
||||||
try {
|
try {
|
||||||
context.ui.setDebugMessage(t('Generating insights...'));
|
context.ui.setDebugMessage(t('Generating insights...'));
|
||||||
|
|
||||||
const projectsDir = join(os.homedir(), '.qwen', 'projects');
|
const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects');
|
||||||
if (!context.services.config) {
|
if (!context.services.config) {
|
||||||
throw new Error('Config service is not available');
|
throw new Error('Config service is not available');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { Storage } from './storage.js';
|
import { Storage } from './storage.js';
|
||||||
|
|
@ -40,3 +40,321 @@ describe('Storage – additional helpers', () => {
|
||||||
expect(Storage.getMcpOAuthTokensPath()).toBe(expected);
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||||
import { getProjectHash, sanitizeCwd } from '../utils/paths.js';
|
import { getProjectHash, sanitizeCwd } from '../utils/paths.js';
|
||||||
|
|
||||||
export const QWEN_DIR = '.qwen';
|
export const QWEN_DIR = '.qwen';
|
||||||
|
|
@ -23,10 +24,99 @@ const ARENA_DIR_NAME = 'arena';
|
||||||
export class Storage {
|
export class Storage {
|
||||||
private readonly targetDir: string;
|
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) {
|
constructor(targetDir: string) {
|
||||||
this.targetDir = targetDir;
|
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<T>(
|
||||||
|
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 {
|
static getGlobalQwenDir(): string {
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
if (!homeDir) {
|
if (!homeDir) {
|
||||||
|
|
@ -60,11 +150,11 @@ export class Storage {
|
||||||
}
|
}
|
||||||
|
|
||||||
static getGlobalTempDir(): string {
|
static getGlobalTempDir(): string {
|
||||||
return path.join(Storage.getGlobalQwenDir(), TMP_DIR_NAME);
|
return path.join(Storage.getRuntimeBaseDir(), TMP_DIR_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getGlobalDebugDir(): string {
|
static getGlobalDebugDir(): string {
|
||||||
return path.join(Storage.getGlobalQwenDir(), DEBUG_DIR_NAME);
|
return path.join(Storage.getRuntimeBaseDir(), DEBUG_DIR_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDebugLogPath(sessionId: string): string {
|
static getDebugLogPath(sessionId: string): string {
|
||||||
|
|
@ -72,7 +162,7 @@ export class Storage {
|
||||||
}
|
}
|
||||||
|
|
||||||
static getGlobalIdeDir(): string {
|
static getGlobalIdeDir(): string {
|
||||||
return path.join(Storage.getGlobalQwenDir(), IDE_DIR_NAME);
|
return path.join(Storage.getRuntimeBaseDir(), IDE_DIR_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getGlobalBinDir(): string {
|
static getGlobalBinDir(): string {
|
||||||
|
|
@ -89,7 +179,10 @@ export class Storage {
|
||||||
|
|
||||||
getProjectDir(): string {
|
getProjectDir(): string {
|
||||||
const projectId = sanitizeCwd(this.getProjectRoot());
|
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);
|
return path.join(projectsDir, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +207,7 @@ export class Storage {
|
||||||
|
|
||||||
getHistoryDir(): string {
|
getHistoryDir(): string {
|
||||||
const hash = getProjectHash(this.getProjectRoot());
|
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);
|
const targetDir = path.join(historyDir, hash);
|
||||||
return targetDir;
|
return targetDir;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,10 @@ import type {
|
||||||
ToolEditConfirmationDetails,
|
ToolEditConfirmationDetails,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
|
ToolConfirmationOutcome,
|
||||||
ToolConfirmationOutcome} from './tools.js';
|
|
||||||
import type { PermissionDecision } from '../permissions/types.js';
|
|
||||||
import {
|
|
||||||
BaseDeclarativeTool,
|
|
||||||
BaseToolInvocation,
|
|
||||||
Kind
|
|
||||||
} from './tools.js';
|
} from './tools.js';
|
||||||
|
import type { PermissionDecision } from '../permissions/types.js';
|
||||||
|
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||||
import type { FunctionDeclaration } from '@google/genai';
|
import type { FunctionDeclaration } from '@google/genai';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import type { TodoWriteParams, TodoItem } from './todoWrite.js';
|
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 fs from 'fs/promises';
|
||||||
import * as fsSync from 'fs';
|
import * as fsSync from 'fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
|
import { Storage } from '../config/storage.js';
|
||||||
|
|
||||||
// Mock fs modules
|
// Mock fs modules
|
||||||
vi.mock('fs/promises');
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,9 @@ import type { FunctionDeclaration } from '@google/genai';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as fsSync from 'fs';
|
import * as fsSync from 'fs';
|
||||||
import * as path from 'path';
|
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 type { Config } from '../config/config.js';
|
||||||
|
import { Storage } from '../config/storage.js';
|
||||||
import { ToolDisplayNames, ToolNames } from './tool-names.js';
|
import { ToolDisplayNames, ToolNames } from './tool-names.js';
|
||||||
import { createDebugLogger } from '../utils/debugLogger.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';
|
const TODO_SUBDIR = 'todos';
|
||||||
|
|
||||||
function getTodoFilePath(sessionId?: string): string {
|
function getTodoFilePath(sessionId?: string): string {
|
||||||
const homeDir =
|
const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR);
|
||||||
process.env['HOME'] || process.env['USERPROFILE'] || process.cwd();
|
|
||||||
const todoDir = path.join(homeDir, QWEN_DIR, TODO_SUBDIR);
|
|
||||||
|
|
||||||
// Use sessionId if provided, otherwise fall back to 'default'
|
// Use sessionId if provided, otherwise fall back to 'default'
|
||||||
const filename = `${sessionId || 'default'}.json`;
|
const filename = `${sessionId || 'default'}.json`;
|
||||||
|
|
@ -399,9 +396,7 @@ export async function readTodosForSession(
|
||||||
*/
|
*/
|
||||||
export async function listTodoSessions(): Promise<string[]> {
|
export async function listTodoSessions(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const homeDir =
|
const todoDir = path.join(Storage.getRuntimeBaseDir(), TODO_SUBDIR);
|
||||||
process.env['HOME'] || process.env['USERPROFILE'] || process.cwd();
|
|
||||||
const todoDir = path.join(homeDir, QWEN_DIR, TODO_SUBDIR);
|
|
||||||
const files = await fs.readdir(todoDir);
|
const files = await fs.readdir(todoDir);
|
||||||
return files
|
return files
|
||||||
.filter((file: string) => file.endsWith('.json'))
|
.filter((file: string) => file.endsWith('.json'))
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,10 @@ import type {
|
||||||
ToolInvocation,
|
ToolInvocation,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolConfirmationPayload,
|
ToolConfirmationPayload,
|
||||||
|
ToolConfirmationOutcome,
|
||||||
ToolConfirmationOutcome} from './tools.js';
|
|
||||||
import type { PermissionDecision } from '../permissions/types.js';
|
|
||||||
import {
|
|
||||||
BaseDeclarativeTool,
|
|
||||||
BaseToolInvocation,
|
|
||||||
Kind
|
|
||||||
} from './tools.js';
|
} 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 { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||||
import { createDebugLogger, type DebugLogger } from '../utils/debugLogger.js';
|
import { createDebugLogger, type DebugLogger } from '../utils/debugLogger.js';
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type { ToolConfirmationOutcome } from '../tools.js';
|
||||||
ToolConfirmationOutcome} from '../tools.js';
|
|
||||||
import {
|
import {
|
||||||
BaseDeclarativeTool,
|
BaseDeclarativeTool,
|
||||||
BaseToolInvocation,
|
BaseToolInvocation,
|
||||||
|
|
@ -13,7 +12,7 @@ import {
|
||||||
type ToolInvocation,
|
type ToolInvocation,
|
||||||
type ToolCallConfirmationDetails,
|
type ToolCallConfirmationDetails,
|
||||||
type ToolInfoConfirmationDetails,
|
type ToolInfoConfirmationDetails,
|
||||||
type ToolConfirmationPayload
|
type ToolConfirmationPayload,
|
||||||
} from '../tools.js';
|
} from '../tools.js';
|
||||||
import type { PermissionDecision } from '../../permissions/types.js';
|
import type { PermissionDecision } from '../../permissions/types.js';
|
||||||
import { ToolErrorType } from '../tool-error.js';
|
import { ToolErrorType } from '../tool-error.js';
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ describe('debugLogger', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env['QWEN_DEBUG_LOG_FILE'] = '1';
|
process.env['QWEN_DEBUG_LOG_FILE'] = '1';
|
||||||
|
Storage.setRuntimeBaseDir(null);
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-01-24T10:30:00.000Z'));
|
vi.setSystemTime(new Date('2026-01-24T10:30:00.000Z'));
|
||||||
|
|
@ -50,6 +51,7 @@ describe('debugLogger', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
setDebugLogSession(null);
|
setDebugLogSession(null);
|
||||||
|
Storage.setRuntimeBaseDir(null);
|
||||||
if (previousDebugLogFileEnv === undefined) {
|
if (previousDebugLogFileEnv === undefined) {
|
||||||
delete process.env['QWEN_DEBUG_LOG_FILE'];
|
delete process.env['QWEN_DEBUG_LOG_FILE'];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -115,6 +117,27 @@ describe('debugLogger', () => {
|
||||||
expect(calls[3]?.[1]).toContain('[ERROR]');
|
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 () => {
|
it('formats multiple arguments', async () => {
|
||||||
const logger = createDebugLogger();
|
const logger = createDebugLogger();
|
||||||
logger.debug('Count:', 42, 'items');
|
logger.debug('Count:', 42, 'items');
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export interface DebugLogger {
|
||||||
}
|
}
|
||||||
|
|
||||||
let ensureDebugDirPromise: Promise<void> | null = null;
|
let ensureDebugDirPromise: Promise<void> | null = null;
|
||||||
|
let ensuredDebugDirPath: string | null = null;
|
||||||
let hasWriteFailure = false;
|
let hasWriteFailure = false;
|
||||||
let globalSession: DebugLogSession | null = null;
|
let globalSession: DebugLogSession | null = null;
|
||||||
const sessionContext = new AsyncLocalStorage<DebugLogSession>();
|
const sessionContext = new AsyncLocalStorage<DebugLogSession>();
|
||||||
|
|
@ -41,13 +42,16 @@ function getActiveSession(): DebugLogSession | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDebugDirExists(): Promise<void> {
|
function ensureDebugDirExists(): Promise<void> {
|
||||||
if (!ensureDebugDirPromise) {
|
const debugDirPath = Storage.getGlobalDebugDir();
|
||||||
|
if (!ensureDebugDirPromise || ensuredDebugDirPath !== debugDirPath) {
|
||||||
|
ensuredDebugDirPath = debugDirPath;
|
||||||
ensureDebugDirPromise = fs
|
ensureDebugDirPromise = fs
|
||||||
.mkdir(Storage.getGlobalDebugDir(), { recursive: true })
|
.mkdir(debugDirPath, { recursive: true })
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
hasWriteFailure = true;
|
hasWriteFailure = true;
|
||||||
ensureDebugDirPromise = null;
|
ensureDebugDirPromise = null;
|
||||||
|
ensuredDebugDirPath = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return ensureDebugDirPromise ?? Promise.resolve();
|
return ensureDebugDirPromise ?? Promise.resolve();
|
||||||
|
|
@ -115,6 +119,7 @@ export function isDebugLoggingDegraded(): boolean {
|
||||||
export function resetDebugLoggingState(): void {
|
export function resetDebugLoggingState(): void {
|
||||||
hasWriteFailure = false;
|
hasWriteFailure = false;
|
||||||
ensureDebugDirPromise = null;
|
ensureDebugDirPromise = null;
|
||||||
|
ensuredDebugDirPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEBUG_LATEST_ALIAS = 'latest';
|
const DEBUG_LATEST_ALIAS = 'latest';
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,10 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": true
|
"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": {
|
"tavilyApiKey": {
|
||||||
"description": "⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.",
|
"description": "⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue