feat(vscode-ide-companion/auth): deduplicate concurrent authentication calls

Prevent multiple simultaneous authentication flows by:
- Adding static authInFlight promise tracking in AcpConnection
- Implementing runExclusiveAuth method in AuthStateManager
- Adding sessionCreateInFlight tracking in QwenAgentManager
- Ensuring only one auth flow runs at a time across different components

This prevents race conditions and duplicate login prompts when multiple components request authentication simultaneously.
This commit is contained in:
yiliang114
2025-12-11 22:56:58 +08:00
parent 58d3a9c253
commit b34894c8ea
12 changed files with 589 additions and 464 deletions

View File

@@ -8,7 +8,11 @@ import * as vscode from 'vscode';
import { IDEServer } from './ide-server.js'; import { IDEServer } from './ide-server.js';
import semver from 'semver'; import semver from 'semver';
import { DiffContentProvider, DiffManager } from './diff-manager.js'; import { DiffContentProvider, DiffManager } from './diff-manager.js';
import { createLogger } from './utils/logger.js'; import {
createLogger,
getConsoleLogger,
initSharedConsoleLogger,
} from './utils/logger.js';
import { import {
detectIdeFromEnv, detectIdeFromEnv,
IDE_DEFINITIONS, IDE_DEFINITIONS,
@@ -105,6 +109,8 @@ async function checkForUpdates(
export async function activate(context: vscode.ExtensionContext) { export async function activate(context: vscode.ExtensionContext) {
logger = vscode.window.createOutputChannel('Qwen Code Companion'); logger = vscode.window.createOutputChannel('Qwen Code Companion');
initSharedConsoleLogger(context);
const consoleLog = getConsoleLogger();
log = createLogger(context, logger); log = createLogger(context, logger);
log('Extension activated'); log('Extension activated');
@@ -142,18 +148,18 @@ export async function activate(context: vscode.ExtensionContext) {
webviewPanel: vscode.WebviewPanel, webviewPanel: vscode.WebviewPanel,
state: unknown, state: unknown,
) { ) {
console.log( consoleLog(
'[Extension] Deserializing WebView panel with state:', '[Extension] Deserializing WebView panel with state:',
state, state,
); );
// Create a new provider for the restored panel // Create a new provider for the restored panel
const provider = createWebViewProvider(); const provider = createWebViewProvider();
console.log('[Extension] Provider created for deserialization'); consoleLog('[Extension] Provider created for deserialization');
// Restore state if available BEFORE restoring the panel // Restore state if available BEFORE restoring the panel
if (state && typeof state === 'object') { if (state && typeof state === 'object') {
console.log('[Extension] Restoring state:', state); consoleLog('[Extension] Restoring state:', state);
provider.restoreState( provider.restoreState(
state as { state as {
conversationId: string | null; conversationId: string | null;
@@ -161,11 +167,11 @@ export async function activate(context: vscode.ExtensionContext) {
}, },
); );
} else { } else {
console.log('[Extension] No state to restore or invalid state'); consoleLog('[Extension] No state to restore or invalid state');
} }
await provider.restorePanel(webviewPanel); await provider.restorePanel(webviewPanel);
console.log('[Extension] Panel restore completed'); consoleLog('[Extension] Panel restore completed');
log('WebView panel restored from serialization'); log('WebView panel restored from serialization');
}, },
@@ -206,7 +212,6 @@ export async function activate(context: vscode.ExtensionContext) {
} catch (err) { } catch (err) {
console.warn('[Extension] Auto-allow on diff.accept failed:', err); console.warn('[Extension] Auto-allow on diff.accept failed:', err);
} }
console.log('[Extension] Diff accepted');
}), }),
vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => { vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => {
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
@@ -223,7 +228,6 @@ export async function activate(context: vscode.ExtensionContext) {
} catch (err) { } catch (err) {
console.warn('[Extension] Auto-reject on diff.cancel failed:', err); console.warn('[Extension] Auto-reject on diff.cancel failed:', err);
} }
console.log('[Extension] Diff cancelled');
})), })),
vscode.commands.registerCommand('qwen.diff.closeAll', async () => { vscode.commands.registerCommand('qwen.diff.closeAll', async () => {
try { try {

View File

@@ -31,6 +31,8 @@ export class AcpConnection {
private child: ChildProcess | null = null; private child: ChildProcess | null = null;
private pendingRequests = new Map<number, PendingRequest<unknown>>(); private pendingRequests = new Map<number, PendingRequest<unknown>>();
private nextRequestId = { value: 0 }; private nextRequestId = { value: 0 };
// Deduplicate concurrent authenticate calls (across retry paths)
private static authInFlight: Promise<AcpResponse> | null = null;
// Remember the working dir provided at connect() so later ACP calls // Remember the working dir provided at connect() so later ACP calls
// that require cwd (e.g. session/list) can include it. // that require cwd (e.g. session/list) can include it.
private workingDir: string = process.cwd(); private workingDir: string = process.cwd();
@@ -271,12 +273,23 @@ export class AcpConnection {
* @returns Authentication response * @returns Authentication response
*/ */
async authenticate(methodId?: string): Promise<AcpResponse> { async authenticate(methodId?: string): Promise<AcpResponse> {
return this.sessionManager.authenticate( if (AcpConnection.authInFlight) {
return AcpConnection.authInFlight;
}
const p = this.sessionManager
.authenticate(
methodId, methodId,
this.child, this.child,
this.pendingRequests, this.pendingRequests,
this.nextRequestId, this.nextRequestId,
); )
.finally(() => {
AcpConnection.authInFlight = null;
});
AcpConnection.authInFlight = p;
return p;
} }
/** /**

View File

@@ -5,6 +5,7 @@
*/ */
import type * as vscode from 'vscode'; import type * as vscode from 'vscode';
import { createConsoleLogger, getConsoleLogger } from '../utils/logger.js';
interface AuthState { interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -21,6 +22,9 @@ export class AuthStateManager {
private static context: vscode.ExtensionContext | null = null; private static context: vscode.ExtensionContext | null = null;
private static readonly AUTH_STATE_KEY = 'qwen.authState'; private static readonly AUTH_STATE_KEY = 'qwen.authState';
private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
private static consoleLog: (...args: unknown[]) => void = getConsoleLogger();
// Deduplicate concurrent auth flows (e.g., multiple tabs prompting login)
private static authFlowInFlight: Promise<unknown> | null = null;
private constructor() {} private constructor() {}
/** /**
@@ -34,11 +38,37 @@ export class AuthStateManager {
// If a context is provided, update the static context // If a context is provided, update the static context
if (context) { if (context) {
AuthStateManager.context = context; AuthStateManager.context = context;
AuthStateManager.consoleLog = createConsoleLogger(
context,
'AuthStateManager',
);
} }
return AuthStateManager.instance; return AuthStateManager.instance;
} }
/**
* Run an auth-related flow exclusively. If another flow is already running,
* return the same promise to prevent duplicate login prompts.
*/
static runExclusiveAuth<T>(task: () => Promise<T>): Promise<T> {
if (AuthStateManager.authFlowInFlight) {
return AuthStateManager.authFlowInFlight as Promise<T>;
}
const p = Promise.resolve()
.then(task)
.finally(() => {
// Clear only if this promise is still the active one
if (AuthStateManager.authFlowInFlight === p) {
AuthStateManager.authFlowInFlight = null;
}
});
AuthStateManager.authFlowInFlight = p;
return p as Promise<T>;
}
/** /**
* Check if there's a valid cached authentication * Check if there's a valid cached authentication
*/ */
@@ -46,17 +76,19 @@ export class AuthStateManager {
const state = await this.getAuthState(); const state = await this.getAuthState();
if (!state) { if (!state) {
console.log('[AuthStateManager] No cached auth state found'); AuthStateManager.consoleLog(
'[AuthStateManager] No cached auth state found',
);
return false; return false;
} }
console.log('[AuthStateManager] Found cached auth state:', { AuthStateManager.consoleLog('[AuthStateManager] Found cached auth state:', {
workingDir: state.workingDir, workingDir: state.workingDir,
authMethod: state.authMethod, authMethod: state.authMethod,
timestamp: new Date(state.timestamp).toISOString(), timestamp: new Date(state.timestamp).toISOString(),
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
}); });
console.log('[AuthStateManager] Checking against:', { AuthStateManager.consoleLog('[AuthStateManager] Checking against:', {
workingDir, workingDir,
authMethod, authMethod,
}); });
@@ -67,8 +99,8 @@ export class AuthStateManager {
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
if (isExpired) { if (isExpired) {
console.log('[AuthStateManager] Cached auth expired'); AuthStateManager.consoleLog('[AuthStateManager] Cached auth expired');
console.log( AuthStateManager.consoleLog(
'[AuthStateManager] Cache age:', '[AuthStateManager] Cache age:',
Math.floor((now - state.timestamp) / 1000 / 60), Math.floor((now - state.timestamp) / 1000 / 60),
'minutes', 'minutes',
@@ -82,15 +114,29 @@ export class AuthStateManager {
state.workingDir === workingDir && state.authMethod === authMethod; state.workingDir === workingDir && state.authMethod === authMethod;
if (!isSameContext) { if (!isSameContext) {
console.log('[AuthStateManager] Working dir or auth method changed'); AuthStateManager.consoleLog(
console.log('[AuthStateManager] Cached workingDir:', state.workingDir); '[AuthStateManager] Working dir or auth method changed',
console.log('[AuthStateManager] Current workingDir:', workingDir); );
console.log('[AuthStateManager] Cached authMethod:', state.authMethod); AuthStateManager.consoleLog(
console.log('[AuthStateManager] Current authMethod:', authMethod); '[AuthStateManager] Cached workingDir:',
state.workingDir,
);
AuthStateManager.consoleLog(
'[AuthStateManager] Current workingDir:',
workingDir,
);
AuthStateManager.consoleLog(
'[AuthStateManager] Cached authMethod:',
state.authMethod,
);
AuthStateManager.consoleLog(
'[AuthStateManager] Current authMethod:',
authMethod,
);
return false; return false;
} }
console.log('[AuthStateManager] Valid cached auth found'); AuthStateManager.consoleLog('[AuthStateManager] Valid cached auth found');
return state.isAuthenticated; return state.isAuthenticated;
} }
@@ -100,7 +146,10 @@ export class AuthStateManager {
*/ */
async debugAuthState(): Promise<void> { async debugAuthState(): Promise<void> {
const state = await this.getAuthState(); const state = await this.getAuthState();
console.log('[AuthStateManager] DEBUG - Current auth state:', state); AuthStateManager.consoleLog(
'[AuthStateManager] DEBUG - Current auth state:',
state,
);
if (state) { if (state) {
const now = Date.now(); const now = Date.now();
@@ -108,9 +157,16 @@ export class AuthStateManager {
const isExpired = const isExpired =
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
console.log('[AuthStateManager] DEBUG - Auth state age:', age, 'minutes'); AuthStateManager.consoleLog(
console.log('[AuthStateManager] DEBUG - Auth state expired:', isExpired); '[AuthStateManager] DEBUG - Auth state age:',
console.log( age,
'minutes',
);
AuthStateManager.consoleLog(
'[AuthStateManager] DEBUG - Auth state expired:',
isExpired,
);
AuthStateManager.consoleLog(
'[AuthStateManager] DEBUG - Auth state valid:', '[AuthStateManager] DEBUG - Auth state valid:',
state.isAuthenticated, state.isAuthenticated,
); );
@@ -135,7 +191,7 @@ export class AuthStateManager {
timestamp: Date.now(), timestamp: Date.now(),
}; };
console.log('[AuthStateManager] Saving auth state:', { AuthStateManager.consoleLog('[AuthStateManager] Saving auth state:', {
workingDir, workingDir,
authMethod, authMethod,
timestamp: new Date(state.timestamp).toISOString(), timestamp: new Date(state.timestamp).toISOString(),
@@ -145,11 +201,14 @@ export class AuthStateManager {
AuthStateManager.AUTH_STATE_KEY, AuthStateManager.AUTH_STATE_KEY,
state, state,
); );
console.log('[AuthStateManager] Auth state saved'); AuthStateManager.consoleLog('[AuthStateManager] Auth state saved');
// Verify the state was saved correctly // Verify the state was saved correctly
const savedState = await this.getAuthState(); const savedState = await this.getAuthState();
console.log('[AuthStateManager] Verified saved state:', savedState); AuthStateManager.consoleLog(
'[AuthStateManager] Verified saved state:',
savedState,
);
} }
/** /**
@@ -163,9 +222,9 @@ export class AuthStateManager {
); );
} }
console.log('[AuthStateManager] Clearing auth state'); AuthStateManager.consoleLog('[AuthStateManager] Clearing auth state');
const currentState = await this.getAuthState(); const currentState = await this.getAuthState();
console.log( AuthStateManager.consoleLog(
'[AuthStateManager] Current state before clearing:', '[AuthStateManager] Current state before clearing:',
currentState, currentState,
); );
@@ -174,11 +233,14 @@ export class AuthStateManager {
AuthStateManager.AUTH_STATE_KEY, AuthStateManager.AUTH_STATE_KEY,
undefined, undefined,
); );
console.log('[AuthStateManager] Auth state cleared'); AuthStateManager.consoleLog('[AuthStateManager] Auth state cleared');
// Verify the state was cleared // Verify the state was cleared
const newState = await this.getAuthState(); const newState = await this.getAuthState();
console.log('[AuthStateManager] State after clearing:', newState); AuthStateManager.consoleLog(
'[AuthStateManager] State after clearing:',
newState,
);
} }
/** /**
@@ -187,17 +249,15 @@ export class AuthStateManager {
private async getAuthState(): Promise<AuthState | undefined> { private async getAuthState(): Promise<AuthState | undefined> {
// Ensure we have a valid context // Ensure we have a valid context
if (!AuthStateManager.context) { if (!AuthStateManager.context) {
console.log( AuthStateManager.consoleLog(
'[AuthStateManager] No context available for getting auth state', '[AuthStateManager] No context available for getting auth state',
); );
return undefined; return undefined;
} }
const a = AuthStateManager.context.globalState.get<AuthState>( return AuthStateManager.context.globalState.get<AuthState>(
AuthStateManager.AUTH_STATE_KEY, AuthStateManager.AUTH_STATE_KEY,
); );
console.log('[AuthStateManager] Auth state:', a);
return a;
} }
/** /**

View File

@@ -23,6 +23,7 @@ import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import { CliContextManager } from '../cli/cliContextManager.js'; import { CliContextManager } from '../cli/cliContextManager.js';
import { authMethod } from '../types/acpTypes.js'; import { authMethod } from '../types/acpTypes.js';
import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js'; import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js';
import { getConsoleLogger } from '../utils/logger.js';
export type { ChatMessage, PlanEntry, ToolCallUpdateData }; export type { ChatMessage, PlanEntry, ToolCallUpdateData };
@@ -45,11 +46,15 @@ export class QwenAgentManager {
// Cache the last used AuthStateManager so internal calls (e.g. fallback paths) // Cache the last used AuthStateManager so internal calls (e.g. fallback paths)
// can reuse it and avoid forcing a fresh authentication unnecessarily. // can reuse it and avoid forcing a fresh authentication unnecessarily.
private defaultAuthStateManager?: AuthStateManager; private defaultAuthStateManager?: AuthStateManager;
// Deduplicate concurrent session/new attempts
private sessionCreateInFlight: Promise<string | null> | null = null;
// Callback storage // Callback storage
private callbacks: QwenAgentCallbacks = {}; private callbacks: QwenAgentCallbacks = {};
private consoleLog: (...args: unknown[]) => void;
constructor() { constructor(consoleLogger = getConsoleLogger()) {
this.consoleLog = consoleLogger;
this.connection = new AcpConnection(); this.connection = new AcpConnection();
this.sessionReader = new QwenSessionReader(); this.sessionReader = new QwenSessionReader();
this.sessionManager = new QwenSessionManager(); this.sessionManager = new QwenSessionManager();
@@ -76,7 +81,7 @@ export class QwenAgentManager {
).update; ).update;
const text = update?.content?.text || ''; const text = update?.content?.text || '';
if (update?.sessionUpdate === 'user_message_chunk' && text) { if (update?.sessionUpdate === 'user_message_chunk' && text) {
console.log( this.consoleLog(
'[QwenAgentManager] Rehydration: routing user message chunk', '[QwenAgentManager] Rehydration: routing user message chunk',
); );
this.callbacks.onMessage?.({ this.callbacks.onMessage?.({
@@ -87,7 +92,7 @@ export class QwenAgentManager {
return; return;
} }
if (update?.sessionUpdate === 'agent_message_chunk' && text) { if (update?.sessionUpdate === 'agent_message_chunk' && text) {
console.log( this.consoleLog(
'[QwenAgentManager] Rehydration: routing agent message chunk', '[QwenAgentManager] Rehydration: routing agent message chunk',
); );
this.callbacks.onMessage?.({ this.callbacks.onMessage?.({
@@ -98,7 +103,7 @@ export class QwenAgentManager {
return; return;
} }
// For other types during rehydration, fall through to normal handler // For other types during rehydration, fall through to normal handler
console.log( this.consoleLog(
'[QwenAgentManager] Rehydration: non-text update, forwarding to handler', '[QwenAgentManager] Rehydration: non-text update, forwarding to handler',
); );
} }
@@ -257,7 +262,7 @@ export class QwenAgentManager {
* @returns Session list * @returns Session list
*/ */
async getSessionList(): Promise<Array<Record<string, unknown>>> { async getSessionList(): Promise<Array<Record<string, unknown>>> {
console.log( this.consoleLog(
'[QwenAgentManager] Getting session list with version-aware strategy', '[QwenAgentManager] Getting session list with version-aware strategy',
); );
@@ -265,7 +270,7 @@ export class QwenAgentManager {
const cliContextManager = CliContextManager.getInstance(); const cliContextManager = CliContextManager.getInstance();
const supportsSessionList = cliContextManager.supportsSessionList(); const supportsSessionList = cliContextManager.supportsSessionList();
console.log( this.consoleLog(
'[QwenAgentManager] CLI supports session/list:', '[QwenAgentManager] CLI supports session/list:',
supportsSessionList, supportsSessionList,
); );
@@ -273,11 +278,14 @@ export class QwenAgentManager {
// Try ACP method first if supported // Try ACP method first if supported
if (supportsSessionList) { if (supportsSessionList) {
try { try {
console.log( this.consoleLog(
'[QwenAgentManager] Attempting to get session list via ACP method', '[QwenAgentManager] Attempting to get session list via ACP method',
); );
const response = await this.connection.listSessions(); const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response); this.consoleLog(
'[QwenAgentManager] ACP session list response:',
response,
);
// sendRequest resolves with the JSON-RPC "result" directly // sendRequest resolves with the JSON-RPC "result" directly
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore } // Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
@@ -295,7 +303,7 @@ export class QwenAgentManager {
: []; : [];
} }
console.log( this.consoleLog(
'[QwenAgentManager] Sessions retrieved via ACP:', '[QwenAgentManager] Sessions retrieved via ACP:',
res, res,
items.length, items.length,
@@ -314,7 +322,7 @@ export class QwenAgentManager {
cwd: item.cwd, cwd: item.cwd,
})); }));
console.log( this.consoleLog(
'[QwenAgentManager] Sessions retrieved via ACP:', '[QwenAgentManager] Sessions retrieved via ACP:',
sessions.length, sessions.length,
); );
@@ -330,9 +338,11 @@ export class QwenAgentManager {
// Always fall back to file system method // Always fall back to file system method
try { try {
console.log('[QwenAgentManager] Getting session list from file system'); this.consoleLog(
'[QwenAgentManager] Getting session list from file system',
);
const sessions = await this.sessionReader.getAllSessions(undefined, true); const sessions = await this.sessionReader.getAllSessions(undefined, true);
console.log( this.consoleLog(
'[QwenAgentManager] Session list from file system (all projects):', '[QwenAgentManager] Session list from file system (all projects):',
sessions.length, sessions.length,
); );
@@ -350,7 +360,7 @@ export class QwenAgentManager {
}), }),
); );
console.log( this.consoleLog(
'[QwenAgentManager] Sessions retrieved from file system:', '[QwenAgentManager] Sessions retrieved from file system:',
result.length, result.length,
); );
@@ -490,7 +500,7 @@ export class QwenAgentManager {
const item = list.find( const item = list.find(
(s) => s.sessionId === sessionId || s.id === sessionId, (s) => s.sessionId === sessionId || s.id === sessionId,
); );
console.log( this.consoleLog(
'[QwenAgentManager] Session list item for filePath lookup:', '[QwenAgentManager] Session list item for filePath lookup:',
item, item,
); );
@@ -561,7 +571,7 @@ export class QwenAgentManager {
} }
} }
// Simple linear reconstruction: filter user/assistant and sort by timestamp // Simple linear reconstruction: filter user/assistant and sort by timestamp
console.log( this.consoleLog(
'[QwenAgentManager] JSONL records read:', '[QwenAgentManager] JSONL records read:',
records.length, records.length,
filePath, filePath,
@@ -718,7 +728,7 @@ export class QwenAgentManager {
// Handle other types if needed // Handle other types if needed
} }
console.log( this.consoleLog(
'[QwenAgentManager] JSONL messages reconstructed:', '[QwenAgentManager] JSONL messages reconstructed:',
msgs.length, msgs.length,
); );
@@ -856,7 +866,7 @@ export class QwenAgentManager {
tag: string, tag: string,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
try { try {
console.log( this.consoleLog(
'[QwenAgentManager] Saving session via /chat save command:', '[QwenAgentManager] Saving session via /chat save command:',
sessionId, sessionId,
'with tag:', 'with tag:',
@@ -867,7 +877,9 @@ export class QwenAgentManager {
// The CLI will handle this as a special command // The CLI will handle this as a special command
await this.connection.sendPrompt(`/chat save "${tag}"`); await this.connection.sendPrompt(`/chat save "${tag}"`);
console.log('[QwenAgentManager] /chat save command sent successfully'); this.consoleLog(
'[QwenAgentManager] /chat save command sent successfully',
);
return { return {
success: true, success: true,
message: `Session saved with tag: ${tag}`, message: `Session saved with tag: ${tag}`,
@@ -914,14 +926,14 @@ export class QwenAgentManager {
conversationId: string, conversationId: string,
): Promise<{ success: boolean; tag?: string; message?: string }> { ): Promise<{ success: boolean; tag?: string; message?: string }> {
try { try {
console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); this.consoleLog('[QwenAgentManager] ===== CHECKPOINT SAVE START =====');
console.log('[QwenAgentManager] Conversation ID:', conversationId); this.consoleLog('[QwenAgentManager] Conversation ID:', conversationId);
console.log('[QwenAgentManager] Message count:', messages.length); this.consoleLog('[QwenAgentManager] Message count:', messages.length);
console.log( this.consoleLog(
'[QwenAgentManager] Current working dir:', '[QwenAgentManager] Current working dir:',
this.currentWorkingDir, this.currentWorkingDir,
); );
console.log( this.consoleLog(
'[QwenAgentManager] Current session ID (from CLI):', '[QwenAgentManager] Current session ID (from CLI):',
this.currentSessionId, this.currentSessionId,
); );
@@ -998,11 +1010,11 @@ export class QwenAgentManager {
try { try {
// Route upcoming session/update messages as discrete messages for replay // Route upcoming session/update messages as discrete messages for replay
this.rehydratingSessionId = sessionId; this.rehydratingSessionId = sessionId;
console.log( this.consoleLog(
'[QwenAgentManager] Rehydration start for session:', '[QwenAgentManager] Rehydration start for session:',
sessionId, sessionId,
); );
console.log( this.consoleLog(
'[QwenAgentManager] Attempting session/load via ACP for session:', '[QwenAgentManager] Attempting session/load via ACP for session:',
sessionId, sessionId,
); );
@@ -1010,7 +1022,7 @@ export class QwenAgentManager {
sessionId, sessionId,
cwdOverride, cwdOverride,
); );
console.log( this.consoleLog(
'[QwenAgentManager] Session load succeeded. Response:', '[QwenAgentManager] Session load succeeded. Response:',
JSON.stringify(response).substring(0, 200), JSON.stringify(response).substring(0, 200),
); );
@@ -1050,7 +1062,10 @@ export class QwenAgentManager {
throw error; throw error;
} finally { } finally {
// End rehydration routing regardless of outcome // End rehydration routing regardless of outcome
console.log('[QwenAgentManager] Rehydration end for session:', sessionId); this.consoleLog(
'[QwenAgentManager] Rehydration end for session:',
sessionId,
);
this.rehydratingSessionId = null; this.rehydratingSessionId = null;
} }
} }
@@ -1063,7 +1078,7 @@ export class QwenAgentManager {
* @returns Loaded session messages or null * @returns Loaded session messages or null
*/ */
async loadSession(sessionId: string): Promise<ChatMessage[] | null> { async loadSession(sessionId: string): Promise<ChatMessage[] | null> {
console.log( this.consoleLog(
'[QwenAgentManager] Loading session with version-aware strategy:', '[QwenAgentManager] Loading session with version-aware strategy:',
sessionId, sessionId,
); );
@@ -1072,7 +1087,7 @@ export class QwenAgentManager {
const cliContextManager = CliContextManager.getInstance(); const cliContextManager = CliContextManager.getInstance();
const supportsSessionLoad = cliContextManager.supportsSessionLoad(); const supportsSessionLoad = cliContextManager.supportsSessionLoad();
console.log( this.consoleLog(
'[QwenAgentManager] CLI supports session/load:', '[QwenAgentManager] CLI supports session/load:',
supportsSessionLoad, supportsSessionLoad,
); );
@@ -1080,11 +1095,13 @@ export class QwenAgentManager {
// Try ACP method first if supported // Try ACP method first if supported
if (supportsSessionLoad) { if (supportsSessionLoad) {
try { try {
console.log( this.consoleLog(
'[QwenAgentManager] Attempting to load session via ACP method', '[QwenAgentManager] Attempting to load session via ACP method',
); );
await this.loadSessionViaAcp(sessionId); await this.loadSessionViaAcp(sessionId);
console.log('[QwenAgentManager] Session loaded successfully via ACP'); this.consoleLog(
'[QwenAgentManager] Session loaded successfully via ACP',
);
// After loading via ACP, we still need to get messages from file system // After loading via ACP, we still need to get messages from file system
// In future, we might get them directly from the ACP response // In future, we might get them directly from the ACP response
@@ -1098,11 +1115,11 @@ export class QwenAgentManager {
// Always fall back to file system method // Always fall back to file system method
try { try {
console.log( this.consoleLog(
'[QwenAgentManager] Loading session messages from file system', '[QwenAgentManager] Loading session messages from file system',
); );
const messages = await this.loadSessionMessagesFromFile(sessionId); const messages = await this.loadSessionMessagesFromFile(sessionId);
console.log( this.consoleLog(
'[QwenAgentManager] Session messages loaded successfully from file system', '[QwenAgentManager] Session messages loaded successfully from file system',
); );
return messages; return messages;
@@ -1125,7 +1142,7 @@ export class QwenAgentManager {
sessionId: string, sessionId: string,
): Promise<ChatMessage[] | null> { ): Promise<ChatMessage[] | null> {
try { try {
console.log( this.consoleLog(
'[QwenAgentManager] Loading session from file system:', '[QwenAgentManager] Loading session from file system:',
sessionId, sessionId,
); );
@@ -1137,7 +1154,7 @@ export class QwenAgentManager {
); );
if (!session) { if (!session) {
console.log( this.consoleLog(
'[QwenAgentManager] Session not found in file system:', '[QwenAgentManager] Session not found in file system:',
sessionId, sessionId,
); );
@@ -1183,55 +1200,23 @@ export class QwenAgentManager {
workingDir: string, workingDir: string,
authStateManager?: AuthStateManager, authStateManager?: AuthStateManager,
): Promise<string | null> { ): Promise<string | null> {
console.log('[QwenAgentManager] Creating new session...'); // Reuse existing session if present
if (this.connection.currentSessionId) {
return this.connection.currentSessionId;
}
// Deduplicate concurrent session/new attempts
if (this.sessionCreateInFlight) {
return this.sessionCreateInFlight;
}
// Check if we have valid cached authentication this.consoleLog('[QwenAgentManager] Creating new session...');
let hasValidAuth = false;
// Prefer the provided authStateManager, otherwise fall back to the one // Prefer the provided authStateManager, otherwise fall back to the one
// remembered during connect(). This prevents accidental re-auth in // remembered during connect(). This prevents accidental re-auth in
// fallback paths (e.g. session switching) when the handler didn't pass it. // fallback paths (e.g. session switching) when the handler didn't pass it.
const effectiveAuth = authStateManager || this.defaultAuthStateManager; const effectiveAuth = authStateManager || this.defaultAuthStateManager;
if (effectiveAuth) {
hasValidAuth = await effectiveAuth.hasValidAuth(workingDir, authMethod);
console.log(
'[QwenAgentManager] Has valid cached auth for new session:',
hasValidAuth,
);
}
// Only authenticate if we don't have valid cached auth this.sessionCreateInFlight = (async () => {
if (!hasValidAuth) {
console.log(
'[QwenAgentManager] Authenticating before creating session...',
);
try { try {
await this.connection.authenticate(authMethod);
console.log('[QwenAgentManager] Authentication successful');
// Save auth state
if (effectiveAuth) {
console.log(
'[QwenAgentManager] Saving auth state after successful authentication',
);
await effectiveAuth.saveAuthState(workingDir, authMethod);
}
} catch (authError) {
console.error('[QwenAgentManager] Authentication failed:', authError);
// Clear potentially invalid cache
if (effectiveAuth) {
console.log(
'[QwenAgentManager] Clearing auth cache due to authentication failure',
);
await effectiveAuth.clearAuthState();
}
throw authError;
}
} else {
console.log(
'[QwenAgentManager] Skipping authentication - using valid cached auth',
);
}
// Try to create a new ACP session. If Qwen asks for auth despite our // Try to create a new ACP session. If Qwen asks for auth despite our
// cached flag (e.g. fresh process or expired tokens), re-authenticate and retry. // cached flag (e.g. fresh process or expired tokens), re-authenticate and retry.
try { try {
@@ -1265,11 +1250,17 @@ export class QwenAgentManager {
} }
} }
const newSessionId = this.connection.currentSessionId; const newSessionId = this.connection.currentSessionId;
console.log( this.consoleLog(
'[QwenAgentManager] New session created with ID:', '[QwenAgentManager] New session created with ID:',
newSessionId, newSessionId,
); );
return newSessionId; return newSessionId;
} finally {
this.sessionCreateInFlight = null;
}
})();
return this.sessionCreateInFlight;
} }
/** /**
@@ -1285,7 +1276,7 @@ export class QwenAgentManager {
* Cancel current prompt * Cancel current prompt
*/ */
async cancelCurrentPrompt(): Promise<void> { async cancelCurrentPrompt(): Promise<void> {
console.log('[QwenAgentManager] Cancelling current prompt'); this.consoleLog('[QwenAgentManager] Cancelling current prompt');
await this.connection.cancelSession(); await this.connection.cancelSession();
} }

View File

@@ -6,6 +6,11 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
type ConsoleLogger = (...args: unknown[]) => void;
// Shared console logger instance, initialized during extension activation.
let sharedConsoleLogger: ConsoleLogger = () => {};
export function createLogger( export function createLogger(
context: vscode.ExtensionContext, context: vscode.ExtensionContext,
logger: vscode.OutputChannel, logger: vscode.OutputChannel,
@@ -16,3 +21,40 @@ export function createLogger(
} }
}; };
} }
/**
* Creates a dev-only logger that writes to the VS Code console (Developer Tools).
*/
export function createConsoleLogger(
context: vscode.ExtensionContext,
scope?: string,
): ConsoleLogger {
return (...args: unknown[]) => {
if (context.extensionMode !== vscode.ExtensionMode.Development) {
return;
}
if (scope) {
console.log(`[${scope}]`, ...args);
return;
}
console.log(...args);
};
}
/**
* Initialize the shared console logger so other modules can import it without
* threading the extension context everywhere.
*/
export function initSharedConsoleLogger(
context: vscode.ExtensionContext,
scope?: string,
) {
sharedConsoleLogger = createConsoleLogger(context, scope);
}
/**
* Get the shared console logger (no-op until initialized).
*/
export function getConsoleLogger(): ConsoleLogger {
return sharedConsoleLogger;
}

View File

@@ -45,9 +45,11 @@ import { FileIcon, UserIcon } from './components/icons/index.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/acpTypes.js';
import type { PlanEntry } from '../types/chatTypes.js'; import type { PlanEntry } from '../types/chatTypes.js';
import { createWebviewConsoleLogger } from './utils/logger.js';
export const App: React.FC = () => { export const App: React.FC = () => {
const vscode = useVSCode(); const vscode = useVSCode();
const consoleLog = useMemo(() => createWebviewConsoleLogger('App'), []);
// Core hooks // Core hooks
const sessionManagement = useSessionManagement(vscode); const sessionManagement = useSessionManagement(vscode);
@@ -167,7 +169,7 @@ export const App: React.FC = () => {
}, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]); }, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]);
// Message submission // Message submission
const handleSubmit = useMessageSubmit({ const { handleSubmit: submitMessage } = useMessageSubmit({
inputText, inputText,
setInputText, setInputText,
messageHandling, messageHandling,
@@ -487,6 +489,22 @@ export const App: React.FC = () => {
setThinkingEnabled((prev) => !prev); setThinkingEnabled((prev) => !prev);
}; };
// When user sends a message after scrolling up, re-pin and jump to the bottom
const handleSubmitWithScroll = useCallback(
(e: React.FormEvent) => {
setPinnedToBottom(true);
const container = messagesContainerRef.current;
if (container) {
const top = container.scrollHeight - container.clientHeight;
container.scrollTo({ top });
}
submitMessage(e);
},
[submitMessage],
);
// Create unified message array containing all types of messages and tool calls // Create unified message array containing all types of messages and tool calls
const allMessages = useMemo< const allMessages = useMemo<
Array<{ Array<{
@@ -524,7 +542,7 @@ export const App: React.FC = () => {
); );
}, [messageHandling.messages, inProgressToolCalls, completedToolCalls]); }, [messageHandling.messages, inProgressToolCalls, completedToolCalls]);
console.log('[App] Rendering messages:', allMessages); consoleLog('[App] Rendering messages:', allMessages);
// Render all messages and tool calls // Render all messages and tool calls
const renderMessages = useCallback<() => React.ReactNode>( const renderMessages = useCallback<() => React.ReactNode>(
@@ -686,7 +704,7 @@ export const App: React.FC = () => {
onCompositionStart={() => setIsComposing(true)} onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)} onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}} onKeyDown={() => {}}
onSubmit={handleSubmit.handleSubmit} onSubmit={handleSubmitWithScroll}
onCancel={handleCancel} onCancel={handleCancel}
onToggleEditMode={handleToggleEditMode} onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking} onToggleThinking={handleToggleThinking}

View File

@@ -16,6 +16,7 @@ import { WebViewContent } from '../webview/WebViewContent.js';
import { CliInstaller } from '../cli/cliInstaller.js'; import { CliInstaller } from '../cli/cliInstaller.js';
import { getFileName } from './utils/webviewUtils.js'; import { getFileName } from './utils/webviewUtils.js';
import { authMethod, type ApprovalModeValue } from '../types/acpTypes.js'; import { authMethod, type ApprovalModeValue } from '../types/acpTypes.js';
import { createConsoleLogger } from '../utils/logger.js';
export class WebViewProvider { export class WebViewProvider {
private panelManager: PanelManager; private panelManager: PanelManager;
@@ -32,12 +33,15 @@ export class WebViewProvider {
private pendingPermissionResolve: ((optionId: string) => void) | null = null; private pendingPermissionResolve: ((optionId: string) => void) | null = null;
// Track current ACP mode id to influence permission/diff behavior // Track current ACP mode id to influence permission/diff behavior
private currentModeId: ApprovalModeValue | null = null; private currentModeId: ApprovalModeValue | null = null;
private consoleLog: (...args: unknown[]) => void;
constructor( constructor(
context: vscode.ExtensionContext, context: vscode.ExtensionContext,
private extensionUri: vscode.Uri, private extensionUri: vscode.Uri,
) { ) {
this.agentManager = new QwenAgentManager(); const agentConsoleLogger = createConsoleLogger(context, 'QwenAgentManager');
this.consoleLog = createConsoleLogger(context, 'WebViewProvider');
this.agentManager = new QwenAgentManager(agentConsoleLogger);
this.conversationStore = new ConversationStore(context); this.conversationStore = new ConversationStore(context);
this.authStateManager = AuthStateManager.getInstance(context); this.authStateManager = AuthStateManager.getInstance(context);
this.panelManager = new PanelManager(extensionUri, () => { this.panelManager = new PanelManager(extensionUri, () => {
@@ -380,7 +384,7 @@ export class WebViewProvider {
// Set up state serialization // Set up state serialization
newPanel.onDidChangeViewState(() => { newPanel.onDidChangeViewState(() => {
console.log( this.consoleLog(
'[WebViewProvider] Panel view state changed, triggering serialization check', '[WebViewProvider] Panel view state changed, triggering serialization check',
); );
}); });
@@ -510,7 +514,7 @@ export class WebViewProvider {
} }
// Attempt to restore authentication state and initialize connection // Attempt to restore authentication state and initialize connection
console.log( this.consoleLog(
'[WebViewProvider] Attempting to restore auth state and connection...', '[WebViewProvider] Attempting to restore auth state and connection...',
); );
await this.attemptAuthStateRestoration(); await this.attemptAuthStateRestoration();
@@ -532,23 +536,26 @@ export class WebViewProvider {
workingDir, workingDir,
authMethod, authMethod,
); );
console.log('[WebViewProvider] Has valid cached auth:', hasValidAuth); this.consoleLog(
'[WebViewProvider] Has valid cached auth:',
hasValidAuth,
);
if (hasValidAuth) { if (hasValidAuth) {
console.log( this.consoleLog(
'[WebViewProvider] Valid auth found, attempting connection...', '[WebViewProvider] Valid auth found, attempting connection...',
); );
// Try to connect with cached auth // Try to connect with cached auth
await this.initializeAgentConnection(); await this.initializeAgentConnection();
} else { } else {
console.log( this.consoleLog(
'[WebViewProvider] No valid auth found, rendering empty conversation', '[WebViewProvider] No valid auth found, rendering empty conversation',
); );
// Render the chat UI immediately without connecting // Render the chat UI immediately without connecting
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
} }
} else { } else {
console.log( this.consoleLog(
'[WebViewProvider] No auth state manager, rendering empty conversation', '[WebViewProvider] No auth state manager, rendering empty conversation',
); );
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
@@ -565,14 +572,24 @@ export class WebViewProvider {
* Can be called from show() or via /login command * Can be called from show() or via /login command
*/ */
async initializeAgentConnection(): Promise<void> { async initializeAgentConnection(): Promise<void> {
return AuthStateManager.runExclusiveAuth(() =>
this.doInitializeAgentConnection(),
);
}
/**
* Internal: perform actual connection/initialization (no auth locking).
*/
private async doInitializeAgentConnection(): Promise<void> {
const run = async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
console.log( this.consoleLog(
'[WebViewProvider] Starting initialization, workingDir:', '[WebViewProvider] Starting initialization, workingDir:',
workingDir, workingDir,
); );
console.log( this.consoleLog(
'[WebViewProvider] AuthStateManager available:', '[WebViewProvider] AuthStateManager available:',
!!this.authStateManager, !!this.authStateManager,
); );
@@ -581,10 +598,13 @@ export class WebViewProvider {
const cliDetection = await CliDetector.detectQwenCli(); const cliDetection = await CliDetector.detectQwenCli();
if (!cliDetection.isInstalled) { if (!cliDetection.isInstalled) {
console.log( this.consoleLog(
'[WebViewProvider] Qwen CLI not detected, skipping agent connection', '[WebViewProvider] Qwen CLI not detected, skipping agent connection',
); );
console.log('[WebViewProvider] CLI detection error:', cliDetection.error); this.consoleLog(
'[WebViewProvider] CLI detection error:',
cliDetection.error,
);
// Show VSCode notification with installation option // Show VSCode notification with installation option
await CliInstaller.promptInstallation(); await CliInstaller.promptInstallation();
@@ -592,20 +612,20 @@ export class WebViewProvider {
// Initialize empty conversation (can still browse history) // Initialize empty conversation (can still browse history)
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
} else { } else {
console.log( this.consoleLog(
'[WebViewProvider] Qwen CLI detected, attempting connection...', '[WebViewProvider] Qwen CLI detected, attempting connection...',
); );
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath); this.consoleLog('[WebViewProvider] CLI path:', cliDetection.cliPath);
console.log('[WebViewProvider] CLI version:', cliDetection.version); this.consoleLog('[WebViewProvider] CLI version:', cliDetection.version);
try { try {
console.log('[WebViewProvider] Connecting to agent...'); this.consoleLog('[WebViewProvider] Connecting to agent...');
console.log( this.consoleLog(
'[WebViewProvider] Using authStateManager:', '[WebViewProvider] Using authStateManager:',
!!this.authStateManager, !!this.authStateManager,
); );
const authInfo = await this.authStateManager.getAuthInfo(); const authInfo = await this.authStateManager.getAuthInfo();
console.log('[WebViewProvider] Auth cache status:', authInfo); this.consoleLog('[WebViewProvider] Auth cache status:', authInfo);
// Pass the detected CLI path to ensure we use the correct installation // Pass the detected CLI path to ensure we use the correct installation
await this.agentManager.connect( await this.agentManager.connect(
@@ -613,7 +633,7 @@ export class WebViewProvider {
this.authStateManager, this.authStateManager,
cliDetection.cliPath, cliDetection.cliPath,
); );
console.log('[WebViewProvider] Agent connected successfully'); this.consoleLog('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true; this.agentInitialized = true;
// Load messages from the current Qwen session // Load messages from the current Qwen session
@@ -638,11 +658,15 @@ export class WebViewProvider {
this.sendMessageToWebView({ this.sendMessageToWebView({
type: 'agentConnectionError', type: 'agentConnectionError',
data: { data: {
message: _error instanceof Error ? _error.message : String(_error), message:
_error instanceof Error ? _error.message : String(_error),
}, },
}); });
} }
} }
};
return run();
} }
/** /**
@@ -650,16 +674,17 @@ export class WebViewProvider {
* Called when user explicitly uses /login command * Called when user explicitly uses /login command
*/ */
async forceReLogin(): Promise<void> { async forceReLogin(): Promise<void> {
console.log('[WebViewProvider] Force re-login requested'); this.consoleLog('[WebViewProvider] Force re-login requested');
console.log( this.consoleLog(
'[WebViewProvider] Current authStateManager:', '[WebViewProvider] Current authStateManager:',
!!this.authStateManager, !!this.authStateManager,
); );
await vscode.window.withProgress( // If a login/connection flow is already running, reuse it to avoid double prompts
const p = Promise.resolve(
vscode.window.withProgress(
{ {
location: vscode.ProgressLocation.Notification, location: vscode.ProgressLocation.Notification,
title: 'Logging in to Qwen Code... ',
cancellable: false, cancellable: false,
}, },
async (progress) => { async (progress) => {
@@ -669,18 +694,23 @@ export class WebViewProvider {
// Clear existing auth cache // Clear existing auth cache
if (this.authStateManager) { if (this.authStateManager) {
await this.authStateManager.clearAuthState(); await this.authStateManager.clearAuthState();
console.log('[WebViewProvider] Auth cache cleared'); this.consoleLog('[WebViewProvider] Auth cache cleared');
} else { } else {
console.log('[WebViewProvider] No authStateManager to clear'); this.consoleLog('[WebViewProvider] No authStateManager to clear');
} }
// Disconnect existing connection if any // Disconnect existing connection if any
if (this.agentInitialized) { if (this.agentInitialized) {
try { try {
this.agentManager.disconnect(); this.agentManager.disconnect();
console.log('[WebViewProvider] Existing connection disconnected'); this.consoleLog(
'[WebViewProvider] Existing connection disconnected',
);
} catch (_error) { } catch (_error) {
console.log('[WebViewProvider] Error disconnecting:', _error); this.consoleLog(
'[WebViewProvider] Error disconnecting:',
_error,
);
} }
this.agentInitialized = false; this.agentInitialized = false;
} }
@@ -693,8 +723,8 @@ export class WebViewProvider {
}); });
// Reinitialize connection (will trigger fresh authentication) // Reinitialize connection (will trigger fresh authentication)
await this.initializeAgentConnection(); await this.doInitializeAgentConnection();
console.log( this.consoleLog(
'[WebViewProvider] Force re-login completed successfully', '[WebViewProvider] Force re-login completed successfully',
); );
@@ -703,7 +733,9 @@ export class WebViewProvider {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
await this.authStateManager.saveAuthState(workingDir, authMethod); await this.authStateManager.saveAuthState(workingDir, authMethod);
console.log('[WebViewProvider] Auth state saved after re-login'); this.consoleLog(
'[WebViewProvider] Auth state saved after re-login',
);
} }
// Send success notification to WebView // Send success notification to WebView
@@ -729,7 +761,10 @@ export class WebViewProvider {
throw _error; throw _error;
} }
}, },
),
); );
return AuthStateManager.runExclusiveAuth(() => p);
} }
/** /**
@@ -737,15 +772,15 @@ export class WebViewProvider {
* Called when restoring WebView after VSCode restart * Called when restoring WebView after VSCode restart
*/ */
async refreshConnection(): Promise<void> { async refreshConnection(): Promise<void> {
console.log('[WebViewProvider] Refresh connection requested'); this.consoleLog('[WebViewProvider] Refresh connection requested');
// Disconnect existing connection if any // Disconnect existing connection if any
if (this.agentInitialized) { if (this.agentInitialized) {
try { try {
this.agentManager.disconnect(); this.agentManager.disconnect();
console.log('[WebViewProvider] Existing connection disconnected'); this.consoleLog('[WebViewProvider] Existing connection disconnected');
} catch (_error) { } catch (_error) {
console.log('[WebViewProvider] Error disconnecting:', _error); this.consoleLog('[WebViewProvider] Error disconnecting:', _error);
} }
this.agentInitialized = false; this.agentInitialized = false;
} }
@@ -756,7 +791,7 @@ export class WebViewProvider {
// Reinitialize connection (will use cached auth if available) // Reinitialize connection (will use cached auth if available)
try { try {
await this.initializeAgentConnection(); await this.initializeAgentConnection();
console.log( this.consoleLog(
'[WebViewProvider] Connection refresh completed successfully', '[WebViewProvider] Connection refresh completed successfully',
); );
@@ -786,25 +821,26 @@ export class WebViewProvider {
*/ */
private async loadCurrentSessionMessages(): Promise<void> { private async loadCurrentSessionMessages(): Promise<void> {
try { try {
console.log( this.consoleLog(
'[WebViewProvider] Initializing with new session (skipping restoration)', '[WebViewProvider] Initializing with new session (skipping restoration)',
); );
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
// Skip session restoration entirely and create a new session directly // avoid creating another session if connect() already created one.
if (!this.agentManager.currentSessionId) {
try { try {
await this.agentManager.createNewSession( await this.agentManager.createNewSession(
workingDir, workingDir,
this.authStateManager, this.authStateManager,
); );
console.log('[WebViewProvider] ACP session created successfully'); this.consoleLog('[WebViewProvider] ACP session created successfully');
// Ensure auth state is saved after successful session creation // Ensure auth state is saved after successful session creation
if (this.authStateManager) { if (this.authStateManager) {
await this.authStateManager.saveAuthState(workingDir, authMethod); await this.authStateManager.saveAuthState(workingDir, authMethod);
console.log( this.consoleLog(
'[WebViewProvider] Auth state saved after session creation', '[WebViewProvider] Auth state saved after session creation',
); );
} }
@@ -817,6 +853,11 @@ export class WebViewProvider {
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`, `Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
); );
} }
} else {
this.consoleLog(
'[WebViewProvider] Existing ACP session detected, skipping new session creation',
);
}
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
} catch (_error) { } catch (_error) {
@@ -837,14 +878,14 @@ export class WebViewProvider {
*/ */
private async initializeEmptyConversation(): Promise<void> { private async initializeEmptyConversation(): Promise<void> {
try { try {
console.log('[WebViewProvider] Initializing empty conversation'); this.consoleLog('[WebViewProvider] Initializing empty conversation');
const newConv = await this.conversationStore.createConversation(); const newConv = await this.conversationStore.createConversation();
this.messageHandler.setCurrentConversationId(newConv.id); this.messageHandler.setCurrentConversationId(newConv.id);
this.sendMessageToWebView({ this.sendMessageToWebView({
type: 'conversationLoaded', type: 'conversationLoaded',
data: newConv, data: newConv,
}); });
console.log( this.consoleLog(
'[WebViewProvider] Empty conversation initialized:', '[WebViewProvider] Empty conversation initialized:',
this.messageHandler.getCurrentConversationId(), this.messageHandler.getCurrentConversationId(),
); );
@@ -968,7 +1009,7 @@ export class WebViewProvider {
* Call this when auth cache is cleared to force re-authentication * Call this when auth cache is cleared to force re-authentication
*/ */
resetAgentState(): void { resetAgentState(): void {
console.log('[WebViewProvider] Resetting agent state'); this.consoleLog('[WebViewProvider] Resetting agent state');
this.agentInitialized = false; this.agentInitialized = false;
// Disconnect existing connection // Disconnect existing connection
this.agentManager.disconnect(); this.agentManager.disconnect();
@@ -978,7 +1019,7 @@ export class WebViewProvider {
* Clear authentication cache for this WebViewProvider instance * Clear authentication cache for this WebViewProvider instance
*/ */
async clearAuthCache(): Promise<void> { async clearAuthCache(): Promise<void> {
console.log('[WebViewProvider] Clearing auth cache for this instance'); this.consoleLog('[WebViewProvider] Clearing auth cache for this instance');
if (this.authStateManager) { if (this.authStateManager) {
await this.authStateManager.clearAuthState(); await this.authStateManager.clearAuthState();
this.resetAgentState(); this.resetAgentState();
@@ -990,8 +1031,8 @@ export class WebViewProvider {
* This sets up the panel with all event listeners * This sets up the panel with all event listeners
*/ */
async restorePanel(panel: vscode.WebviewPanel): Promise<void> { async restorePanel(panel: vscode.WebviewPanel): Promise<void> {
console.log('[WebViewProvider] Restoring WebView panel'); this.consoleLog('[WebViewProvider] Restoring WebView panel');
console.log( this.consoleLog(
'[WebViewProvider] Current authStateManager in restore:', '[WebViewProvider] Current authStateManager in restore:',
!!this.authStateManager, !!this.authStateManager,
); );
@@ -1122,10 +1163,10 @@ export class WebViewProvider {
// Capture the tab reference on restore // Capture the tab reference on restore
this.panelManager.captureTab(); this.panelManager.captureTab();
console.log('[WebViewProvider] Panel restored successfully'); this.consoleLog('[WebViewProvider] Panel restored successfully');
// Attempt to restore authentication state and initialize connection // Attempt to restore authentication state and initialize connection
console.log( this.consoleLog(
'[WebViewProvider] Attempting to restore auth state and connection after restore...', '[WebViewProvider] Attempting to restore auth state and connection after restore...',
); );
await this.attemptAuthStateRestoration(); await this.attemptAuthStateRestoration();
@@ -1139,12 +1180,12 @@ export class WebViewProvider {
conversationId: string | null; conversationId: string | null;
agentInitialized: boolean; agentInitialized: boolean;
} { } {
console.log('[WebViewProvider] Getting state for serialization'); this.consoleLog('[WebViewProvider] Getting state for serialization');
console.log( this.consoleLog(
'[WebViewProvider] Current conversationId:', '[WebViewProvider] Current conversationId:',
this.messageHandler.getCurrentConversationId(), this.messageHandler.getCurrentConversationId(),
); );
console.log( this.consoleLog(
'[WebViewProvider] Current agentInitialized:', '[WebViewProvider] Current agentInitialized:',
this.agentInitialized, this.agentInitialized,
); );
@@ -1152,7 +1193,7 @@ export class WebViewProvider {
conversationId: this.messageHandler.getCurrentConversationId(), conversationId: this.messageHandler.getCurrentConversationId(),
agentInitialized: this.agentInitialized, agentInitialized: this.agentInitialized,
}; };
console.log('[WebViewProvider] Returning state:', state); this.consoleLog('[WebViewProvider] Returning state:', state);
return state; return state;
} }
@@ -1170,10 +1211,10 @@ export class WebViewProvider {
conversationId: string | null; conversationId: string | null;
agentInitialized: boolean; agentInitialized: boolean;
}): void { }): void {
console.log('[WebViewProvider] Restoring state:', state); this.consoleLog('[WebViewProvider] Restoring state:', state);
this.messageHandler.setCurrentConversationId(state.conversationId); this.messageHandler.setCurrentConversationId(state.conversationId);
this.agentInitialized = state.agentInitialized; this.agentInitialized = state.agentInitialized;
console.log( this.consoleLog(
'[WebViewProvider] State restored. agentInitialized:', '[WebViewProvider] State restored. agentInitialized:',
this.agentInitialized, this.agentInitialized,
); );
@@ -1206,8 +1247,6 @@ export class WebViewProvider {
type: 'conversationCleared', type: 'conversationCleared',
data: {}, data: {},
}); });
console.log('[WebViewProvider] New session created successfully');
} catch (_error) { } catch (_error) {
console.error('[WebViewProvider] Failed to create new session:', _error); console.error('[WebViewProvider] Failed to create new session:', _error);
vscode.window.showErrorMessage(`Failed to create new session: ${_error}`); vscode.window.showErrorMessage(`Failed to create new session: ${_error}`);

View File

@@ -291,6 +291,41 @@ export class SessionMessageHandler extends BaseMessageHandler {
return; return;
} }
// Ensure an ACP session exists before sending prompt
if (!this.agentManager.currentSessionId) {
try {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
await this.agentManager.createNewSession(workingDir);
} catch (createErr) {
console.error(
'[SessionMessageHandler] Failed to create session before sending message:',
createErr,
);
const errorMsg =
createErr instanceof Error ? createErr.message : String(createErr);
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)')
) {
const result = await vscode.window.showWarningMessage(
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwen-code.login');
}
}
return;
}
vscode.window.showErrorMessage(`Failed to create session: ${errorMsg}`);
return;
}
}
// Send to agent // Send to agent
try { try {
this.resetStreamContent(); this.resetStreamContent();

View File

@@ -14,6 +14,7 @@ import type {
import type { ToolCallUpdate } from '../../types/chatTypes.js'; import type { ToolCallUpdate } from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../types/acpTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js';
import { createWebviewConsoleLogger } from '../utils/logger.js';
interface UseWebViewMessagesProps { interface UseWebViewMessagesProps {
// Session management // Session management
@@ -129,6 +130,7 @@ export const useWebViewMessages = ({
}: UseWebViewMessagesProps) => { }: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host // VS Code API for posting messages back to the extension host
const vscode = useVSCode(); const vscode = useVSCode();
const consoleLog = useRef(createWebviewConsoleLogger('WebViewMessages'));
// Track active long-running tool calls (execute/bash/command) so we can // Track active long-running tool calls (execute/bash/command) so we can
// keep the bottom "waiting" message visible until all of them complete. // keep the bottom "waiting" message visible until all of them complete.
const activeExecToolCallsRef = useRef<Set<string>>(new Set()); const activeExecToolCallsRef = useRef<Set<string>>(new Set());
@@ -227,40 +229,26 @@ export const useWebViewMessages = ({
break; break;
} }
// case 'cliNotInstalled': { case 'agentConnected': {
// // Show CLI not installed message // Agent connected successfully; clear any pending spinner
// const errorMsg = handlers.messageHandling.clearWaitingForResponse();
// (message?.data?.error as string) || break;
// 'Qwen Code CLI is not installed. Please install it to enable full functionality.'; }
// handlers.messageHandling.addMessage({ case 'agentConnectionError': {
// role: 'assistant', // Agent connection failed; surface the error and unblock the UI
// content: `Qwen CLI is not installed. Please install it to enable full functionality.\n\nError: ${errorMsg}\n\nInstallation instructions:\n1. Install via npm:\n npm install -g @qwen-code/qwen-code@latest\n\n2. After installation, reload VS Code or restart the extension.`, handlers.messageHandling.clearWaitingForResponse();
// timestamp: Date.now(), const errorMsg =
// }); (message?.data?.message as string) ||
// break; 'Failed to connect to Qwen agent.';
// }
// case 'agentConnected': { handlers.messageHandling.addMessage({
// // Agent connected successfully role: 'assistant',
// handlers.messageHandling.clearWaitingForResponse(); content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`,
// break; timestamp: Date.now(),
// } });
break;
// case 'agentConnectionError': { }
// // Agent connection failed
// handlers.messageHandling.clearWaitingForResponse();
// const errorMsg =
// (message?.data?.message as string) ||
// 'Failed to connect to Qwen agent.';
// handlers.messageHandling.addMessage({
// role: 'assistant',
// content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`,
// timestamp: Date.now(),
// });
// break;
// }
case 'loginError': { case 'loginError': {
// Clear loading state and show error notice // Clear loading state and show error notice
@@ -765,7 +753,10 @@ export const useWebViewMessages = ({
path: string; path: string;
}>; }>;
if (files) { if (files) {
console.log('[WebView] Received workspaceFiles:', files.length); consoleLog.current(
'[WebView] Received workspaceFiles:',
files.length,
);
handlers.fileContext.setWorkspaceFiles(files); handlers.fileContext.setWorkspaceFiles(files);
} }
break; break;

View File

@@ -5,7 +5,6 @@
*/ */
/* Import component styles */ /* Import component styles */
@import '../components/messages/Assistant/AssistantMessage.css';
@import './timeline.css'; @import './timeline.css';
@import '../components/messages/MarkdownRenderer/MarkdownRenderer.css'; @import '../components/messages/MarkdownRenderer/MarkdownRenderer.css';

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Creates a dev-only console logger for the WebView bundle.
* In production builds it becomes a no-op to avoid noisy logs.
*/
export function createWebviewConsoleLogger(scope?: string) {
return (...args: unknown[]) => {
const env = (globalThis as { process?: { env?: Record<string, string> } })
.process?.env;
const isProduction = env?.NODE_ENV === 'production';
if (isProduction) {
return;
}
if (scope) {
console.log(`[${scope}]`, ...args);
return;
}
console.log(...args);
};
}

View File

@@ -1,92 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Minimal line-diff utility for webview previews.
*
* This is a lightweight LCS-based algorithm to compute add/remove operations
* between two texts. It intentionally avoids heavy dependencies and is
* sufficient for rendering a compact preview inside the chat.
*/
export type DiffOp =
| { type: 'add'; line: string; newIndex: number }
| { type: 'remove'; line: string; oldIndex: number };
/**
* Compute a minimal line-diff (added/removed only).
* - Equal lines are omitted from output by design (we only preview changes).
* - Order of operations follows the new text progression so the preview feels natural.
*/
export function computeLineDiff(
oldText: string | null | undefined,
newText: string | undefined,
): DiffOp[] {
const a = (oldText || '').split('\n');
const b = (newText || '').split('\n');
const n = a.length;
const m = b.length;
// Build LCS DP table
const dp: number[][] = Array.from({ length: n + 1 }, () =>
new Array(m + 1).fill(0),
);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
if (a[i] === b[j]) {
dp[i][j] = dp[i + 1][j + 1] + 1;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
// Walk to produce operations
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
// remove a[i]
ops.push({ type: 'remove', line: a[i], oldIndex: i });
i++;
} else {
// add b[j]
ops.push({ type: 'add', line: b[j], newIndex: j });
j++;
}
}
// Remaining tails
while (i < n) {
ops.push({ type: 'remove', line: a[i], oldIndex: i });
i++;
}
while (j < m) {
ops.push({ type: 'add', line: b[j], newIndex: j });
j++;
}
return ops;
}
/**
* Truncate a long list of operations for preview purposes.
* Keeps first `head` and last `tail` operations, inserting a gap marker.
*/
export function truncateOps<T>(
ops: T[],
head = 120,
tail = 80,
): { items: T[]; truncated: boolean; omitted: number } {
if (ops.length <= head + tail) {
return { items: ops, truncated: false, omitted: 0 };
}
const items = [...ops.slice(0, head), ...ops.slice(-tail)];
return { items, truncated: true, omitted: ops.length - head - tail };
}