Merge remote-tracking branch 'origin/main' into refactor/task-to-agent-tool

This commit is contained in:
tanzhenxin 2026-03-20 16:32:37 +08:00
commit 3a686d5ad1
49 changed files with 3624 additions and 315 deletions

View file

@ -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');
@ -87,7 +88,33 @@ export async function runAcpAgent(
stream,
);
// Handle SIGTERM/SIGINT for graceful shutdown.
// Without this, signal handlers registered elsewhere in the CLI
// (e.g., stdin raw mode restoration) override the default exit behavior,
// causing the ACP process to ignore termination signals.
let shuttingDown = false;
const shutdownHandler = () => {
if (shuttingDown) return;
shuttingDown = true;
debugLogger.debug('[ACP] Shutdown signal received, closing streams');
try {
process.stdin.destroy();
} catch {
// stdin may already be closed
}
try {
process.stdout.destroy();
} catch {
// stdout may already be closed
}
};
process.on('SIGTERM', shutdownHandler);
process.on('SIGINT', shutdownHandler);
await connection.closed;
process.off('SIGTERM', shutdownHandler);
process.off('SIGINT', shutdownHandler);
}
function toStdioServer(server: McpServer): McpServerStdio | undefined {
@ -188,8 +215,14 @@ class QwenAgent implements Agent {
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
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,
@ -230,10 +263,12 @@ class QwenAgent implements Agent {
params: ListSessionsRequest,
): Promise<ListSessionsResponse> {
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) => ({

View file

@ -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());
});
});

View 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,
);
}

View file

@ -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),
);
});
});
});

View file

@ -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<void> | 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<PromptResponse> {
// 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<void> {
@ -867,13 +890,12 @@ export class Session implements SessionContext {
}
case 'no_command':
// No command was found or executed, use original prompt
return originalPrompt.map((block) => {
if (block.type === 'text') {
return { text: block.text };
}
throw new Error(`Unsupported block type: ${block.type}`);
});
// No command was found or executed, resolve the original prompt
// through the standard path that handles all block types
return this.#resolvePrompt(
originalPrompt,
new AbortController().signal,
);
default: {
// Exhaustiveness check

View file

@ -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());
});
});

View file

@ -708,6 +708,11 @@ export async function loadCliConfig(
): Promise<Config> {
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;

View file

@ -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)',

View file

@ -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

View file

@ -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);
});
});

View file

@ -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<string> {
const outputDir = path.join(os.homedir(), '.qwen', 'insights');
const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights');
await fs.mkdir(outputDir, { recursive: true });
return outputDir;
}

View 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),
);
});
});

View file

@ -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');
}