diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 5ea9944f14c..7a01f8faa91 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -34,7 +34,8 @@ "onCommand:qwen-code.openChat", "onCommand:qwen-code.focusChat", "onCommand:qwen-code.newConversation", - "onCommand:qwen-code.showLogs" + "onCommand:qwen-code.showLogs", + "onCommand:qwen-code.daemonSmoke" ], "contributes": { "jsonValidation": [ @@ -126,6 +127,10 @@ "command": "qwen-code.showLogs", "title": "Qwen Code: Show Logs" }, + { + "command": "qwen-code.daemonSmoke", + "title": "Qwen Code: Daemon Smoke Test" + }, { "command": "qwen-code.copyMessage", "title": "%qwen-code.copyMessage.title%" @@ -152,6 +157,9 @@ { "command": "qwen-code.auth" }, + { + "command": "qwen-code.daemonSmoke" + }, { "command": "qwen-code.copyMessage", "when": "false" @@ -247,6 +255,24 @@ "type": "boolean", "default": true, "description": "Show notifications with sound when a task completes (at least 20 seconds) or needs your attention, while you are not actively viewing the Qwen Code panel." + }, + "qwen-code.daemonUrl": { + "order": 5, + "type": "string", + "default": "", + "description": "Experimental qwen serve URL used by daemon-backed IDE drafts and the Daemon Smoke Test command." + }, + "qwen-code.daemonToken": { + "order": 6, + "type": "string", + "default": "", + "description": "Optional bearer token used by daemon-backed IDE drafts and the Daemon Smoke Test command." + }, + "qwen-code.experimentalDaemonIde": { + "order": 7, + "type": "boolean", + "default": false, + "description": "Experimental: route the IDE webview through a loopback qwen serve daemon instead of spawning a local ACP child process. The daemon owns runtime execution for the same workspace." } } }, diff --git a/packages/vscode-ide-companion/src/commands/daemonSmoke.ts b/packages/vscode-ide-companion/src/commands/daemonSmoke.ts new file mode 100644 index 00000000000..edc66ef2406 --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/daemonSmoke.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import { DaemonIdeConnection } from '../services/daemonIdeConnection.js'; + +type Logger = (message: string) => void; + +export const daemonSmokeCommand = 'qwen-code.daemonSmoke'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getTextContent(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + return typeof value['text'] === 'string' ? value['text'] : undefined; +} + +function getSessionUpdateText(data: SessionNotification): string | undefined { + if (!isRecord(data)) { + return undefined; + } + const update = data['update']; + if (!isRecord(update)) { + return undefined; + } + const sessionUpdate = update['sessionUpdate']; + if ( + sessionUpdate !== 'agent_message_chunk' && + sessionUpdate !== 'agent_thought_chunk' + ) { + return undefined; + } + return getTextContent(update['content']); +} + +async function pickPermissionOption( + request: RequestPermissionRequest, +): Promise<{ optionId?: string }> { + const options = Array.isArray(request.options) ? request.options : []; + const picked = await vscode.window.showQuickPick( + options.map((option) => ({ + label: option.name ?? option.optionId, + description: option.optionId, + optionId: option.optionId, + })), + { + title: `Qwen daemon permission: ${request.toolCall?.kind ?? 'tool'}`, + placeHolder: 'Choose a daemon permission response', + }, + ); + return { optionId: picked?.optionId ?? 'cancel' }; +} + +export function registerDaemonSmokeCommand( + context: vscode.ExtensionContext, + log: Logger, + outputChannel?: vscode.OutputChannel, +): void { + context.subscriptions.push( + vscode.commands.registerCommand(daemonSmokeCommand, async () => { + const config = vscode.workspace.getConfiguration(); + const configuredUrl = + config.get('qwen-code.daemonUrl') || 'http://127.0.0.1:4170'; + const baseUrl = await vscode.window.showInputBox({ + title: 'Qwen daemon URL', + value: configuredUrl, + ignoreFocusOut: true, + }); + if (!baseUrl) { + return; + } + + const prompt = await vscode.window.showInputBox({ + title: 'Qwen daemon smoke prompt', + value: 'Say hello from the daemon IDE wire-up.', + ignoreFocusOut: true, + }); + if (!prompt) { + return; + } + + const token = + config.get('qwen-code.daemonToken') || + process.env['QWEN_SERVER_TOKEN']; + const workspaceCwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const connection = new DaemonIdeConnection(); + + outputChannel?.show(true); + outputChannel?.appendLine(`[daemon] connecting to ${baseUrl}`); + connection.onSessionUpdate = (data) => { + const text = getSessionUpdateText(data); + if (text) { + outputChannel?.append(text); + } + }; + connection.onPermissionRequest = pickPermissionOption; + connection.onEndTurn = (reason) => { + outputChannel?.appendLine(''); + outputChannel?.appendLine(`[daemon] turn ended: ${reason ?? 'ok'}`); + }; + connection.onDisconnected = (_code, signal) => { + outputChannel?.appendLine(`[daemon] disconnected: ${signal ?? 'ok'}`); + }; + + try { + await connection.connect({ + baseUrl, + token, + workspaceCwd, + }); + outputChannel?.appendLine( + `[daemon] session ${connection.currentSessionId ?? 'unknown'}`, + ); + await connection.sendPrompt(prompt); + vscode.window.showInformationMessage( + 'Qwen daemon smoke prompt completed.', + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`[DaemonSmoke] ${message}`); + vscode.window.showErrorMessage(`Qwen daemon smoke failed: ${message}`); + } finally { + await connection.disconnect(); + } + }), + ); +} diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 959946b77cd..259d0268231 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -12,6 +12,10 @@ import { CHAT_VIEW_ID_SIDEBAR, CHAT_VIEW_ID_SECONDARY, } from '../constants/viewIds.js'; +import { + daemonSmokeCommand, + registerDaemonSmokeCommand, +} from './daemonSmoke.js'; type Logger = (message: string) => void; @@ -23,6 +27,7 @@ export const authCommand = 'qwen-code.auth'; export const focusChatCommand = 'qwen-code.focusChat'; export const newConversationCommand = 'qwen-code.newConversation'; export const showLogsCommand = 'qwen-code.showLogs'; +export { daemonSmokeCommand }; /** * Register all Qwen Code chat-related commands. @@ -147,4 +152,5 @@ export function registerNewCommands( ); context.subscriptions.push(...disposables); + registerDaemonSmokeCommand(context, log, outputChannel); } diff --git a/packages/vscode-ide-companion/src/services/daemonAcpConnection.ts b/packages/vscode-ide-companion/src/services/daemonAcpConnection.ts new file mode 100644 index 00000000000..78f0a4c3486 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/daemonAcpConnection.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AuthenticateResponse, + ContentBlock, + ListSessionsResponse, + LoadSessionResponse, + NewSessionResponse, + PromptResponse, + SetSessionModeResponse, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; +import { AcpConnection } from './acpConnection.js'; +import { DaemonIdeConnection } from './daemonIdeConnection.js'; + +export interface DaemonAcpConnectionOptions { + baseUrl: string; + token?: string; + modelServiceId?: string; +} + +export type DaemonAcpConnectionOptionsProvider = + () => DaemonAcpConnectionOptions; + +/** + * Daemon-native IDE adapter for the local-IDE + local-daemon draft path. + * + * The existing webview still talks to an AcpConnection-shaped facade, but the + * facade sends prompts through DaemonIdeConnection/DaemonSessionClient instead + * of spawning a local ACP child process. This keeps the draft adapter small and + * flag-gated while preserving the server/client/runtime boundary: IDE code does + * not import daemon EventBus internals and the daemon remains the runtime owner. + * + * It intentionally preserves AcpConnection's public shape so the first + * daemon-backed IDE draft can stay small and fully flag-gated. Unsupported + * session-management APIs remain explicit no-ops/failures until the daemon + * protocol grows matching endpoints. + */ +export class DaemonAcpConnection extends AcpConnection { + private readonly daemon = new DaemonIdeConnection(); + // Mutable because each webview session may provide its own local workspace + // path. In this draft that path must also be visible to the local daemon. + private daemonWorkingDir = process.cwd(); + + constructor( + private readonly optionsProvider: DaemonAcpConnectionOptionsProvider, + ) { + super(); + } + + override async connect( + _cliEntryPath: string, + workingDir: string = process.cwd(), + _extraArgs: string[] = [], + ): Promise { + // Daemon mode does not spawn the CLI entrypoint; the running qwen serve + // instance owns runtime startup, auth, tools, MCP, skills, and files. + this.daemonWorkingDir = workingDir; + this.wireDaemonCallbacks(); + const options = this.optionsProvider(); + const baseUrl = options.baseUrl || 'http://127.0.0.1:4170'; + await this.daemon.connect({ + baseUrl, + token: options.token || undefined, + workspaceCwd: workingDir, + modelServiceId: options.modelServiceId || undefined, + lastEventId: this.daemon.lastEventId, + }); + } + + override async authenticate( + _methodId?: string, + ): Promise { + // Auth is brokered by the daemon server. This ACP-shaped method remains a + // no-op until the IDE consumes daemon auth/status routes directly. + return {} as AuthenticateResponse; + } + + override async newSession( + cwd: string = process.cwd(), + ): Promise { + if (!this.daemon.isConnected) { + await this.connect('', cwd); + } + const sessionId = this.daemon.currentSessionId; + if (!sessionId) { + throw new Error('Daemon IDE session was not created'); + } + return { sessionId } as NewSessionResponse; + } + + override async sendPrompt( + prompt: string | ContentBlock[], + ): Promise { + return (await this.daemon.sendPrompt(prompt)) as PromptResponse; + } + + override async cancelSession(): Promise { + await this.daemon.cancelSession(); + } + + override async setModel(modelId: string): Promise { + return (await this.daemon.setModel(modelId)) as SetSessionModelResponse; + } + + override async setMode( + _modeId: ApprovalModeValue, + ): Promise { + // Approval-mode mutation needs a daemon control-plane route before the IDE + // draft can forward this safely. + return {} as SetSessionModeResponse; + } + + override async getAccountInfo(): Promise<{ + authType: string | null; + model: string | null; + baseUrl: string | null; + apiKeyEnvKey: string | null; + }> { + // The daemon owns provider credentials and active model state. This draft + // only exposes the daemon transport identity to keep legacy UI code stable. + return { + authType: 'daemon', + model: null, + baseUrl: this.optionsProvider().baseUrl || 'http://127.0.0.1:4170', + apiKeyEnvKey: null, + }; + } + + override async listSessions(): Promise { + // Until the IDE consumes daemon session list/load routes, expose only the + // attached daemon session to avoid implying full session-manager parity. + const sessionId = this.daemon.currentSessionId; + return { + sessions: sessionId + ? [ + { + sessionId, + cwd: this.daemonWorkingDir, + }, + ] + : [], + } as ListSessionsResponse; + } + + override async loadSession( + sessionId: string, + _cwdOverride?: string, + ): Promise { + if (sessionId === this.daemon.currentSessionId) { + return { sessionId } as LoadSessionResponse; + } + throw new Error('Daemon IDE session/load is not wired in this draft'); + } + + override async deleteSession( + _sessionId: string, + ): Promise<{ success: boolean }> { + return { success: false }; + } + + override async renameSession( + _sessionId: string, + _title: string, + ): Promise<{ success: boolean }> { + return { success: false }; + } + + override async switchSession(sessionId: string): Promise { + if (sessionId !== this.daemon.currentSessionId) { + throw new Error( + 'Daemon IDE session switching is not wired in this draft', + ); + } + } + + override async rewindSession( + _targetTurnIndex: number, + ): Promise<{ historyBeforeRewind?: unknown[] }> { + throw new Error('Daemon IDE rewind is not wired in this draft'); + } + + override async restoreSessionHistory(_history: unknown[]): Promise { + throw new Error('Daemon IDE history restore is not wired in this draft'); + } + + override disconnect(): void { + void this.daemon.disconnect(); + } + + override get isConnected(): boolean { + return this.daemon.isConnected; + } + + override get currentSessionId(): string | null { + return this.daemon.currentSessionId; + } + + private wireDaemonCallbacks(): void { + this.daemon.onSessionUpdate = (data) => this.onSessionUpdate(data); + this.daemon.onPermissionRequest = (data) => this.onPermissionRequest(data); + this.daemon.onAskUserQuestion = (data) => this.onAskUserQuestion(data); + this.daemon.onEndTurn = (reason) => this.onEndTurn(reason); + this.daemon.onDisconnected = (code, signal) => + this.onDisconnected(code, signal); + } +} diff --git a/packages/vscode-ide-companion/src/services/daemonIdeConnection.test.ts b/packages/vscode-ide-companion/src/services/daemonIdeConnection.test.ts index 670e9f010f3..bec9d197ef6 100644 --- a/packages/vscode-ide-companion/src/services/daemonIdeConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/daemonIdeConnection.test.ts @@ -910,8 +910,6 @@ describe('DaemonIdeConnection', () => { baseUrl: 'http://example.com:4170', sessionFactory: vi.fn(), }), - ).rejects.toThrow( - 'Daemon baseUrl must target a loopback address, got "example.com"', - ); + ).rejects.toThrow('Daemon baseUrl must target a loopback address'); }); }); diff --git a/packages/vscode-ide-companion/src/services/daemonIdeConnection.ts b/packages/vscode-ide-companion/src/services/daemonIdeConnection.ts index a5f0e3c2c91..83e6b3d5cd1 100644 --- a/packages/vscode-ide-companion/src/services/daemonIdeConnection.ts +++ b/packages/vscode-ide-companion/src/services/daemonIdeConnection.ts @@ -5,8 +5,12 @@ */ /** - * Daemon-backed IDE connection spike. It mirrors the ACP process connection - * shape while replacing the local child process with a qwen serve session. + * Daemon-backed IDE connection spike. + * + * This is a daemon-native event consumer: prompts and session events travel + * through DaemonSessionClient, then get projected into the existing ACP-shaped + * webview callbacks. It intentionally avoids PTY proxying and does not own a + * separate runtime/event protocol. */ import type { @@ -114,7 +118,8 @@ function validateDaemonBaseUrl(baseUrl: string): string { } if (!isLoopbackHostname(url.hostname)) { throw new Error( - `Daemon baseUrl must target a loopback address, got "${url.hostname}"`, + `Daemon baseUrl must target a loopback address for this local IDE ` + + `draft, got "${url.hostname}"`, ); } return url.href; @@ -147,10 +152,18 @@ export function createSdkDaemonSessionFactory(): DaemonIdeSessionFactory { baseUrl: validateDaemonBaseUrl(opts.baseUrl), token: opts.token, }); - const session = await SdkDaemonSessionClient.createOrAttach(daemon, { - workspaceCwd: opts.workspaceCwd, - modelServiceId: opts.modelServiceId, - }); + let session: DaemonIdeSessionClient; + try { + session = await SdkDaemonSessionClient.createOrAttach(daemon, { + workspaceCwd: opts.workspaceCwd, + modelServiceId: opts.modelServiceId, + }); + } catch (error) { + const message = toSafeErrorMessage(error); + throw new Error( + `Failed to attach IDE to daemon workspace "${opts.workspaceCwd ?? ''}": ${message}`, + ); + } if (opts.lastEventId !== undefined) { session.setLastEventId?.(opts.lastEventId); } diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index ab4f44631c3..0e3ad883498 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -86,6 +86,10 @@ interface AgentSessionOptions { forceNew?: boolean; } +interface QwenAgentManagerOptions { + connection?: AcpConnection; +} + export class QwenAgentManager { private connection: AcpConnection; private sessionReader: QwenSessionReader; @@ -116,8 +120,8 @@ export class QwenAgentManager { private baselineModelInfo: ModelInfo | null = null; private baselineAvailableModels: ModelInfo[] = []; - constructor() { - this.connection = new AcpConnection(); + constructor(options: QwenAgentManagerOptions = {}) { + this.connection = options.connection ?? new AcpConnection(); this.sessionReader = new QwenSessionReader(); this.sessionManager = new QwenSessionManager(); this.connectionHandler = new QwenConnectionHandler(); diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index d6ada5b8b1e..d91d7408bfa 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { execFile } from 'child_process'; import { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import { DaemonAcpConnection } from '../../services/daemonAcpConnection.js'; import { ConversationStore } from '../../services/conversationStore.js'; import type { RequestPermissionRequest, @@ -35,6 +36,33 @@ import { } from '../../services/settingsWriter.js'; import { parseInsightMessage } from '@qwen-code/qwen-code-core'; +/** + * Creates the agent manager for this webview instance. + * + * The daemon path is intentionally local-IDE + local-daemon only in this draft: + * the extension still owns the editor UI, while qwen serve owns runtime, + * tools, MCP, skills, and files for the same workspace. Setting changes apply + * to newly created managers; existing sessions are not hot-swapped. + */ +function createAgentManagerFromConfiguration(): QwenAgentManager { + const config = vscode.workspace.getConfiguration('qwen-code'); + const useDaemon = config.get('experimentalDaemonIde') === true; + if (!useDaemon) { + return new QwenAgentManager(); + } + + return new QwenAgentManager({ + connection: new DaemonAcpConnection(() => { + const latestConfig = vscode.workspace.getConfiguration('qwen-code'); + return { + baseUrl: + latestConfig.get('daemonUrl') || 'http://127.0.0.1:4170', + token: latestConfig.get('daemonToken') || undefined, + }; + }), + }); +} + /** Threshold (ms) before a completed task triggers a notification. */ const LONG_TASK_THRESHOLD_MS = 20_000; @@ -124,7 +152,7 @@ export class WebViewProvider { private context: vscode.ExtensionContext, private extensionUri: vscode.Uri, ) { - this.agentManager = new QwenAgentManager(); + this.agentManager = createAgentManagerFromConfiguration(); this.conversationStore = new ConversationStore(context); this.panelManager = new PanelManager(extensionUri, () => { // Panel dispose callback — unblock any pending ACP Promises