chore(vscode-ide-companion): wip

This commit is contained in:
yiliang114
2025-12-13 21:37:10 +08:00
parent c8d18591b0
commit ccc6192164
4 changed files with 55 additions and 708 deletions

View File

@@ -1,498 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { exec } from 'child_process';
import { promisify } from 'util';
import semver from 'semver';
import { CliInstaller } from './cliInstaller.js';
const execAsync = promisify(exec);
export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.5.0';
export interface CliDetectionResult {
isInstalled: boolean;
cliPath?: string;
version?: string;
error?: string;
}
export interface CliFeatureFlags {
supportsSessionList: boolean;
supportsSessionLoad: boolean;
}
export interface CliVersionInfo {
version: string | undefined;
isSupported: boolean;
features: CliFeatureFlags;
detectionResult: CliDetectionResult;
}
export class CliManager {
private static instance: CliManager;
private lastNotificationTime: number = 0;
private static readonly NOTIFICATION_COOLDOWN_MS = 300000; // 5 minutes cooldown
private context: vscode.ExtensionContext | undefined;
// Cache mechanisms
private static cachedDetectionResult: CliDetectionResult | null = null;
private static detectionLastCheckTime: number = 0;
private cachedVersionInfo: CliVersionInfo | null = null;
private versionLastCheckTime: number = 0;
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
private constructor(context?: vscode.ExtensionContext) {
this.context = context;
}
/**
* Get singleton instance
*/
static getInstance(context?: vscode.ExtensionContext): CliManager {
if (!CliManager.instance && context) {
CliManager.instance = new CliManager(context);
}
return CliManager.instance;
}
/**
* Checks if the Qwen Code CLI is installed
* @param forceRefresh - Force a new check, ignoring cache
* @returns Detection result with installation status and details
*/
static async detectQwenCli(
forceRefresh = false,
): Promise<CliDetectionResult> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedDetectionResult &&
now - this.detectionLastCheckTime < this.CACHE_DURATION_MS
) {
console.log('[CliManager] Returning cached detection result');
return this.cachedDetectionResult;
}
console.log(
'[CliManager] Starting CLI detection, current PATH:',
process.env.PATH,
);
try {
const isWindows = process.platform === 'win32';
const whichCommand = isWindows ? 'where' : 'which';
// Check if qwen command exists
try {
// Use NVM environment for consistent detection
// Fallback chain: default alias -> node alias -> current version
const detectionCommand =
process.platform === 'win32'
? `${whichCommand} qwen`
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); which qwen';
console.log(
'[CliManager] Detecting CLI with command:',
detectionCommand,
);
const { stdout } = await execAsync(detectionCommand, {
timeout: 5000,
shell: isWindows ? undefined : '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual path
const lines = stdout
.trim()
.split('\n')
.filter((line) => line.trim());
const cliPath = lines[lines.length - 1];
console.log('[CliManager] Found CLI at:', cliPath);
// Try to get version
let version: string | undefined;
try {
// Use NVM environment for version check
// Fallback chain: default alias -> node alias -> current version
// Also ensure we use the correct Node.js version that matches the CLI installation
const versionCommand =
process.platform === 'win32'
? 'qwen --version'
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); qwen --version';
console.log(
'[CliManager] Getting version with command:',
versionCommand,
);
const { stdout: versionOutput } = await execAsync(versionCommand, {
timeout: 5000,
shell: isWindows ? undefined : '/bin/bash',
});
// The output may contain multiple lines, with NVM activation messages
// We want the last line which should be the actual version
const versionLines = versionOutput
.trim()
.split('\n')
.filter((line) => line.trim());
version = versionLines[versionLines.length - 1];
console.log('[CliManager] CLI version:', version);
} catch (versionError) {
console.log('[CliManager] Failed to get CLI version:', versionError);
// Version check failed, but CLI is installed
}
this.cachedDetectionResult = {
isInstalled: true,
cliPath,
version,
};
this.detectionLastCheckTime = now;
return this.cachedDetectionResult;
} catch (detectionError) {
console.log('[CliManager] CLI not found, error:', detectionError);
// CLI not found
let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`;
// Provide specific guidance for permission errors
if (detectionError instanceof Error) {
const errorMessage = detectionError.message;
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
error += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
}
this.cachedDetectionResult = {
isInstalled: false,
error,
};
this.detectionLastCheckTime = now;
return this.cachedDetectionResult;
}
} catch (error) {
console.log('[CliManager] General detection error:', error);
const errorMessage =
error instanceof Error ? error.message : String(error);
let userFriendlyError = `Failed to detect Qwen Code CLI: ${errorMessage}`;
// Provide specific guidance for permission errors
if (
errorMessage.includes('EACCES') ||
errorMessage.includes('Permission denied')
) {
userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions:
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
\n3. Use nvm for Node.js version management to avoid permission issues
\n4. Check your PATH environment variable includes npm's global bin directory`;
}
this.cachedDetectionResult = {
isInstalled: false,
error: userFriendlyError,
};
this.detectionLastCheckTime = now;
return this.cachedDetectionResult;
}
}
/**
* Clears the cached detection result
*/
static clearCache(): void {
this.cachedDetectionResult = null;
this.detectionLastCheckTime = 0;
}
/**
* Gets installation instructions based on the platform
*/
static getInstallationInstructions(): {
title: string;
steps: string[];
documentationUrl: string;
} {
return {
title: 'Qwen Code CLI is not installed',
steps: [
'Install via npm:',
' npm install -g @qwen-code/qwen-code@latest',
'',
'If you are using nvm (automatically handled by the plugin):',
' The plugin will automatically use your default nvm version',
'',
'Or install from source:',
' git clone https://github.com/QwenLM/qwen-code.git',
' cd qwen-code',
' npm install',
' npm install -g .',
'',
'After installation, reload VS Code or restart the extension.',
],
documentationUrl: 'https://github.com/QwenLM/qwen-code#installation',
};
}
/**
* Check if CLI version meets minimum requirements
*
* @param version - Version string to check
* @param minVersion - Minimum required version
* @returns Whether version meets requirements
*/
private isVersionSupported(
version: string | undefined,
minVersion: string,
): boolean {
if (!version) {
return false;
}
// Use semver for robust comparison (handles v-prefix, pre-release, etc.)
const v = semver.valid(version) ?? semver.coerce(version)?.version ?? null;
const min =
semver.valid(minVersion) ?? semver.coerce(minVersion)?.version ?? null;
if (!v || !min) {
console.warn(
`[CliManager] Invalid semver: version=${version}, min=${minVersion}`,
);
return false;
}
console.log(`[CliManager] Version ${v} meets requirements: ${min}`);
return semver.gte(v, min);
}
/**
* Get feature flags based on CLI version
*
* @param version - CLI version string
* @returns Feature flags
*/
private getFeatureFlags(version: string | undefined): CliFeatureFlags {
const isSupportedVersion = this.isVersionSupported(
version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
);
return {
supportsSessionList: isSupportedVersion,
supportsSessionLoad: isSupportedVersion,
};
}
/**
* Detect CLI version and features
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns CLI version information
*/
async detectCliVersion(forceRefresh = false): Promise<CliVersionInfo> {
const now = Date.now();
// Return cached result if available and not expired
if (
!forceRefresh &&
this.cachedVersionInfo &&
now - this.versionLastCheckTime < CliManager.CACHE_DURATION_MS
) {
console.log('[CliManager] Returning cached version info');
return this.cachedVersionInfo;
}
console.log('[CliManager] Detecting CLI version...');
try {
// Detect CLI installation
const detectionResult = await CliManager.detectQwenCli(forceRefresh);
const versionInfo: CliVersionInfo = {
version: detectionResult.version,
isSupported: this.isVersionSupported(
detectionResult.version,
MIN_CLI_VERSION_FOR_SESSION_METHODS,
),
features: this.getFeatureFlags(detectionResult.version),
detectionResult,
};
// Cache the result
this.cachedVersionInfo = versionInfo;
this.versionLastCheckTime = now;
console.log('[CliManager] CLI version detection result:', versionInfo);
return versionInfo;
} catch (error) {
console.error('[CliManager] Failed to detect CLI version:', error);
// Return fallback result
const fallbackResult: CliVersionInfo = {
version: undefined,
isSupported: false,
features: {
supportsSessionList: false,
supportsSessionLoad: false,
},
detectionResult: {
isInstalled: false,
error: error instanceof Error ? error.message : String(error),
},
};
return fallbackResult;
}
}
/**
* Clear cached version information
*/
clearVersionCache(): void {
this.cachedVersionInfo = null;
this.versionLastCheckTime = 0;
CliManager.clearCache();
}
/**
* Check if CLI supports session/list method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/list is supported
*/
async supportsSessionList(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionList;
}
/**
* Check if CLI supports session/load method
*
* @param forceRefresh - Force a new check, ignoring cache
* @returns Whether session/load is supported
*/
async supportsSessionLoad(forceRefresh = false): Promise<boolean> {
const versionInfo = await this.detectCliVersion(forceRefresh);
return versionInfo.features.supportsSessionLoad;
}
/**
* Check CLI version with cooldown to prevent spamming notifications
*
* @param showNotifications - Whether to show notifications for issues
* @returns Promise with version check result
*/
async checkCliVersion(showNotifications: boolean = true): Promise<{
isInstalled: boolean;
version?: string;
isSupported: boolean;
needsUpdate: boolean;
error?: string;
}> {
try {
// Detect CLI installation
const detectionResult: CliDetectionResult =
await CliManager.detectQwenCli();
if (!detectionResult.isInstalled) {
if (showNotifications && this.canShowNotification()) {
vscode.window.showWarningMessage(
`Qwen Code CLI not found. Please install it using: npm install -g @qwen-code/qwen-code@latest`,
);
this.lastNotificationTime = Date.now();
}
return {
isInstalled: false,
error: detectionResult.error,
isSupported: false,
needsUpdate: false,
};
}
// Get version information
const versionInfo = await this.detectCliVersion();
const currentVersion = detectionResult.version;
const isSupported = versionInfo.isSupported;
// Check if update is needed (version is too old)
const needsUpdate = currentVersion
? !semver.satisfies(
currentVersion,
`>=${MIN_CLI_VERSION_FOR_SESSION_METHODS}`,
)
: false;
// Show notification only if needed and within cooldown period
if (showNotifications && !isSupported && this.canShowNotification()) {
vscode.window
.showWarningMessage(
`Qwen Code CLI version ${currentVersion} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later`,
'Upgrade Now',
'View Documentation',
)
.then(async (selection) => {
if (selection === 'Upgrade Now') {
await CliInstaller.install();
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse(
'https://github.com/QwenLM/qwen-code#installation',
),
);
}
});
this.lastNotificationTime = Date.now();
}
return {
isInstalled: true,
version: currentVersion,
isSupported,
needsUpdate,
};
} catch (error) {
console.error('[CliManager] Version check failed:', error);
if (showNotifications && this.canShowNotification()) {
vscode.window.showErrorMessage(
`Failed to check Qwen Code CLI version: ${error instanceof Error ? error.message : String(error)}`,
);
this.lastNotificationTime = Date.now();
}
return {
isInstalled: false,
error: error instanceof Error ? error.message : String(error),
isSupported: false,
needsUpdate: false,
};
}
}
/**
* Check if notification can be shown based on cooldown period
*/
private canShowNotification(): boolean {
return (
Date.now() - this.lastNotificationTime >
CliManager.NOTIFICATION_COOLDOWN_MS
);
}
}

View File

@@ -197,7 +197,6 @@ export class QwenAgentManager {
this.currentWorkingDir = workingDir; this.currentWorkingDir = workingDir;
return this.connectionHandler.connect( return this.connectionHandler.connect(
this.connection, this.connection,
this.sessionReader,
workingDir, workingDir,
cliEntryPath, cliEntryPath,
options, options,

View File

@@ -11,9 +11,6 @@
*/ */
import type { AcpConnection } from './acpConnection.js'; import type { AcpConnection } from './acpConnection.js';
import type { QwenSessionReader } from '../services/qwenSessionReader.js';
import { authMethod } from '../types/acpTypes.js';
import { isAuthenticationRequiredError } from '../utils/authErrors.js';
export interface QwenConnectionResult { export interface QwenConnectionResult {
sessionCreated: boolean; sessionCreated: boolean;
@@ -29,156 +26,31 @@ export class QwenConnectionHandler {
* Connect to Qwen service and establish session * Connect to Qwen service and establish session
* *
* @param connection - ACP connection instance * @param connection - ACP connection instance
* @param sessionReader - Session reader instance
* @param workingDir - Working directory * @param workingDir - Working directory
* @param cliEntryPath - Path to bundled CLI entrypoint (cli.js) * @param cliEntryPath - Path to bundled CLI entrypoint (cli.js)
*/ */
async connect( async connect(
connection: AcpConnection, connection: AcpConnection,
sessionReader: QwenSessionReader,
workingDir: string, workingDir: string,
cliEntryPath: string, cliEntryPath: string,
options?: {
autoAuthenticate?: boolean;
},
): Promise<QwenConnectionResult> { ): Promise<QwenConnectionResult> {
const connectId = Date.now(); const connectId = Date.now();
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`); console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
const autoAuthenticate = options?.autoAuthenticate ?? true; const sessionCreated = false;
let sessionCreated = false; const requiresAuth = false;
let requiresAuth = false;
// Build extra CLI arguments (only essential parameters) // Build extra CLI arguments (only essential parameters)
const extraArgs: string[] = []; const extraArgs: string[] = [];
await connection.connect(cliEntryPath, workingDir, extraArgs); await connection.connect(cliEntryPath, workingDir, extraArgs);
// Try to restore existing session or create new session // Note: Session creation is now handled by the caller (QwenAgentManager)
// Note: Auto-restore on connect is disabled to avoid surprising loads // This prevents automatic session creation on every connection which was
// when user opens a "New Chat" tab. Restoration is now an explicit action // causing unwanted authentication prompts
// (session selector → session/load) or handled by higher-level flows.
const sessionRestored = false;
// Create new session if unable to restore
if (!sessionRestored) {
console.log(
'[QwenAgentManager] no sessionRestored, Creating new session...',
);
try {
console.log(
'[QwenAgentManager] Creating new session (letting CLI handle authentication)...',
);
await this.newSessionWithRetry(
connection,
workingDir,
3,
authMethod,
autoAuthenticate,
);
console.log('[QwenAgentManager] New session created successfully');
sessionCreated = true;
} catch (sessionError) {
const needsAuth =
autoAuthenticate === false &&
isAuthenticationRequiredError(sessionError);
if (needsAuth) {
requiresAuth = true;
console.log(
'[QwenAgentManager] Session creation requires authentication; waiting for user-triggered login.',
);
} else {
console.log(
`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`,
);
console.log(`[QwenAgentManager] Error details:`, sessionError);
throw sessionError;
}
}
} else {
sessionCreated = true;
}
console.log(`\n========================================`); console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`); console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`); console.log(`========================================\n`);
return { sessionCreated, requiresAuth }; return { sessionCreated, requiresAuth };
} }
/**
* Create new session (with retry)
*
* @param connection - ACP connection instance
* @param workingDir - Working directory
* @param maxRetries - Maximum number of retries
*/
private async newSessionWithRetry(
connection: AcpConnection,
workingDir: string,
maxRetries: number,
authMethod: string,
autoAuthenticate: boolean,
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
);
await connection.newSession(workingDir);
console.log('[QwenAgentManager] Session created successfully');
return;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[QwenAgentManager] Session creation attempt ${attempt} failed:`,
errorMessage,
);
// If Qwen reports that authentication is required, try to
// authenticate on-the-fly once and retry without waiting.
const requiresAuth = isAuthenticationRequiredError(error);
if (requiresAuth) {
if (!autoAuthenticate) {
console.log(
'[QwenAgentManager] Authentication required but auto-authentication is disabled. Propagating error.',
);
throw error;
}
console.log(
'[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...',
);
try {
await connection.authenticate(authMethod);
// FIXME: @yiliang114 If there is no delay for a while, immediately executing
// newSession may cause the cli authorization jump to be triggered again
// Add a slight delay to ensure auth state is settled
await new Promise((resolve) => setTimeout(resolve, 300));
// Retry immediately after successful auth
await connection.newSession(workingDir);
console.log(
'[QwenAgentManager] Session created successfully after auth',
);
return;
} catch (authErr) {
console.error(
'[QwenAgentManager] Re-authentication failed:',
authErr,
);
// Fall through to retry logic below
}
}
if (attempt === maxRetries) {
throw new Error(
`Session creation failed after ${maxRetries} attempts: ${errorMessage}`,
);
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.log(`[QwenAgentManager] Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
} }

View File

@@ -570,94 +570,68 @@ export class WebViewProvider {
).fsPath; ).fsPath;
try { try {
console.log('[WebViewProvider] Connecting to bundled agent...'); console.log('[WebViewProvider] Connecting to agent...');
console.log('[WebViewProvider] Bundled CLI entry:', bundledCliEntry);
await this.agentManager.connect(workingDir, bundledCliEntry); // Pass the detected CLI path to ensure we use the correct installation
const connectResult = await this.agentManager.connect(
workingDir,
bundledCliEntry,
options,
);
console.log('[WebViewProvider] Agent connected successfully'); console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true; this.agentInitialized = true;
// Load messages from the current Qwen session // If authentication is required and autoAuthenticate is false,
await this.loadCurrentSessionMessages(); // send authState message and return without creating session
if (connectResult.requiresAuth && !autoAuthenticate) {
console.log(
'[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
// Initialize empty conversation to allow browsing history
await this.initializeEmptyConversation();
return;
}
// Notify webview that agent is connected if (connectResult.requiresAuth) {
this.sendMessageToWebView({ this.sendMessageToWebView({
type: 'agentConnected', type: 'authState',
data: {}, data: { authenticated: false },
}); });
}
// Load messages from the current Qwen session
const sessionReady = await this.loadCurrentSessionMessages(options);
if (sessionReady) {
// Notify webview that agent is connected
this.sendMessageToWebView({
type: 'agentConnected',
data: {},
});
} else {
console.log(
'[WebViewProvider] Session creation deferred until user logs in.',
);
}
} catch (_error) { } catch (_error) {
console.error('[WebViewProvider] Agent connection error:', _error); console.error('[WebViewProvider] Agent connection error:', _error);
vscode.window.showWarningMessage( vscode.window.showWarningMessage(
`Failed to start bundled Qwen Code CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`, `Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
); );
// Fallback to empty conversation // Fallback to empty conversation
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
try { // Notify webview that agent connection failed
console.log('[WebViewProvider] Connecting to agent...'); this.sendMessageToWebView({
type: 'agentConnectionError',
// Pass the detected CLI path to ensure we use the correct installation data: {
const connectResult = await this.agentManager.connect( message: _error instanceof Error ? _error.message : String(_error),
workingDir, },
bundledCliEntry, });
options,
);
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// If authentication is required and autoAuthenticate is false,
// send authState message and return without creating session
if (connectResult.requiresAuth && !autoAuthenticate) {
console.log(
'[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning',
);
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
// Initialize empty conversation to allow browsing history
await this.initializeEmptyConversation();
return;
}
if (connectResult.requiresAuth) {
this.sendMessageToWebView({
type: 'authState',
data: { authenticated: false },
});
}
// Load messages from the current Qwen session
const sessionReady = await this.loadCurrentSessionMessages(options);
if (sessionReady) {
// Notify webview that agent is connected
this.sendMessageToWebView({
type: 'agentConnected',
data: {},
});
} else {
console.log(
'[WebViewProvider] Session creation deferred until user logs in.',
);
}
} catch (_error) {
console.error('[WebViewProvider] Agent connection error:', _error);
vscode.window.showWarningMessage(
`Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
);
// Fallback to empty conversation
await this.initializeEmptyConversation();
// Notify webview that agent connection failed
this.sendMessageToWebView({
type: 'agentConnectionError',
data: {
message:
_error instanceof Error ? _error.message : String(_error),
},
});
}
} }
}; };