fix(vscode-ide-companion): Interactive unification of first login and login

This commit is contained in:
yiliang114
2025-11-30 22:26:04 +08:00
parent b1e74e5732
commit 1acc24bc17
6 changed files with 273 additions and 382 deletions

View File

@@ -686,7 +686,38 @@ export class QwenAgentManager {
); );
} }
// Try to create a new ACP session. If the backend asks for auth despite our
// cached flag (e.g. fresh process or expired tokens), re-authenticate and retry.
try {
await this.connection.newSession(workingDir); await this.connection.newSession(workingDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const requiresAuth =
msg.includes('Authentication required') ||
msg.includes('(code: -32000)');
if (requiresAuth) {
console.warn(
'[QwenAgentManager] session/new requires authentication. Retrying with authenticate...',
);
try {
await this.connection.authenticate(authMethod);
// Persist auth cache so subsequent calls can skip the web flow.
if (effectiveAuth) {
await effectiveAuth.saveAuthState(workingDir, authMethod);
}
await this.connection.newSession(workingDir);
} catch (reauthErr) {
// Clear potentially stale cache on failure and rethrow
if (effectiveAuth) {
await effectiveAuth.clearAuthState();
}
throw reauthErr;
}
} else {
throw err;
}
}
const newSessionId = this.connection.currentSessionId; const newSessionId = this.connection.currentSessionId;
console.log( console.log(
'[QwenAgentManager] New session created with ID:', '[QwenAgentManager] New session created with ID:',

View File

@@ -39,10 +39,7 @@ export class QwenConnectionHandler {
cliPath?: string, cliPath?: string,
): Promise<void> { ): Promise<void> {
const connectId = Date.now(); const connectId = Date.now();
console.log(`\n========================================`);
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`); console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
console.log(`[QwenAgentManager] Call stack:\n${new Error().stack}`);
console.log(`========================================\n`);
// Check CLI version and features // Check CLI version and features
const cliVersionManager = CliVersionManager.getInstance(); const cliVersionManager = CliVersionManager.getInstance();
@@ -166,7 +163,9 @@ export class QwenConnectionHandler {
// Create new session if unable to restore // Create new session if unable to restore
if (!sessionRestored) { if (!sessionRestored) {
console.log('[QwenAgentManager] Creating new session...'); console.log(
'[QwenAgentManager] no sessionRestored, Creating new session...',
);
// Check if we have valid cached authentication // Check if we have valid cached authentication
let hasValidAuth = false; let hasValidAuth = false;
@@ -217,7 +216,13 @@ export class QwenConnectionHandler {
console.log( console.log(
'[QwenAgentManager] Creating new session after authentication...', '[QwenAgentManager] Creating new session after authentication...',
); );
await this.newSessionWithRetry(connection, workingDir, 3); await this.newSessionWithRetry(
connection,
workingDir,
3,
authMethod,
authStateManager,
);
console.log('[QwenAgentManager] New session created successfully'); console.log('[QwenAgentManager] New session created successfully');
// Ensure auth state is saved (prevent repeated authentication) // Ensure auth state is saved (prevent repeated authentication)
@@ -257,6 +262,8 @@ export class QwenConnectionHandler {
connection: AcpConnection, connection: AcpConnection,
workingDir: string, workingDir: string,
maxRetries: number, maxRetries: number,
authMethod: string,
authStateManager?: AuthStateManager,
): Promise<void> { ): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
@@ -274,6 +281,38 @@ export class QwenConnectionHandler {
errorMessage, errorMessage,
); );
// If the backend reports that authentication is required, try to
// authenticate on-the-fly once and retry without waiting.
const requiresAuth =
errorMessage.includes('Authentication required') ||
errorMessage.includes('(code: -32000)');
if (requiresAuth) {
console.log(
'[QwenAgentManager] Backend requires authentication. Authenticating and retrying session/new...',
);
try {
await connection.authenticate(authMethod);
if (authStateManager) {
await authStateManager.saveAuthState(workingDir, authMethod);
}
// 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,
);
if (authStateManager) {
await authStateManager.clearAuthState();
}
// Fall through to retry logic below
}
}
if (attempt === maxRetries) { if (attempt === maxRetries) {
throw new Error( throw new Error(
`Session creation failed after ${maxRetries} attempts: ${errorMessage}`, `Session creation failed after ${maxRetries} attempts: ${errorMessage}`,

View File

@@ -60,10 +60,16 @@ export const App: React.FC = () => {
toolCall: PermissionToolCall; toolCall: PermissionToolCall;
} | null>(null); } | null>(null);
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]); const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(
null,
) as React.RefObject<HTMLDivElement>;
// Scroll container for message list; used to keep the view anchored to the latest content // Scroll container for message list; used to keep the view anchored to the latest content
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(
const inputFieldRef = useRef<HTMLDivElement>(null); null,
) as React.RefObject<HTMLDivElement>;
const inputFieldRef = useRef<HTMLDivElement>(
null,
) as React.RefObject<HTMLDivElement>;
const [showBanner, setShowBanner] = useState(true); const [showBanner, setShowBanner] = useState(true);
const [editMode, setEditMode] = useState<EditMode>('ask'); const [editMode, setEditMode] = useState<EditMode>('ask');
const [thinkingEnabled, setThinkingEnabled] = useState(false); const [thinkingEnabled, setThinkingEnabled] = useState(false);

View File

@@ -309,58 +309,14 @@ export class WebViewProvider {
}); });
} }
// // Initialize empty conversation immediately for fast UI rendering // Lazy initialization: Do not attempt to connect/auth on WebView show.
// await this.initializeEmptyConversation(); // Render the chat UI immediately; we will connect/login on-demand when the
// user sends a message or requests a session action.
// // Perform background CLI detection and connection without blocking UI
// this.performBackgroundInitialization();
// Smart login restore: Check if we have valid cached auth and restore connection if available
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
const config = vscode.workspace.getConfiguration('qwenCode');
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
// Check if we have valid cached authentication
let hasValidAuth = false;
if (this.authStateManager) {
hasValidAuth = await this.authStateManager.hasValidAuth(
workingDir,
authMethod,
);
console.log( console.log(
'[WebViewProvider] Has valid cached auth on show:', '[WebViewProvider] Lazy init: rendering empty conversation only',
hasValidAuth,
); );
}
if (hasValidAuth && !this.agentInitialized) {
console.log(
'[WebViewProvider] Found valid cached auth, attempting to restore connection...',
);
try {
await this.initializeAgentConnection();
console.log('[WebViewProvider] Connection restored successfully');
} catch (error) {
console.error('[WebViewProvider] Failed to restore connection:', error);
// Fall back to empty conversation if restore fails
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
} }
} else if (this.agentInitialized) {
console.log(
'[WebViewProvider] Agent already initialized, reusing existing connection',
);
// Reload current session messages
await this.loadCurrentSessionMessages();
} else {
console.log(
'[WebViewProvider] No valid cached auth or agent already initialized, showing empty conversation',
);
// Just initialize empty conversation for the UI
await this.initializeEmptyConversation();
}
}
/** /**
* Initialize agent connection and session * Initialize agent connection and session
@@ -459,124 +415,6 @@ export class WebViewProvider {
} }
} }
/**
* Perform background initialization without blocking UI
* This method runs CLI detection and connection in the background
*/
private async performBackgroundInitialization(): Promise<void> {
try {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
const config = vscode.workspace.getConfiguration('qwenCode');
const qwenEnabled = config.get<boolean>('qwen.enabled', true);
if (qwenEnabled) {
// Check if we have valid cached authentication
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
let hasValidAuth = false;
if (this.authStateManager) {
hasValidAuth = await this.authStateManager.hasValidAuth(
workingDir,
authMethod,
);
console.log(
'[WebViewProvider] Has valid cached auth in background init:',
hasValidAuth,
);
}
// Perform CLI detection in background
const cliDetection = await CliDetector.detectQwenCli();
if (!cliDetection.isInstalled) {
console.log(
'[WebViewProvider] Qwen CLI not detected in background check',
);
console.log(
'[WebViewProvider] CLI detection error:',
cliDetection.error,
);
// Notify webview that CLI is not installed
this.sendMessageToWebView({
type: 'cliNotInstalled',
data: {
error: cliDetection.error,
},
});
} else {
console.log(
'[WebViewProvider] Qwen CLI detected in background check, attempting connection...',
);
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath);
console.log('[WebViewProvider] CLI version:', cliDetection.version);
if (hasValidAuth && !this.agentInitialized) {
console.log(
'[WebViewProvider] Found valid cached auth, attempting to restore connection in background...',
);
try {
// Pass the detected CLI path to ensure we use the correct installation
await this.agentManager.connect(
workingDir,
this.authStateManager,
cliDetection.cliPath,
);
console.log(
'[WebViewProvider] Connection restored successfully in background',
);
this.agentInitialized = true;
// Load messages from the current Qwen session
await this.loadCurrentSessionMessages();
// Notify webview that agent is connected
this.sendMessageToWebView({
type: 'agentConnected',
data: {},
});
} catch (error) {
console.error(
'[WebViewProvider] Failed to restore connection in background:',
error,
);
// Clear auth cache on error
await this.authStateManager.clearAuthState();
// Notify webview that agent connection failed
this.sendMessageToWebView({
type: 'agentConnectionError',
data: {
message:
error instanceof Error ? error.message : String(error),
},
});
}
} else if (this.agentInitialized) {
console.log(
'[WebViewProvider] Agent already initialized, no need to reconnect in background',
);
} else {
console.log(
'[WebViewProvider] No valid cached auth, skipping background connection',
);
}
}
} else {
console.log(
'[WebViewProvider] Qwen agent is disabled in settings (background)',
);
}
} catch (error) {
console.error(
'[WebViewProvider] Background initialization failed:',
error,
);
}
}
/** /**
* Force re-login by clearing auth cache and reconnecting * Force re-login by clearing auth cache and reconnecting
* Called when user explicitly uses /login command * Called when user explicitly uses /login command
@@ -588,6 +426,16 @@ export class WebViewProvider {
!!this.authStateManager, !!this.authStateManager,
); );
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Logging in to Qwen Code... ',
cancellable: false,
},
async (progress) => {
try {
progress.report({ message: 'Preparing sign-in...' });
// Clear existing auth cache // Clear existing auth cache
if (this.authStateManager) { if (this.authStateManager) {
await this.authStateManager.clearAuthState(); await this.authStateManager.clearAuthState();
@@ -608,12 +456,17 @@ export class WebViewProvider {
} }
// Wait a moment for cleanup to complete // Wait a moment for cleanup to complete
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 300));
progress.report({
message: 'Connecting to CLI and starting sign-in...',
});
// Reinitialize connection (will trigger fresh authentication) // Reinitialize connection (will trigger fresh authentication)
try {
await this.initializeAgentConnection(); await this.initializeAgentConnection();
console.log('[WebViewProvider] Force re-login completed successfully'); console.log(
'[WebViewProvider] Force re-login completed successfully',
);
// Send success notification to WebView // Send success notification to WebView
this.sendMessageToWebView({ this.sendMessageToWebView({
@@ -637,6 +490,8 @@ export class WebViewProvider {
throw error; throw error;
} }
},
);
} }
/** /**
@@ -873,64 +728,12 @@ export class WebViewProvider {
console.log('[WebViewProvider] Panel restored successfully'); console.log('[WebViewProvider] Panel restored successfully');
// TODO: // Lazy init on restore as well: do not auto-connect; just render UI.
// await this.initializeEmptyConversation();
// // Perform background initialization without blocking UI
// this.performBackgroundInitialization();
// Smart login restore: Check if we have valid cached auth and restore connection if available
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
const config = vscode.workspace.getConfiguration('qwenCode');
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
// Check if we have valid cached authentication
let hasValidAuth = false;
if (this.authStateManager) {
hasValidAuth = await this.authStateManager.hasValidAuth(
workingDir,
authMethod,
);
console.log( console.log(
'[WebViewProvider] Has valid cached auth on restore:', '[WebViewProvider] Lazy restore: rendering empty conversation only',
hasValidAuth,
); );
}
if (hasValidAuth && !this.agentInitialized) {
console.log(
'[WebViewProvider] Found valid cached auth, attempting to restore connection...',
);
try {
await this.initializeAgentConnection();
console.log('[WebViewProvider] Connection restored successfully');
} catch (error) {
console.error('[WebViewProvider] Failed to restore connection:', error);
// Fall back to empty conversation if restore fails
await this.initializeEmptyConversation(); await this.initializeEmptyConversation();
} }
} else if (this.agentInitialized) {
console.log(
'[WebViewProvider] Agent already initialized, refreshing connection...',
);
try {
await this.refreshConnection();
console.log('[WebViewProvider] Connection refreshed successfully');
} catch (error) {
console.error('[WebViewProvider] Failed to refresh connection:', error);
// Fall back to empty conversation if refresh fails
this.agentInitialized = false;
await this.initializeEmptyConversation();
}
} else {
console.log(
'[WebViewProvider] No valid cached auth or agent already initialized, showing empty conversation',
);
// Just initialize empty conversation for the UI
await this.initializeEmptyConversation();
}
}
/** /**
* Get the current state for serialization * Get the current state for serialization

View File

@@ -1,88 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// Use explicit Vitest imports instead of relying on globals.
import { describe, it, expect } from 'vitest';
import type { ToolCallData } from '../toolcalls/shared/types.js';
import { hasToolCallOutput } from '../toolcalls/shared/utils.js';
describe('Message Ordering', () => {
it('should correctly identify tool calls with output', () => {
// Test failed tool call (should show)
const failedToolCall: ToolCallData = {
toolCallId: 'test-1',
kind: 'read',
title: 'Read file',
status: 'failed',
timestamp: 1000,
};
expect(hasToolCallOutput(failedToolCall)).toBe(true);
// Test execute tool call with title (should show)
const executeToolCall: ToolCallData = {
toolCallId: 'test-2',
kind: 'execute',
title: 'ls -la',
status: 'completed',
timestamp: 2000,
};
expect(hasToolCallOutput(executeToolCall)).toBe(true);
// Test tool call with content (should show)
const contentToolCall: ToolCallData = {
toolCallId: 'test-3',
kind: 'read',
title: 'Read file',
status: 'completed',
content: [
{
type: 'content',
content: {
type: 'text',
text: 'File content',
},
},
],
timestamp: 3000,
};
expect(hasToolCallOutput(contentToolCall)).toBe(true);
// Test tool call with locations (should show)
const locationToolCall: ToolCallData = {
toolCallId: 'test-4',
kind: 'read',
title: 'Read file',
status: 'completed',
locations: [
{
path: '/path/to/file.txt',
},
],
timestamp: 4000,
};
expect(hasToolCallOutput(locationToolCall)).toBe(true);
// Test tool call with title (should show)
const titleToolCall: ToolCallData = {
toolCallId: 'test-5',
kind: 'generic',
title: 'Generic tool call',
status: 'completed',
timestamp: 5000,
};
expect(hasToolCallOutput(titleToolCall)).toBe(true);
// Test tool call without output (should not show)
const noOutputToolCall: ToolCallData = {
toolCallId: 'test-6',
kind: 'generic',
title: '',
status: 'completed',
timestamp: 6000,
};
expect(hasToolCallOutput(noOutputToolCall)).toBe(false);
});
});

View File

@@ -376,17 +376,23 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for session not found error and handle it appropriately // Check for session not found error and handle it appropriately
if ( if (
errorMsg.includes('Session not found') || errorMsg.includes('Session not found') ||
errorMsg.includes('No active ACP session') errorMsg.includes('No active ACP session') ||
errorMsg.includes('Authentication required') ||
errorMsg.includes('(code: -32000)')
) { ) {
// Clear auth cache since session is invalid // Clear auth cache since session is invalid
// Note: We would need access to authStateManager for this, but for now we'll just show login prompt // Note: We would need access to authStateManager for this, but for now we'll just show login prompt
const result = await vscode.window.showWarningMessage( const result = await vscode.window.showWarningMessage(
'Your session has expired. Please login again to continue using Qwen Code.', 'Your login has expired. Please login again to continue using Qwen Code.',
'Login Now', 'Login Now',
); );
if (result === 'Login Now') { if (result === 'Login Now') {
vscode.commands.executeCommand('qwenCode.login'); if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
}
} }
} else { } else {
vscode.window.showErrorMessage(`Error sending message: ${error}`); vscode.window.showErrorMessage(`Error sending message: ${error}`);
@@ -405,6 +411,23 @@ export class SessionMessageHandler extends BaseMessageHandler {
try { try {
console.log('[SessionMessageHandler] Creating new Qwen session...'); console.log('[SessionMessageHandler] Creating new Qwen session...');
// Ensure connection (login) before creating a new session
if (!this.agentManager.isConnected) {
const result = await vscode.window.showWarningMessage(
'You need to login before creating a new session.',
'Login Now',
);
if (result === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
}
} else {
return;
}
}
// Save current session before creating new one // Save current session before creating new one
if (this.currentConversationId && this.agentManager.isConnected) { if (this.currentConversationId && this.agentManager.isConnected) {
try { try {
@@ -450,6 +473,39 @@ export class SessionMessageHandler extends BaseMessageHandler {
try { try {
console.log('[SessionMessageHandler] Switching to session:', sessionId); console.log('[SessionMessageHandler] Switching to session:', sessionId);
// If not connected yet, offer to login or view offline
if (!this.agentManager.isConnected) {
const selection = await vscode.window.showWarningMessage(
'You are not logged in. Login now to fully restore this session, or view it offline.',
'Login Now',
'View Offline',
);
if (selection === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
}
} else if (selection === 'View Offline') {
// Show messages from local cache only
const messages =
await this.agentManager.getSessionMessages(sessionId);
this.currentConversationId = sessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
vscode.window.showInformationMessage(
'Showing cached session content. Login to interact with the AI.',
);
return;
} else {
// User dismissed; do nothing
return;
}
}
// Save current session before switching // Save current session before switching
if ( if (
this.currentConversationId && this.currentConversationId &&
@@ -489,7 +545,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
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();
// Try to load session via ACP // Try to load session via ACP (now we should be connected)
try { try {
const loadResponse = const loadResponse =
await this.agentManager.loadSessionViaAcp(sessionId); await this.agentManager.loadSessionViaAcp(sessionId);
@@ -514,6 +570,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Fallback: create new session // Fallback: create new session
const messages = await this.agentManager.getSessionMessages(sessionId); const messages = await this.agentManager.getSessionMessages(sessionId);
// If we are connected, try to create a fresh ACP session so user can interact
if (this.agentManager.isConnected) {
try { try {
const newAcpSessionId = const newAcpSessionId =
await this.agentManager.createNewSession(workingDir); await this.agentManager.createNewSession(workingDir);
@@ -535,6 +593,17 @@ export class SessionMessageHandler extends BaseMessageHandler {
); );
throw createError; throw createError;
} }
} else {
// Offline view only
this.currentConversationId = sessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages, session: sessionDetails },
});
vscode.window.showWarningMessage(
'Showing cached session content. Login to interact with the AI.',
);
}
} }
} catch (error) { } catch (error) {
console.error('[SessionMessageHandler] Failed to switch session:', error); console.error('[SessionMessageHandler] Failed to switch session:', error);
@@ -620,6 +689,37 @@ export class SessionMessageHandler extends BaseMessageHandler {
*/ */
private async handleResumeSession(sessionId: string): Promise<void> { private async handleResumeSession(sessionId: string): Promise<void> {
try { try {
// If not connected, offer to login or view offline
if (!this.agentManager.isConnected) {
const selection = await vscode.window.showWarningMessage(
'You are not logged in. Login now to fully restore this session, or view it offline.',
'Login Now',
'View Offline',
);
if (selection === 'Login Now') {
if (this.loginHandler) {
await this.loginHandler();
} else {
await vscode.commands.executeCommand('qwenCode.login');
}
} else if (selection === 'View Offline') {
const messages =
await this.agentManager.getSessionMessages(sessionId);
this.currentConversationId = sessionId;
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
vscode.window.showInformationMessage(
'Showing cached session content. Login to interact with the AI.',
);
return;
} else {
return;
}
}
// Try ACP load first // Try ACP load first
try { try {
await this.agentManager.loadSessionViaAcp(sessionId); await this.agentManager.loadSessionViaAcp(sessionId);