From c044d4dba1f9abfdc3df20173369bef7870eaba9 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 5 Mar 2026 19:05:48 +0800 Subject: [PATCH] refactor(acp): migrate to @agentclientprotocol/sdk and clean up handlers - Replace deprecated ACP session manager with new SDK integration - Add acpFileHandler test coverage - Remove obsolete acpMessageHandler and acpSessionManager - Update type definitions and connection handlers - Apply code formatting fixes Co-authored-by: Qwen-Coder --- package-lock.json | 2 +- packages/cli/src/acp-integration/acpAgent.ts | 18 +- packages/vscode-ide-companion/NOTICES.txt | 197 +++++++ packages/vscode-ide-companion/package.json | 1 + .../src/constants/acpSchema.ts | 37 +- .../src/services/acpConnection.ts | 550 +++++++++--------- .../src/services/acpFileHandler.test.ts | 131 +++++ .../src/services/acpFileHandler.ts | 5 +- .../src/services/acpMessageHandler.ts | 253 -------- .../src/services/acpSessionManager.test.ts | 147 ----- .../src/services/acpSessionManager.ts | 511 ---------------- .../src/services/qwenAgentManager.ts | 226 ++++--- .../src/services/qwenConnectionHandler.ts | 2 +- .../src/services/qwenSessionManager.ts | 65 +-- .../services/qwenSessionUpdateHandler.test.ts | 177 +++--- .../src/services/qwenSessionUpdateHandler.ts | 124 ++-- .../src/types/acpTypes.ts | 254 +------- .../src/types/chatTypes.ts | 17 +- .../src/types/connectionTypes.ts | 12 +- .../src/utils/acpModelInfo.ts | 4 +- .../vscode-ide-companion/src/webview/App.tsx | 2 +- .../src/webview/WebViewProvider.ts | 12 +- .../webview/components/layout/InputForm.tsx | 2 +- .../components/layout/ModelSelector.tsx | 2 +- .../webview/handlers/SessionMessageHandler.ts | 86 --- .../hooks/message/useMessageHandling.ts | 29 +- .../hooks/session/useSessionManagement.ts | 37 -- .../src/webview/hooks/useWebViewMessages.ts | 15 +- 28 files changed, 959 insertions(+), 1959 deletions(-) create mode 100644 packages/vscode-ide-companion/src/services/acpFileHandler.test.ts delete mode 100644 packages/vscode-ide-companion/src/services/acpMessageHandler.ts delete mode 100644 packages/vscode-ide-companion/src/services/acpSessionManager.test.ts delete mode 100644 packages/vscode-ide-companion/src/services/acpSessionManager.ts diff --git a/package-lock.json b/package-lock.json index 6f90131b9..c0c2bb039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14293,7 +14293,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -22904,6 +22903,7 @@ "version": "0.12.0", "license": "LICENSE", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@modelcontextprotocol/sdk": "^1.25.1", "@qwen-code/webui": "*", "cors": "^2.8.5", diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index c24c5cfd9..af3590422 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -214,7 +214,16 @@ class QwenAgent implements Agent { } await this.createAndStoreSession(config, sessionData.conversation); - return null as unknown as LoadSessionResponse; + + const modesData = this.buildModesData(config); + const availableModels = this.buildAvailableModels(config); + const configOptions = this.buildConfigOptions(config); + + return { + modes: modesData, + models: availableModels, + configOptions, + }; } async unstable_listSessions( @@ -323,6 +332,13 @@ class QwenAgent implements Agent { await session.cancelPendingPrompt(); } + async extMethod( + method: string, + _params: Record, + ): Promise> { + throw RequestError.methodNotFound(method); + } + // --- private helpers --- private async newSessionConfig( diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 9daf209d9..af27b707a 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -1,5 +1,202 @@ This file contains third-party software notices and license terms. +============================================================ +@agentclientprotocol/sdk@0.14.1 +(git+https://github.com/agentclientprotocol/typescript-sdk.git) + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Zed Industries, Inc. and contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ============================================================ @qwen-code/webui@undefined (No repository found) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index f83d3cd86..79e6193df 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -158,6 +158,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@qwen-code/webui": "*", "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", diff --git a/packages/vscode-ide-companion/src/constants/acpSchema.ts b/packages/vscode-ide-companion/src/constants/acpSchema.ts index 7cd8d4c09..526085293 100644 --- a/packages/vscode-ide-companion/src/constants/acpSchema.ts +++ b/packages/vscode-ide-companion/src/constants/acpSchema.ts @@ -4,41 +4,30 @@ * SPDX-License-Identifier: Apache-2.0 */ -export const AGENT_METHODS = { - authenticate: 'authenticate', - initialize: 'initialize', - session_cancel: 'session/cancel', - session_list: 'session/list', - session_load: 'session/load', - session_new: 'session/new', - session_prompt: 'session/prompt', - session_save: 'session/save', - session_set_mode: 'session/set_mode', - session_set_model: 'session/set_model', -} as const; +export { + AGENT_METHODS, + CLIENT_METHODS, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; -export const CLIENT_METHODS = { - fs_read_text_file: 'fs/read_text_file', - fs_write_text_file: 'fs/write_text_file', +export { RequestError } from '@agentclientprotocol/sdk'; + +// Local extension: authenticate/update is not part of the ACP spec. +// It is routed as an extension notification by our CLI. +export const EXT_CLIENT_METHODS = { authenticate_update: 'authenticate/update', - session_request_permission: 'session/request_permission', - session_update: 'session/update', } as const; +// Re-export error codes in the shape that existing consumers expect. +// The numeric values match the SDK's ErrorCode type. export const ACP_ERROR_CODES = { - // Parse error: invalid JSON received by server. PARSE_ERROR: -32700, - // Invalid request: JSON is not a valid Request object. INVALID_REQUEST: -32600, - // Method not found: method does not exist or is unavailable. METHOD_NOT_FOUND: -32601, - // Invalid params: invalid method parameter(s). INVALID_PARAMS: -32602, - // Internal error: implementation-defined server error. INTERNAL_ERROR: -32603, - // Authentication required: must authenticate before operation. + REQUEST_CANCELLED: -32800, AUTH_REQUIRED: -32000, - // Resource not found: e.g. missing file. RESOURCE_NOT_FOUND: -32002, } as const; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 0a5aec02c..69abdb53d 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -4,64 +4,59 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { JSONRPC_VERSION } from '../types/acpTypes.js'; -import { ACP_ERROR_CODES } from '../constants/acpSchema.js'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; import type { - AcpMessage, - AcpPermissionRequest, - AcpResponse, - AcpSessionUpdate, - AuthenticateUpdateNotification, -} from '../types/acpTypes.js'; + Client, + Agent, + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse, + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, + AuthenticateResponse, + NewSessionResponse, + LoadSessionResponse, + ListSessionsResponse, + PromptResponse, + SetSessionModeResponse, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; import { spawn } from 'child_process'; -import type { - PendingRequest, - AcpConnectionCallbacks, -} from '../types/connectionTypes.js'; -import { AcpMessageHandler } from './acpMessageHandler.js'; -import { AcpSessionManager } from './acpSessionManager.js'; +import { Readable, Writable } from 'node:stream'; import * as fs from 'node:fs'; +import { AcpFileHandler } from './acpFileHandler.js'; /** * ACP Connection Handler for VSCode Extension * - * This class implements the client side of the ACP (Agent Communication Protocol). + * External API preserved for backward compatibility. + * Internally uses SDK ClientSideConnection + ndJsonStream for protocol handling. */ export class AcpConnection { private child: ChildProcess | null = null; - private pendingRequests = new Map>(); - private nextRequestId = { value: 0 }; - // Remember the working dir provided at connect() so later ACP calls - // that require cwd (e.g. session/list) can include it. + private sdkConnection: ClientSideConnection | null = null; + private sessionId: string | null = null; private workingDir: string = process.cwd(); + private fileHandler = new AcpFileHandler(); - private messageHandler: AcpMessageHandler; - private sessionManager: AcpSessionManager; - - onSessionUpdate: (data: AcpSessionUpdate) => void = () => {}; - onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + onSessionUpdate: (data: SessionNotification) => void = () => {}; + onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ optionId: string; - }> = () => Promise.resolve({ optionId: 'allow' }); + }> = () => Promise.resolve({ optionId: 'allow_once' }); onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = () => {}; - onEndTurn: () => void = () => {}; - // Called after successful initialize() with the initialize result + onEndTurn: (reason?: string) => void = () => {}; onInitialized: (init: unknown) => void = () => {}; - constructor() { - this.messageHandler = new AcpMessageHandler(); - this.sessionManager = new AcpSessionManager(); - } - - /** - * Connect to Qwen ACP - * - * @param cliEntryPath - Path to the bundled CLI entrypoint (cli.js) - * @param workingDir - Working directory - * @param extraArgs - Extra command line arguments - */ async connect( cliEntryPath: string, workingDir: string = process.cwd(), @@ -75,8 +70,6 @@ export class AcpConnection { const env = { ...process.env }; - // If proxy is configured in extraArgs, also set it as environment variable - // This ensures token refresh requests also use the proxy const proxyArg = extraArgs.find( (arg, i) => arg === '--proxy' && i + 1 < extraArgs.length, ); @@ -84,15 +77,12 @@ export class AcpConnection { const proxyIndex = extraArgs.indexOf('--proxy'); const proxyUrl = extraArgs[proxyIndex + 1]; console.log('[ACP] Setting proxy environment variables:', proxyUrl); - env['HTTP_PROXY'] = proxyUrl; env['HTTPS_PROXY'] = proxyUrl; env['http_proxy'] = proxyUrl; env['https_proxy'] = proxyUrl; } - // Always run the bundled CLI using the VS Code extension host's Node runtime. - // This avoids PATH/NVM/global install problems and ensures deterministic behavior. const spawnCommand: string = process.execPath; const spawnArgs: string[] = [ cliEntryPath, @@ -113,7 +103,6 @@ export class AcpConnection { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], env, - // We spawn node directly; no shell needed (and shell quoting can break paths). shell: false, }; @@ -121,13 +110,10 @@ export class AcpConnection { await this.setupChildProcessHandlers(); } - /** - * Set up child process handlers - */ private async setupChildProcessHandlers(): Promise { let spawnError: Error | null = null; - this.child!.stderr?.on('data', (data) => { + this.child!.stderr?.on('data', (data: Buffer) => { const message = data.toString(); if ( message.toLowerCase().includes('error') && @@ -139,19 +125,16 @@ export class AcpConnection { } }); - this.child!.on('error', (error) => { + this.child!.on('error', (error: Error) => { spawnError = error; }); - this.child!.on('exit', (code, signal) => { + this.child!.on('exit', (code: number | null, signal: string | null) => { console.error( `[ACP qwen] Process exited with code: ${code}, signal: ${signal}`, ); - // Clear pending requests when process exits - this.pendingRequests.clear(); }); - // Wait for process to start await new Promise((resolve) => setTimeout(resolve, 1000)); if (spawnError) { @@ -162,291 +145,292 @@ export class AcpConnection { throw new Error(`Qwen ACP process failed to start`); } - // Handle messages from ACP server - let buffer = ''; - this.child.stdout?.on('data', (data) => { - buffer += data.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; + // Convert Node.js child process streams to Web Streams for SDK + const stdout = Readable.toWeb( + this.child.stdout!, + ) as ReadableStream; + const stdin = Writable.toWeb(this.child.stdin!) as WritableStream; - for (const line of lines) { - if (line.trim()) { + const stream = ndJsonStream(stdin, stdout); + + // Build the SDK Client implementation that bridges to our callbacks + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + this.sdkConnection = new ClientSideConnection( + (_agent: Agent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + console.log( + '[ACP] >>> Processing session_update:', + JSON.stringify(params).substring(0, 300), + ); + self.onSessionUpdate(params as unknown as SessionNotification); + return Promise.resolve(); + }, + + async requestPermission( + params: RequestPermissionRequest, + ): Promise { + const permissionData = params as unknown as RequestPermissionRequest; try { - const message = JSON.parse(line) as AcpMessage; - console.log( - '[ACP] <<< Received message:', - JSON.stringify(message).substring(0, 500 * 3), - ); - this.handleMessage(message); - } catch (_error) { - // Ignore non-JSON lines - console.log( - '[ACP] <<< Non-JSON line (ignored):', - line.substring(0, 200), - ); - } - } - } - }); + const response = await self.onPermissionRequest(permissionData); + const optionId = response?.optionId; + console.log('[ACP] Permission request:', optionId); + let outcome: 'selected' | 'cancelled'; + if ( + optionId && + (optionId.includes('reject') || optionId === 'cancel') + ) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + console.log('[ACP] Permission outcome:', outcome); - // Initialize protocol - const res = await this.sessionManager.initialize( - this.child, - this.pendingRequests, - this.nextRequestId, + if (outcome === 'cancelled') { + return { outcome: { outcome: 'cancelled' } }; + } + return { + outcome: { + outcome: 'selected', + optionId: optionId || 'allow_once', + }, + }; + } catch (_error) { + return { outcome: { outcome: 'cancelled' } }; + } + }, + + async readTextFile( + params: ReadTextFileRequest, + ): Promise { + const result = await self.fileHandler.handleReadTextFile({ + path: params.path, + sessionId: params.sessionId, + line: params.line ?? null, + limit: params.limit ?? null, + }); + return { content: result.content }; + }, + + async writeTextFile( + params: WriteTextFileRequest, + ): Promise { + await self.fileHandler.handleWriteTextFile({ + path: params.path, + content: params.content, + sessionId: params.sessionId, + }); + return {}; + }, + + async extNotification( + method: string, + params: Record, + ): Promise { + if (method === 'authenticate/update') { + console.log( + '[ACP] >>> Processing authenticate_update:', + JSON.stringify(params).substring(0, 300), + ); + self.onAuthenticateUpdate( + params as unknown as AuthenticateUpdateNotification, + ); + } else { + console.warn(`[ACP] Unhandled extension notification: ${method}`); + } + }, + }), + stream, ); - console.log('[ACP] Initialization response:', res); + // Initialize protocol via SDK + console.log('[ACP] Sending initialize request...'); + const initResponse = await this.sdkConnection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + }, + }); + + console.log('[ACP] Initialize successful'); + console.log('[ACP] Initialization response:', initResponse); try { - this.onInitialized(res); + this.onInitialized(initResponse); } catch (err) { console.warn('[ACP] onInitialized callback error:', err); } } - /** - * Handle received messages - * - * @param message - ACP message - */ - private handleMessage(message: AcpMessage): void { - const callbacks: AcpConnectionCallbacks = { - onSessionUpdate: this.onSessionUpdate, - onPermissionRequest: this.onPermissionRequest, - onAuthenticateUpdate: this.onAuthenticateUpdate, - onEndTurn: this.onEndTurn, - }; - - // Handle message - if ('method' in message) { - // Request or notification - this.messageHandler - .handleIncomingRequest(message, callbacks) - .then((result) => { - if ('id' in message && typeof message.id === 'number') { - this.messageHandler.sendResponseMessage(this.child, { - jsonrpc: JSONRPC_VERSION, - id: message.id, - result, - }); - } - }) - .catch((error) => { - if ('id' in message && typeof message.id === 'number') { - const errorMessage = - error instanceof Error - ? error.message - : typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as { message: unknown }).message === 'string' - ? (error as { message: string }).message - : String(error); - - let errorCode: number = ACP_ERROR_CODES.INTERNAL_ERROR; - const errorCodeValue = - typeof error === 'object' && error !== null && 'code' in error - ? (error as { code?: unknown }).code - : undefined; - - if (typeof errorCodeValue === 'number') { - errorCode = errorCodeValue; - } else if (errorCodeValue === 'ENOENT') { - errorCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND; - } - - this.messageHandler.sendResponseMessage(this.child, { - jsonrpc: JSONRPC_VERSION, - id: message.id, - error: { - code: errorCode, - message: errorMessage, - }, - }); - } - }); - } else { - // Response - this.messageHandler.handleMessage( - message, - this.pendingRequests, - callbacks, - ); + private ensureConnection(): ClientSideConnection { + if (!this.sdkConnection) { + throw new Error('Not connected to ACP agent'); } + return this.sdkConnection; } - /** - * Authenticate - * - * @param methodId - Authentication method ID - * @returns Authentication response - */ - async authenticate(methodId?: string): Promise { - return this.sessionManager.authenticate( - methodId, - this.child, - this.pendingRequests, - this.nextRequestId, + async authenticate(methodId?: string): Promise { + const conn = this.ensureConnection(); + const authMethodId = methodId || 'default'; + console.log( + '[ACP] Sending authenticate request with methodId:', + authMethodId, ); + const response = await conn.authenticate({ methodId: authMethodId }); + console.log('[ACP] Authenticate successful', response); + return response; } - /** - * Create new session - * - * @param cwd - Working directory - * @returns New session response - */ - async newSession(cwd: string = process.cwd()): Promise { - return this.sessionManager.newSession( + async newSession(cwd: string = process.cwd()): Promise { + const conn = this.ensureConnection(); + console.log('[ACP] Sending session/new request with cwd:', cwd); + const response: NewSessionResponse = await conn.newSession({ cwd, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + mcpServers: [], + }); + this.sessionId = response.sessionId || null; + console.log('[ACP] Session created with ID:', this.sessionId); + return response; } - /** - * Send prompt message - * - * @param prompt - Prompt content - * @returns Response - */ - async sendPrompt(prompt: string): Promise { - return this.sessionManager.sendPrompt( - prompt, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + async sendPrompt(prompt: string): Promise { + const conn = this.ensureConnection(); + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + const response: PromptResponse = await conn.prompt({ + sessionId: this.sessionId, + prompt: [{ type: 'text', text: prompt }], + }); + // Emit end-of-turn from stopReason + if (response.stopReason) { + this.onEndTurn(response.stopReason); + } else { + this.onEndTurn(); + } + return response; } - /** - * Load existing session - * - * @param sessionId - Session ID - * @returns Load response - */ async loadSession( sessionId: string, cwdOverride?: string, - ): Promise { - return this.sessionManager.loadSession( - sessionId, - this.child, - this.pendingRequests, - this.nextRequestId, - cwdOverride || this.workingDir, - ); + ): Promise { + const conn = this.ensureConnection(); + console.log('[ACP] Sending session/load request for session:', sessionId); + const cwd = cwdOverride || this.workingDir; + try { + const response = await conn.loadSession({ + sessionId, + cwd, + mcpServers: [], + }); + console.log('[ACP] Session load succeeded'); + this.sessionId = sessionId; + return response; + } catch (error) { + console.error( + '[ACP] Session load request failed:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } } - /** - * Get session list - * - * @returns Session list response - */ async listSessions(options?: { cursor?: number; size?: number; - }): Promise { - return this.sessionManager.listSessions( - this.child, - this.pendingRequests, - this.nextRequestId, - this.workingDir, - options, + }): Promise { + const conn = this.ensureConnection(); + console.log('[ACP] Requesting session list...'); + try { + const params: Record = { cwd: this.workingDir }; + if (options?.cursor !== undefined) { + params['cursor'] = String(options.cursor); + } + if (options?.size !== undefined) { + params['size'] = options.size; + } + const response = await conn.unstable_listSessions( + params as Parameters[0], + ); + console.log( + '[ACP] Session list response:', + JSON.stringify(response).substring(0, 200), + ); + return response; + } catch (error) { + console.error('[ACP] Failed to get session list:', error); + throw error; + } + } + + async switchSession(sessionId: string): Promise { + console.log('[ACP] Switching to session:', sessionId); + this.sessionId = sessionId; + console.log( + '[ACP] Session ID updated locally (switch not supported by CLI)', ); } - /** - * Switch to specified session - * - * @param sessionId - Session ID - * @returns Switch response - */ - async switchSession(sessionId: string): Promise { - return this.sessionManager.switchSession(sessionId, this.nextRequestId); - } - - /** - * Cancel current session prompt generation - */ async cancelSession(): Promise { - await this.sessionManager.cancelSession(this.child); + const conn = this.ensureConnection(); + if (!this.sessionId) { + console.warn('[ACP] No active session to cancel'); + return; + } + console.log('[ACP] Cancelling session:', this.sessionId); + await conn.cancel({ sessionId: this.sessionId }); + console.log('[ACP] Cancel notification sent'); } - /** - * Save current session - * - * @param tag - Save tag - * @returns Save response - */ - async saveSession(tag: string): Promise { - return this.sessionManager.saveSession( - tag, - this.child, - this.pendingRequests, - this.nextRequestId, - ); - } - - /** - * Set approval mode - */ - async setMode(modeId: ApprovalModeValue): Promise { - return this.sessionManager.setMode( + async setMode(modeId: ApprovalModeValue): Promise { + const conn = this.ensureConnection(); + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + console.log('[ACP] Sending session/set_mode:', modeId); + const res = await conn.setSessionMode({ + sessionId: this.sessionId, modeId, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + }); + console.log('[ACP] set_mode response:', res); + return res; } - /** - * Set model for current session - * - * @param modelId - Model ID - * @returns Set model response - */ - async setModel(modelId: string): Promise { - return this.sessionManager.setModel( + async setModel(modelId: string): Promise { + const conn = this.ensureConnection(); + if (!this.sessionId) { + throw new Error('No active ACP session'); + } + console.log('[ACP] Sending session/set_model:', modelId); + const res = await conn.unstable_setSessionModel({ + sessionId: this.sessionId, modelId, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + }); + console.log('[ACP] set_model response:', res); + return res; } - /** - * Disconnect - */ disconnect(): void { if (this.child) { this.child.kill(); this.child = null; } - - this.pendingRequests.clear(); - this.sessionManager.reset(); + this.sdkConnection = null; + this.sessionId = null; } - /** - * Check if connected - */ get isConnected(): boolean { return this.child !== null && !this.child.killed; } - /** - * Check if there is an active session - */ get hasActiveSession(): boolean { - return this.sessionManager.getCurrentSessionId() !== null; + return this.sessionId !== null; } - /** - * Get current session ID - */ get currentSessionId(): string | null { - return this.sessionManager.getCurrentSessionId(); + return this.sessionId; } } diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts new file mode 100644 index 000000000..fa87c9ab0 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AcpFileHandler } from './acpFileHandler.js'; +import { promises as fs } from 'fs'; + +vi.mock('fs', () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + }, +})); + +describe('AcpFileHandler', () => { + let handler: AcpFileHandler; + + beforeEach(() => { + handler = new AcpFileHandler(); + vi.clearAllMocks(); + }); + + describe('handleReadTextFile', () => { + it('returns full content when no line/limit specified', async () => { + vi.mocked(fs.readFile).mockResolvedValue('line1\nline2\nline3\n'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: null, + limit: null, + }); + + expect(result.content).toBe('line1\nline2\nline3\n'); + }); + + it('uses 1-based line indexing (ACP spec)', async () => { + vi.mocked(fs.readFile).mockResolvedValue( + 'line1\nline2\nline3\nline4\nline5', + ); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: 2, + limit: 2, + }); + + expect(result.content).toBe('line2\nline3'); + }); + + it('treats line=1 as first line', async () => { + vi.mocked(fs.readFile).mockResolvedValue('first\nsecond\nthird'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: 1, + limit: 1, + }); + + expect(result.content).toBe('first'); + }); + + it('defaults to line=1 when line is null but limit is set', async () => { + vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc\nd'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: null, + limit: 2, + }); + + expect(result.content).toBe('a\nb'); + }); + + it('clamps negative line values to 0', async () => { + vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc'); + + const result = await handler.handleReadTextFile({ + path: '/test/file.txt', + sessionId: 'sid', + line: -5, + limit: null, + }); + + expect(result.content).toBe('a\nb\nc'); + }); + + it('propagates ENOENT errors', async () => { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(err); + + await expect( + handler.handleReadTextFile({ + path: '/missing/file.txt', + sessionId: 'sid', + line: null, + limit: null, + }), + ).rejects.toThrow('ENOENT'); + }); + }); + + describe('handleWriteTextFile', () => { + it('creates directories and writes file', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const result = await handler.handleWriteTextFile({ + path: '/test/dir/file.txt', + content: 'hello', + sessionId: 'sid', + }); + + expect(result).toBeNull(); + expect(fs.mkdir).toHaveBeenCalledWith('/test/dir', { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/dir/file.txt', + 'hello', + 'utf-8', + ); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/acpFileHandler.ts b/packages/vscode-ide-companion/src/services/acpFileHandler.ts index 2416ceb37..e41240788 100644 --- a/packages/vscode-ide-companion/src/services/acpFileHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpFileHandler.ts @@ -48,10 +48,11 @@ export class AcpFileHandler { `[ACP] Successfully read file: ${params.path} (${content.length} bytes)`, ); - // Handle line offset and limit + // Handle line offset and limit. + // ACP spec: `line` is 1-based (first line = 1). if (params.line !== null || params.limit !== null) { const lines = content.split('\n'); - const startLine = params.line || 0; + const startLine = Math.max(0, (params.line ?? 1) - 1); const endLine = params.limit ? startLine + params.limit : lines.length; const selectedLines = lines.slice(startLine, endLine); const result = { content: selectedLines.join('\n') }; diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts deleted file mode 100644 index c2fad7701..000000000 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * ACP Message Handler - * - * Responsible for receiving, parsing, and distributing messages in the ACP protocol - */ - -import type { - AcpMessage, - AcpRequest, - AcpNotification, - AcpResponse, - AcpSessionUpdate, - AcpPermissionRequest, - AuthenticateUpdateNotification, -} from '../types/acpTypes.js'; -import { CLIENT_METHODS } from '../constants/acpSchema.js'; -import type { - PendingRequest, - AcpConnectionCallbacks, -} from '../types/connectionTypes.js'; -import { AcpFileHandler } from '../services/acpFileHandler.js'; -import type { ChildProcess } from 'child_process'; -import { isWindows } from '../utils/platform.js'; - -/** - * ACP Message Handler Class - * Responsible for receiving, parsing, and processing messages - */ -export class AcpMessageHandler { - private fileHandler: AcpFileHandler; - - constructor() { - this.fileHandler = new AcpFileHandler(); - } - - /** - * Send response message to child process - * - * @param child - Child process instance - * @param response - Response message - */ - sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void { - if (child?.stdin) { - const jsonString = JSON.stringify(response); - const lineEnding = isWindows ? '\r\n' : '\n'; - child.stdin.write(jsonString + lineEnding); - } - } - - /** - * Handle received messages - * - * @param message - ACP message - * @param pendingRequests - Pending requests map - * @param callbacks - Callback functions collection - */ - handleMessage( - message: AcpMessage, - pendingRequests: Map>, - callbacks: AcpConnectionCallbacks, - ): void { - try { - if ('method' in message) { - // Request or notification - this.handleIncomingRequest(message, callbacks).catch(() => {}); - } else if ( - 'id' in message && - typeof message.id === 'number' && - pendingRequests.has(message.id) - ) { - // Response - this.handleResponse(message, pendingRequests, callbacks); - } - } catch (error) { - console.error('[ACP] Error handling message:', error); - } - } - - /** - * Handle response message - * - * @param message - Response message - * @param pendingRequests - Pending requests map - * @param callbacks - Callback functions collection - */ - private handleResponse( - message: AcpMessage, - pendingRequests: Map>, - callbacks: AcpConnectionCallbacks, - ): void { - if (!('id' in message) || typeof message.id !== 'number') { - return; - } - - const pendingRequest = pendingRequests.get(message.id); - if (!pendingRequest) { - return; - } - - const { resolve, reject, method } = pendingRequest; - pendingRequests.delete(message.id); - - if ('result' in message) { - console.log( - `[ACP] Response for ${method}:`, - // JSON.stringify(message.result).substring(0, 200), - message.result, - ); - - if (message.result && typeof message.result === 'object') { - const stopReasonValue = - (message.result as { stopReason?: unknown }).stopReason ?? - (message.result as { stop_reason?: unknown }).stop_reason; - if (typeof stopReasonValue === 'string') { - callbacks.onEndTurn(stopReasonValue); - } else if ( - 'stopReason' in message.result || - 'stop_reason' in message.result - ) { - // stop_reason present but not a string (e.g., null) -> still emit - callbacks.onEndTurn(); - } - } - resolve(message.result); - } else if ('error' in message) { - const errorCode = message.error?.code || 'unknown'; - const errorMsg = message.error?.message || 'Unknown ACP error'; - const errorData = message.error?.data - ? JSON.stringify(message.error.data) - : ''; - console.error(`[ACP] Error response for ${method}:`, { - code: errorCode, - message: errorMsg, - data: errorData, - }); - reject( - new Error( - `${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`, - ), - ); - } - } - - /** - * Handle incoming requests - * - * @param message - Request or notification message - * @param callbacks - Callback functions collection - * @returns Request processing result - */ - async handleIncomingRequest( - message: AcpRequest | AcpNotification, - callbacks: AcpConnectionCallbacks, - ): Promise { - const { method, params } = message; - - let result = null; - - switch (method) { - case CLIENT_METHODS.session_update: - console.log( - '[ACP] >>> Processing session_update:', - JSON.stringify(params).substring(0, 300), - ); - callbacks.onSessionUpdate(params as AcpSessionUpdate); - break; - case CLIENT_METHODS.authenticate_update: - console.log( - '[ACP] >>> Processing authenticate_update:', - JSON.stringify(params).substring(0, 300), - ); - callbacks.onAuthenticateUpdate( - params as AuthenticateUpdateNotification, - ); - break; - case CLIENT_METHODS.session_request_permission: - result = await this.handlePermissionRequest( - params as AcpPermissionRequest, - callbacks, - ); - break; - case CLIENT_METHODS.fs_read_text_file: - result = await this.fileHandler.handleReadTextFile( - params as { - path: string; - sessionId: string; - line: number | null; - limit: number | null; - }, - ); - break; - case CLIENT_METHODS.fs_write_text_file: - result = await this.fileHandler.handleWriteTextFile( - params as { path: string; content: string; sessionId: string }, - ); - break; - default: - console.warn(`[ACP] Unhandled method: ${method}`); - break; - } - - return result; - } - - /** - * Handle permission requests - * - * @param params - Permission request parameters - * @param callbacks - Callback functions collection - * @returns Permission request result - */ - private async handlePermissionRequest( - params: AcpPermissionRequest, - callbacks: AcpConnectionCallbacks, - ): Promise<{ - outcome: { outcome: string; optionId: string }; - }> { - try { - const response = await callbacks.onPermissionRequest(params); - const optionId = response?.optionId; - console.log('[ACP] Permission request:', optionId); - // Handle cancel, deny, or allow - let outcome: string; - if (optionId && (optionId.includes('reject') || optionId === 'cancel')) { - outcome = 'cancelled'; - } else { - outcome = 'selected'; - } - console.log('[ACP] Permission outcome:', outcome); - - return { - outcome: { - outcome, - // optionId: optionId === 'cancel' ? 'cancel' : optionId, - optionId, - }, - }; - } catch (_error) { - return { - outcome: { - outcome: 'rejected', - optionId: 'reject_once', - }, - }; - } - } -} diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.test.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.test.ts deleted file mode 100644 index 17e3e4f8e..000000000 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { AcpSessionManager } from './acpSessionManager.js'; -import type { ChildProcess } from 'child_process'; -import type { PendingRequest } from '../types/connectionTypes.js'; -import { AGENT_METHODS } from '../constants/acpSchema.js'; - -describe('AcpSessionManager', () => { - let sessionManager: AcpSessionManager; - let mockChild: ChildProcess; - let pendingRequests: Map>; - let nextRequestId: { value: number }; - let writtenMessages: string[]; - - beforeEach(() => { - sessionManager = new AcpSessionManager(); - writtenMessages = []; - - mockChild = { - stdin: { - write: vi.fn((msg: string) => { - writtenMessages.push(msg); - // Simulate async response - const parsed = JSON.parse(msg.trim()); - const id = parsed.id; - setTimeout(() => { - const pending = pendingRequests.get(id); - if (pending) { - pending.resolve({ modeId: 'default', modelId: 'test-model' }); - pendingRequests.delete(id); - } - }, 10); - }), - }, - } as unknown as ChildProcess; - - pendingRequests = new Map(); - nextRequestId = { value: 0 }; - }); - - describe('setModel', () => { - it('sends session/set_model request with correct parameters', async () => { - // First initialize the session - // @ts-expect-error - accessing private property for testing - sessionManager.sessionId = 'test-session-id'; - - const responsePromise = sessionManager.setModel( - 'qwen3-coder-plus', - mockChild, - pendingRequests, - nextRequestId, - ); - - // Wait for the response - const response = await responsePromise; - - // Verify the message was sent - expect(writtenMessages.length).toBe(1); - const sentMessage = JSON.parse(writtenMessages[0].trim()); - - expect(sentMessage.method).toBe(AGENT_METHODS.session_set_model); - expect(sentMessage.params).toEqual({ - sessionId: 'test-session-id', - modelId: 'qwen3-coder-plus', - }); - expect(response).toEqual({ modeId: 'default', modelId: 'test-model' }); - }); - - it('throws error when no active session', async () => { - await expect( - sessionManager.setModel( - 'qwen3-coder-plus', - mockChild, - pendingRequests, - nextRequestId, - ), - ).rejects.toThrow('No active ACP session'); - }); - - it('increments request ID for each call', async () => { - // @ts-expect-error - accessing private property for testing - sessionManager.sessionId = 'test-session-id'; - - await sessionManager.setModel( - 'model-1', - mockChild, - pendingRequests, - nextRequestId, - ); - - await sessionManager.setModel( - 'model-2', - mockChild, - pendingRequests, - nextRequestId, - ); - - const firstMessage = JSON.parse(writtenMessages[0].trim()); - const secondMessage = JSON.parse(writtenMessages[1].trim()); - - expect(firstMessage.id).toBe(0); - expect(secondMessage.id).toBe(1); - }); - }); - - describe('setMode', () => { - it('sends session/set_mode request with correct parameters', async () => { - // @ts-expect-error - accessing private property for testing - sessionManager.sessionId = 'test-session-id'; - - const responsePromise = sessionManager.setMode( - 'auto-edit', - mockChild, - pendingRequests, - nextRequestId, - ); - - const response = await responsePromise; - - expect(writtenMessages.length).toBe(1); - const sentMessage = JSON.parse(writtenMessages[0].trim()); - - expect(sentMessage.method).toBe(AGENT_METHODS.session_set_mode); - expect(sentMessage.params).toEqual({ - sessionId: 'test-session-id', - modeId: 'auto-edit', - }); - expect(response).toBeDefined(); - }); - - it('throws error when no active session', async () => { - await expect( - sessionManager.setMode( - 'default', - mockChild, - pendingRequests, - nextRequestId, - ), - ).rejects.toThrow('No active ACP session'); - }); - }); -}); diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts deleted file mode 100644 index 240bd5736..000000000 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ /dev/null @@ -1,511 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * ACP Session Manager - * - * Responsible for managing ACP protocol session operations, including initialization, authentication, session creation, and switching - */ -import { JSONRPC_VERSION } from '../types/acpTypes.js'; -import type { - AcpRequest, - AcpNotification, - AcpResponse, -} from '../types/acpTypes.js'; -import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; -import { AGENT_METHODS } from '../constants/acpSchema.js'; -import type { PendingRequest } from '../types/connectionTypes.js'; -import type { ChildProcess } from 'child_process'; -import { isWindows } from '../utils/platform.js'; - -/** - * ACP Session Manager Class - * Provides session initialization, authentication, creation, loading, and switching functionality - */ -export class AcpSessionManager { - private sessionId: string | null = null; - private isInitialized = false; - - /** - * Send request to ACP server - * - * @param method - Request method name - * @param params - Request parameters - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns Request response - */ - private sendRequest( - method: string, - params: Record | undefined, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - const id = nextRequestId.value++; - const message: AcpRequest = { - jsonrpc: JSONRPC_VERSION, - id, - method, - ...(params && { params }), - }; - - return new Promise((resolve, reject) => { - // No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer - // The request should always terminate with a stop_reason - let timeoutId: NodeJS.Timeout | undefined; - let timeoutDuration: number | undefined; - - if (method !== AGENT_METHODS.session_prompt) { - // Set timeout for other methods - timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000; - timeoutId = setTimeout(() => { - pendingRequests.delete(id); - reject(new Error(`Request ${method} timed out`)); - }, timeoutDuration); - } - - const pendingRequest: PendingRequest = { - resolve: (value: T) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - resolve(value); - }, - reject: (error: Error) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - reject(error); - }, - timeoutId, - method, - }; - - pendingRequests.set(id, pendingRequest as PendingRequest); - this.sendMessage(message, child); - }); - } - - /** - * Send message to child process - * - * @param message - Request or notification message - * @param child - Child process instance - */ - private sendMessage( - message: AcpRequest | AcpNotification, - child: ChildProcess | null, - ): void { - if (child?.stdin) { - const jsonString = JSON.stringify(message); - const lineEnding = isWindows ? '\r\n' : '\n'; - child.stdin.write(jsonString + lineEnding); - } - } - - /** - * Initialize ACP protocol connection - * - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns Initialization response - */ - async initialize( - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - const initializeParams = { - protocolVersion: 1, - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, - }, - }, - }; - - console.log('[ACP] Sending initialize request...'); - const response = await this.sendRequest( - AGENT_METHODS.initialize, - initializeParams, - child, - pendingRequests, - nextRequestId, - ); - this.isInitialized = true; - - console.log('[ACP] Initialize successful'); - return response; - } - - /** - * Perform authentication - * - * @param methodId - Authentication method ID - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns Authentication response - */ - async authenticate( - methodId: string | undefined, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - const authMethodId = methodId || 'default'; - console.log( - '[ACP] Sending authenticate request with methodId:', - authMethodId, - ); - const response = await this.sendRequest( - AGENT_METHODS.authenticate, - { - methodId: authMethodId, - }, - child, - pendingRequests, - nextRequestId, - ); - console.log('[ACP] Authenticate successful', response); - return response; - } - - /** - * Create new session - * - * @param cwd - Working directory - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns New session response - */ - async newSession( - cwd: string, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - console.log('[ACP] Sending session/new request with cwd:', cwd); - const response = await this.sendRequest< - AcpResponse & { sessionId?: string } - >( - AGENT_METHODS.session_new, - { - cwd, - mcpServers: [], - }, - child, - pendingRequests, - nextRequestId, - ); - - this.sessionId = (response && response.sessionId) || null; - console.log('[ACP] Session created with ID:', this.sessionId); - return response; - } - - /** - * Send prompt message - * - * @param prompt - Prompt content - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns Response - * @throws Error when there is no active session - */ - async sendPrompt( - prompt: string, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - if (!this.sessionId) { - throw new Error('No active ACP session'); - } - - return await this.sendRequest( - AGENT_METHODS.session_prompt, - { - sessionId: this.sessionId, - prompt: [{ type: 'text', text: prompt }], - }, - child, - pendingRequests, - nextRequestId, - ); - } - - /** - * Load existing session - * - * @param sessionId - Session ID - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns Load response - */ - async loadSession( - sessionId: string, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - cwd: string = process.cwd(), - ): Promise { - console.log('[ACP] Sending session/load request for session:', sessionId); - console.log('[ACP] Request parameters:', { - sessionId, - cwd, - mcpServers: [], - }); - - try { - const response = await this.sendRequest( - AGENT_METHODS.session_load, - { - sessionId, - cwd, - mcpServers: [], - }, - child, - pendingRequests, - nextRequestId, - ); - - console.log( - '[ACP] Session load response:', - JSON.stringify(response).substring(0, 500), - ); - - // Check if response contains an error - if (response && response.error) { - console.error('[ACP] Session load returned error:', response.error); - } else { - console.log('[ACP] Session load succeeded'); - // session/load returns null on success per schema; update local sessionId - // so subsequent prompts use the loaded session. - this.sessionId = sessionId; - } - - return response; - } catch (error) { - console.error( - '[ACP] Session load request failed with exception:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - } - - /** - * Get session list - * - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns Session list response - */ - async listSessions( - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - cwd: string = process.cwd(), - options?: { cursor?: number; size?: number }, - ): Promise { - console.log('[ACP] Requesting session list...'); - try { - // session/list requires cwd in params per ACP schema - const params: Record = { cwd }; - if (options?.cursor !== undefined) { - params.cursor = options.cursor; - } - if (options?.size !== undefined) { - params.size = options.size; - } - - const response = await this.sendRequest( - AGENT_METHODS.session_list, - params, - child, - pendingRequests, - nextRequestId, - ); - console.log( - '[ACP] Session list response:', - JSON.stringify(response).substring(0, 200), - ); - return response; - } catch (error) { - console.error('[ACP] Failed to get session list:', error); - throw error; - } - } - - /** - * Set approval mode for current session (ACP session/set_mode) - * - * @param modeId - Approval mode value - */ - async setMode( - modeId: ApprovalModeValue, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - if (!this.sessionId) { - throw new Error('No active ACP session'); - } - console.log('[ACP] Sending session/set_mode:', modeId); - const res = await this.sendRequest( - AGENT_METHODS.session_set_mode, - { sessionId: this.sessionId, modeId }, - child, - pendingRequests, - nextRequestId, - ); - console.log('[ACP] set_mode response:', res); - return res; - } - - /** - * Set model for current session (ACP session/set_model) - * - * @param modelId - Model ID - */ - async setModel( - modelId: string, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - if (!this.sessionId) { - throw new Error('No active ACP session'); - } - console.log('[ACP] Sending session/set_model:', modelId); - const res = await this.sendRequest( - AGENT_METHODS.session_set_model, - { sessionId: this.sessionId, modelId }, - child, - pendingRequests, - nextRequestId, - ); - console.log('[ACP] set_model response:', res); - return res; - } - - /** - * Switch to specified session - * - * @param sessionId - Session ID - * @param nextRequestId - Request ID counter - * @returns Switch response - */ - async switchSession( - sessionId: string, - nextRequestId: { value: number }, - ): Promise { - console.log('[ACP] Switching to session:', sessionId); - this.sessionId = sessionId; - - const mockResponse: AcpResponse = { - jsonrpc: JSONRPC_VERSION, - id: nextRequestId.value++, - result: { sessionId }, - }; - console.log( - '[ACP] Session ID updated locally (switch not supported by CLI)', - ); - return mockResponse; - } - - /** - * Cancel prompt generation for current session - * - * @param child - Child process instance - */ - async cancelSession(child: ChildProcess | null): Promise { - if (!this.sessionId) { - console.warn('[ACP] No active session to cancel'); - return; - } - - console.log('[ACP] Cancelling session:', this.sessionId); - - const cancelParams = { - sessionId: this.sessionId, - }; - - const message: AcpNotification = { - jsonrpc: JSONRPC_VERSION, - method: AGENT_METHODS.session_cancel, - params: cancelParams, - }; - - this.sendMessage(message, child); - console.log('[ACP] Cancel notification sent'); - } - - /** - * Save current session - * - * @param tag - Save tag - * @param child - Child process instance - * @param pendingRequests - Pending requests map - * @param nextRequestId - Request ID counter - * @returns Save response - */ - async saveSession( - tag: string, - child: ChildProcess | null, - pendingRequests: Map>, - nextRequestId: { value: number }, - ): Promise { - if (!this.sessionId) { - throw new Error('No active ACP session'); - } - - console.log('[ACP] Saving session with tag:', tag); - const response = await this.sendRequest( - AGENT_METHODS.session_save, - { - sessionId: this.sessionId, - tag, - }, - child, - pendingRequests, - nextRequestId, - ); - console.log('[ACP] Session save response:', response); - return response; - } - - /** - * Reset session manager state - */ - reset(): void { - this.sessionId = null; - this.isInitialized = false; - } - - /** - * Get current session ID - */ - getCurrentSessionId(): string | null { - return this.sessionId; - } - - /** - * Check if initialized - */ - getIsInitialized(): boolean { - return this.isInitialized; - } -} diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 0944ee5b7..0cb17a344 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -5,12 +5,12 @@ */ import { AcpConnection } from './acpConnection.js'; import type { - AcpSessionUpdate, - AcpPermissionRequest, - AuthenticateUpdateNotification, ModelInfo, AvailableCommand, -} from '../types/acpTypes.js'; + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionManager } from './qwenSessionManager.js'; @@ -74,9 +74,13 @@ export class QwenAgentManager { this.sessionUpdateHandler = new QwenSessionUpdateHandler({}); // Set ACP connection callbacks - this.connection.onSessionUpdate = (data: AcpSessionUpdate) => { + this.connection.onSessionUpdate = (data: SessionNotification) => { // If we are rehydrating a loaded session, map message chunks into - // full messages for the UI, instead of streaming behavior. + // discrete messages for the UI instead of streaming behavior. + // During rehydration the webview is NOT in streaming mode, so + // streaming-only callbacks (onStreamChunk, onThoughtChunk) would be + // silently dropped by the UI. Route all text-bearing updates through + // onMessage which calls addMessage() regardless of streaming state. try { const targetId = this.rehydratingSessionId; if ( @@ -91,19 +95,18 @@ export class QwenAgentManager { update: { sessionUpdate: string; content?: { text?: string }; - _meta?: { timestamp?: number }; + _meta?: Record; }; } ).update; const text = update?.content?.text || ''; + const metaObj = update?._meta ?? {}; const timestamp = - typeof update?._meta?.timestamp === 'number' - ? update._meta.timestamp + typeof metaObj['timestamp'] === 'number' + ? (metaObj['timestamp'] as number) : Date.now(); + if (update?.sessionUpdate === 'user_message_chunk' && text) { - console.log( - '[QwenAgentManager] Rehydration: routing user message chunk', - ); this.callbacks.onMessage?.({ role: 'user', content: text, @@ -111,10 +114,8 @@ export class QwenAgentManager { }); return; } + if (update?.sessionUpdate === 'agent_message_chunk' && text) { - console.log( - '[QwenAgentManager] Rehydration: routing agent message chunk', - ); this.callbacks.onMessage?.({ role: 'assistant', content: text, @@ -122,10 +123,44 @@ export class QwenAgentManager { }); return; } - // For other types during rehydration, fall through to normal handler - console.log( - '[QwenAgentManager] Rehydration: non-text update, forwarding to handler', - ); + + if (update?.sessionUpdate === 'agent_thought_chunk' && text) { + this.callbacks.onMessage?.({ + role: 'thinking', + content: text, + timestamp, + }); + return; + } + + // Usage-only agent_message_chunk (empty text): forward usage but + // skip the empty stream chunk that would be discarded anyway. + if ( + update?.sessionUpdate === 'agent_message_chunk' && + !text && + metaObj['usage'] + ) { + if (this.callbacks.onUsageUpdate) { + const raw = metaObj['usage'] as Record; + this.callbacks.onUsageUpdate({ + usage: { + inputTokens: raw['inputTokens'] as number | undefined, + outputTokens: raw['outputTokens'] as number | undefined, + totalTokens: raw['totalTokens'] as number | undefined, + thoughtTokens: raw['thoughtTokens'] as number | undefined, + cachedReadTokens: raw['cachedReadTokens'] as + | number + | undefined, + }, + durationMs: metaObj['durationMs'] as number | undefined, + }); + } + return; + } + + // Tool calls, plans, mode/model updates: fall through to the + // normal handler which emits them via dedicated callbacks that + // the webview can process independently of streaming state. } } catch (err) { console.warn('[QwenAgentManager] Rehydration routing failed:', err); @@ -136,7 +171,7 @@ export class QwenAgentManager { }; this.connection.onPermissionRequest = async ( - data: AcpPermissionRequest, + data: RequestPermissionRequest, ) => { if (this.callbacks.onPermissionRequest) { const optionId = await this.callbacks.onPermissionRequest(data); @@ -249,16 +284,9 @@ export class QwenAgentManager { ): Promise { const modeId = mode; try { - const res = await this.connection.setMode(modeId); - // Optimistically notify UI using response - const result = (res?.result || {}) as { modeId?: string }; - const confirmed = - (result.modeId as - | 'plan' - | 'default' - | 'auto-edit' - | 'yolo' - | undefined) || modeId; + await this.connection.setMode(modeId); + // set_mode response has no mode payload; use requested value. + const confirmed = modeId; this.callbacks.onModeChanged?.(confirmed); return confirmed; } catch (err) { @@ -272,10 +300,8 @@ export class QwenAgentManager { */ async setModelFromUi(modelId: string): Promise { try { - const res = await this.connection.setModel(modelId); - // Parse response and notify UI - const result = (res?.result || {}) as { modelId?: string }; - const confirmedModelId = result.modelId || modelId; + await this.connection.setModel(modelId); + const confirmedModelId = modelId; const modelInfo: ModelInfo = { modelId: confirmedModelId, name: confirmedModelId, @@ -338,19 +364,13 @@ export class QwenAgentManager { const response = await this.connection.listSessions(); console.log('[QwenAgentManager] ACP session list response:', response); - // sendRequest resolves with the JSON-RPC "result" directly - // Newer CLI returns an object: { items: [...], nextCursor?, hasMore } - // Older prototypes might return an array. Support both. const res: unknown = response; let items: Array> = []; - // Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC - // "result" directly (not the full AcpResponse). Treat it as unknown - // and carefully narrow before accessing `items` to satisfy strict TS. - if (res && typeof res === 'object' && 'items' in res) { - const itemsValue = (res as { items?: unknown }).items; - items = Array.isArray(itemsValue) - ? (itemsValue as Array>) + if (res && typeof res === 'object' && 'sessions' in res) { + const sessionsValue = (res as { sessions?: unknown }).sessions; + items = Array.isArray(sessionsValue) + ? (sessionsValue as Array>) : []; } @@ -366,7 +386,7 @@ export class QwenAgentManager { title: item.title || item.name || item.prompt || 'Untitled Session', name: item.title || item.name || item.prompt || 'Untitled Session', startTime: item.startTime, - lastUpdated: item.mtime || item.lastUpdated, + lastUpdated: item.updatedAt || item.mtime || item.lastUpdated, messageCount: item.messageCount || 0, projectHash: item.projectHash, filePath: item.filePath, @@ -445,17 +465,14 @@ export class QwenAgentManager { size, ...(cursor !== undefined ? { cursor } : {}), }); - // sendRequest resolves with the JSON-RPC "result" directly const res: unknown = response; let items: Array> = []; - if (Array.isArray(res)) { - items = res; - } else if (typeof res === 'object' && res !== null && 'items' in res) { - const responseObject = res as { - items?: Array>; - }; - items = Array.isArray(responseObject.items) ? responseObject.items : []; + if (res && typeof res === 'object' && 'sessions' in res) { + const sessionsValue = (res as { sessions?: unknown }).sessions; + items = Array.isArray(sessionsValue) + ? (sessionsValue as Array>) + : []; } const mapped = items.map((item) => ({ @@ -464,25 +481,29 @@ export class QwenAgentManager { title: item.title || item.name || item.prompt || 'Untitled Session', name: item.title || item.name || item.prompt || 'Untitled Session', startTime: item.startTime, - lastUpdated: item.mtime || item.lastUpdated, + lastUpdated: item.updatedAt || item.mtime || item.lastUpdated, messageCount: item.messageCount || 0, projectHash: item.projectHash, filePath: item.filePath, cwd: item.cwd, })); - const nextCursor: number | undefined = - typeof res === 'object' && res !== null && 'nextCursor' in res - ? typeof res.nextCursor === 'number' - ? res.nextCursor - : undefined - : undefined; - const hasMore: boolean = - typeof res === 'object' && res !== null && 'hasMore' in res - ? Boolean(res.hasMore) - : false; + // SDK returns nextCursor as string; convert to numeric cursor for paging + let nextCursorNum: number | undefined; + if (typeof res === 'object' && res !== null && 'nextCursor' in res) { + const raw = (res as { nextCursor?: unknown }).nextCursor; + if (typeof raw === 'number') { + nextCursorNum = raw; + } else if (typeof raw === 'string') { + const parsed = Number(raw); + if (!Number.isNaN(parsed)) { + nextCursorNum = parsed; + } + } + } + const hasMore = nextCursorNum !== undefined; - return { sessions: mapped, nextCursor, hasMore }; + return { sessions: mapped, nextCursor: nextCursorNum, hasMore }; } catch (error) { console.warn('[QwenAgentManager] Paged ACP session list failed:', error); // fall through to file system @@ -893,63 +914,6 @@ export class QwenAgentManager { } } - /** - * Save session via /chat save command - * Since CLI doesn't support session/save ACP method, we send /chat save command directly - * - * @param sessionId - Session ID - * @param tag - Save tag - * @returns Save response - */ - async saveSessionViaCommand( - sessionId: string, - tag: string, - ): Promise<{ success: boolean; message?: string }> { - try { - console.log( - '[QwenAgentManager] Saving session via /chat save command:', - sessionId, - 'with tag:', - tag, - ); - - // Send /chat save command as a prompt - // The CLI will handle this as a special command - await this.connection.sendPrompt(`/chat save "${tag}"`); - - console.log('[QwenAgentManager] /chat save command sent successfully'); - return { - success: true, - message: `Session saved with tag: ${tag}`, - }; - } catch (error) { - console.error('[QwenAgentManager] /chat save command failed:', error); - return { - success: false, - message: error instanceof Error ? error.message : String(error), - }; - } - } - - /** - * Save session via ACP session/save method (deprecated, CLI doesn't support) - * - * @deprecated Use saveSessionViaCommand instead - * @param sessionId - Session ID - * @param tag - Save tag - * @returns Save response - */ - async saveSessionViaAcp( - sessionId: string, - tag: string, - ): Promise<{ success: boolean; message?: string }> { - // Fallback to command-based save since CLI doesn't support session/save ACP method - console.warn( - '[QwenAgentManager] saveSessionViaAcp is deprecated, using command-based save instead', - ); - return this.saveSessionViaCommand(sessionId, tag); - } - /** * Try to load session via ACP session/load method * This method will only be used if CLI version supports it @@ -980,6 +944,20 @@ export class QwenAgentManager { '[QwenAgentManager] Session load succeeded. Response:', JSON.stringify(response).substring(0, 200), ); + + // Extract model/mode state from load response (same shape as newSession) + const modelInfo = extractModelInfoFromNewSessionResult(response); + if (modelInfo && this.callbacks.onModelInfo) { + this.callbacks.onModelInfo(modelInfo); + } + const modelState = extractSessionModelState(response); + if ( + modelState?.availableModels && + modelState.availableModels.length > 0 + ) { + this.callbacks.onAvailableModels?.(modelState.availableModels); + } + return response; } catch (error) { const errorMessage = @@ -1307,7 +1285,7 @@ export class QwenAgentManager { * @param callback - Permission request callback function */ onPermissionRequest( - callback: (request: AcpPermissionRequest) => Promise, + callback: (request: RequestPermissionRequest) => Promise, ): void { this.callbacks.onPermissionRequest = callback; this.sessionUpdateHandler.updateCallbacks(this.callbacks); @@ -1367,7 +1345,7 @@ export class QwenAgentManager { } /** - * Register callback for model changed updates (from ACP current_model_update) + * Register callback for model changed updates. */ onModelChanged(callback: (model: ModelInfo) => void): void { this.callbacks.onModelChanged = callback; diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 9b4a188c8..694932871 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -17,7 +17,7 @@ import { extractModelInfoFromNewSessionResult, extractSessionModelState, } from '../utils/acpModelInfo.js'; -import type { ModelInfo } from '../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; export interface QwenConnectionResult { sessionCreated: boolean; diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index a5e817cad..48a219ad9 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -9,17 +9,16 @@ import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; -import type { QwenSession, QwenMessage } from './qwenSessionReader.js'; +import type { QwenSession } from './qwenSessionReader.js'; /** * Qwen Session Manager * - * This service provides direct filesystem access to save and load sessions - * without relying on the CLI's ACP session/save method. + * This service provides direct filesystem access to load sessions. * - * Note: This is primarily used as a fallback mechanism when ACP methods are - * unavailable or fail. In normal operation, ACP session/list and session/load - * should be preferred for consistency with the CLI. + * Note: Sessions are auto-saved by the CLI's ChatRecordingService. + * This class is primarily used as a fallback mechanism for loading sessions + * when ACP methods are unavailable or fail. */ export class QwenSessionManager { private qwenDir: string; @@ -44,60 +43,6 @@ export class QwenSessionManager { return crypto.randomUUID(); } - /** - * Save current conversation as a named session - * - * @param messages - Current conversation messages - * @param sessionName - Name/tag for the saved session - * @param workingDir - Current working directory - * @returns Session ID of the saved session - */ - async saveSession( - messages: QwenMessage[], - sessionName: string, - workingDir: string, - ): Promise { - try { - // Create session directory if it doesn't exist - const sessionDir = this.getSessionDir(workingDir); - if (!fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }); - } - - // Generate session ID and filename using CLI's naming convention - const sessionId = this.generateSessionId(); - const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars) - const now = new Date(); - const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD - const isoTime = now - .toISOString() - .split('T')[1] - .split(':') - .slice(0, 2) - .join('-'); // HH-MM - const filename = `session-${isoDate}T${isoTime}-${shortId}.json`; - const filePath = path.join(sessionDir, filename); - - // Create session object - const session: QwenSession = { - sessionId, - projectHash: getProjectHash(workingDir), - startTime: messages[0]?.timestamp || new Date().toISOString(), - lastUpdated: new Date().toISOString(), - messages, - }; - - // Save session to file - fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8'); - - console.log(`[QwenSessionManager] Session saved: ${filePath}`); - return sessionId; - } catch (error) { - console.error('[QwenSessionManager] Failed to save session:', error); - throw error; - } - } - /** * Load a saved session by name * diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts index dc84199e8..ab2e34179 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; -import type { AcpSessionUpdate } from '../types/acpTypes.js'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { QwenAgentCallbacks } from '../types/chatTypes.js'; @@ -28,81 +28,15 @@ describe('QwenSessionUpdateHandler', () => { handler = new QwenSessionUpdateHandler(mockCallbacks); }); - describe('current_model_update handling', () => { - it('calls onModelChanged callback with model info', () => { - const modelUpdate: AcpSessionUpdate = { - sessionId: 'test-session', - update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'qwen3-coder-plus', - name: 'Qwen3 Coder Plus', - description: 'A powerful coding model', - }, - }, - } as AcpSessionUpdate; - - handler.handleSessionUpdate(modelUpdate); - - expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({ - modelId: 'qwen3-coder-plus', - name: 'Qwen3 Coder Plus', - description: 'A powerful coding model', - }); - }); - - it('handles model update with _meta field', () => { - const modelUpdate: AcpSessionUpdate = { - sessionId: 'test-session', - update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'test-model', - name: 'Test Model', - _meta: { contextLimit: 128000 }, - }, - }, - } as AcpSessionUpdate; - - handler.handleSessionUpdate(modelUpdate); - - expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({ - modelId: 'test-model', - name: 'Test Model', - _meta: { contextLimit: 128000 }, - }); - }); - - it('does not call callback when onModelChanged is not set', () => { - const handlerWithoutCallback = new QwenSessionUpdateHandler({}); - - const modelUpdate: AcpSessionUpdate = { - sessionId: 'test-session', - update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'qwen3-coder', - name: 'Qwen3 Coder', - }, - }, - } as AcpSessionUpdate; - - // Should not throw - expect(() => - handlerWithoutCallback.handleSessionUpdate(modelUpdate), - ).not.toThrow(); - }); - }); - describe('current_mode_update handling', () => { it('calls onModeChanged callback with mode id', () => { - const modeUpdate: AcpSessionUpdate = { + const modeUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'current_mode_update', - modeId: 'auto-edit' as ApprovalModeValue, + currentModeId: 'auto-edit' as ApprovalModeValue, }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(modeUpdate); @@ -112,7 +46,7 @@ describe('QwenSessionUpdateHandler', () => { describe('agent_message_chunk handling', () => { it('calls onStreamChunk callback with text content', () => { - const messageUpdate: AcpSessionUpdate = { + const messageUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'agent_message_chunk', @@ -129,7 +63,7 @@ describe('QwenSessionUpdateHandler', () => { }); it('emits usage metadata when present', () => { - const messageUpdate: AcpSessionUpdate = { + const messageUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'agent_message_chunk', @@ -152,18 +86,66 @@ describe('QwenSessionUpdateHandler', () => { expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({ usage: { + inputTokens: 100, + outputTokens: 50, + thoughtTokens: undefined, + totalTokens: 150, + cachedReadTokens: undefined, + cachedWriteTokens: undefined, promptTokens: 100, completionTokens: 50, - totalTokens: 150, + thoughtsTokens: undefined, + cachedTokens: undefined, }, durationMs: 1234, }); }); + + it('maps SDK usage field names to both SDK and legacy fields', () => { + const messageUpdate: SessionNotification = { + sessionId: 'test-session', + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'Response', + }, + _meta: { + usage: { + inputTokens: 200, + outputTokens: 80, + thoughtTokens: 30, + totalTokens: 310, + cachedReadTokens: 10, + } as never, + durationMs: 500, + }, + }, + }; + + handler.handleSessionUpdate(messageUpdate); + + expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({ + usage: { + inputTokens: 200, + outputTokens: 80, + thoughtTokens: 30, + totalTokens: 310, + cachedReadTokens: 10, + cachedWriteTokens: undefined, + promptTokens: 200, + completionTokens: 80, + thoughtsTokens: 30, + cachedTokens: 10, + }, + durationMs: 500, + }); + }); }); describe('tool_call handling', () => { it('calls onToolCall callback with tool call data', () => { - const toolCallUpdate: AcpSessionUpdate = { + const toolCallUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'tool_call', @@ -191,7 +173,7 @@ describe('QwenSessionUpdateHandler', () => { describe('plan handling', () => { it('calls onPlan callback with plan entries', () => { - const planUpdate: AcpSessionUpdate = { + const planUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'plan', @@ -215,7 +197,7 @@ describe('QwenSessionUpdateHandler', () => { onStreamChunk: vi.fn(), }); - const planUpdate: AcpSessionUpdate = { + const planUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'plan', @@ -231,7 +213,7 @@ describe('QwenSessionUpdateHandler', () => { describe('available_commands_update handling', () => { it('calls onAvailableCommands callback with commands', () => { - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -253,7 +235,7 @@ describe('QwenSessionUpdateHandler', () => { }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); @@ -269,7 +251,7 @@ describe('QwenSessionUpdateHandler', () => { }); it('handles commands with input hint', () => { - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -281,7 +263,7 @@ describe('QwenSessionUpdateHandler', () => { }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); @@ -297,7 +279,7 @@ describe('QwenSessionUpdateHandler', () => { it('does not call callback when onAvailableCommands is not set', () => { const handlerWithoutCallback = new QwenSessionUpdateHandler({}); - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -305,7 +287,7 @@ describe('QwenSessionUpdateHandler', () => { { name: 'compress', description: 'Compress', input: null }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; // Should not throw expect(() => @@ -314,13 +296,13 @@ describe('QwenSessionUpdateHandler', () => { }); it('handles empty commands list', () => { - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', availableCommands: [], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); @@ -329,28 +311,25 @@ describe('QwenSessionUpdateHandler', () => { }); describe('updateCallbacks', () => { - it('updates callbacks and uses new ones', () => { - const newOnModelChanged = vi.fn(); + it('updates mode callback and uses new one', () => { + const newOnModeChanged = vi.fn(); handler.updateCallbacks({ ...mockCallbacks, - onModelChanged: newOnModelChanged, + onModeChanged: newOnModeChanged, }); - const modelUpdate: AcpSessionUpdate = { + const modeUpdate: SessionNotification = { sessionId: 'test-session', update: { - sessionUpdate: 'current_model_update', - model: { - modelId: 'new-model', - name: 'New Model', - }, + sessionUpdate: 'current_mode_update', + currentModeId: 'yolo' as ApprovalModeValue, }, - } as AcpSessionUpdate; + } as SessionNotification; - handler.handleSessionUpdate(modelUpdate); + handler.handleSessionUpdate(modeUpdate); - expect(newOnModelChanged).toHaveBeenCalled(); - expect(mockCallbacks.onModelChanged).not.toHaveBeenCalled(); + expect(newOnModeChanged).toHaveBeenCalled(); + expect(mockCallbacks.onModeChanged).not.toHaveBeenCalled(); }); it('updates onAvailableCommands callback', () => { @@ -360,7 +339,7 @@ describe('QwenSessionUpdateHandler', () => { onAvailableCommands: newOnAvailableCommands, }); - const commandsUpdate: AcpSessionUpdate = { + const commandsUpdate: SessionNotification = { sessionId: 'test-session', update: { sessionUpdate: 'available_commands_update', @@ -368,7 +347,7 @@ describe('QwenSessionUpdateHandler', () => { { name: 'test', description: 'Test command', input: null }, ], }, - } as AcpSessionUpdate; + } as SessionNotification; handler.handleSessionUpdate(commandsUpdate); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 2000003fd..06e03d454 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -11,11 +11,10 @@ */ import type { - AcpSessionUpdate, - SessionUpdateMeta, - ModelInfo, + SessionNotification, AvailableCommand, -} from '../types/acpTypes.js'; +} from '@agentclientprotocol/sdk'; +import type { SessionUpdateMeta } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { QwenAgentCallbacks, @@ -47,41 +46,58 @@ export class QwenSessionUpdateHandler { * * @param data - ACP session update data */ - handleSessionUpdate(data: AcpSessionUpdate): void { + handleSessionUpdate(data: SessionNotification): void { const update = data.update; + const sessionUpdate = (update as { sessionUpdate?: string }).sessionUpdate; console.log( '[SessionUpdateHandler] Processing update type:', - update.sessionUpdate, + sessionUpdate, ); - switch (update.sessionUpdate) { - case 'user_message_chunk': - if (update.content?.text && this.callbacks.onStreamChunk) { - this.callbacks.onStreamChunk(update.content.text); + switch (sessionUpdate) { + case 'user_message_chunk': { + const text = this.getTextContent( + (update as { content?: unknown }).content, + ); + if (text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(text); } break; + } - case 'agent_message_chunk': - if (update.content?.text && this.callbacks.onStreamChunk) { - this.callbacks.onStreamChunk(update.content.text); + case 'agent_message_chunk': { + const text = this.getTextContent( + (update as { content?: unknown }).content, + ); + if (text && this.callbacks.onStreamChunk) { + this.callbacks.onStreamChunk(text); } - this.emitUsageMeta(update._meta); + this.emitUsageMeta( + (update as { _meta?: SessionUpdateMeta | null })._meta, + ); break; + } - case 'agent_thought_chunk': - if (update.content?.text) { + case 'agent_thought_chunk': { + const text = this.getTextContent( + (update as { content?: unknown }).content, + ); + if (text) { if (this.callbacks.onThoughtChunk) { - this.callbacks.onThoughtChunk(update.content.text); + this.callbacks.onThoughtChunk(text); } else if (this.callbacks.onStreamChunk) { // Fallback to regular stream processing console.log( '[SessionUpdateHandler] 🧠 Falling back to onStreamChunk', ); - this.callbacks.onStreamChunk(update.content.text); + this.callbacks.onStreamChunk(text); } } - this.emitUsageMeta(update._meta); + this.emitUsageMeta( + (update as { _meta?: SessionUpdateMeta | null })._meta, + ); break; + } case 'tool_call': { // Handle new tool call @@ -159,8 +175,9 @@ export class QwenSessionUpdateHandler { case 'current_mode_update': { // Notify UI about mode change try { - const modeId = (update as unknown as { modeId?: ApprovalModeValue }) - .modeId; + const modeId = ( + update as unknown as { currentModeId?: ApprovalModeValue } + ).currentModeId; if (modeId && this.callbacks.onModeChanged) { this.callbacks.onModeChanged(modeId); } @@ -173,22 +190,6 @@ export class QwenSessionUpdateHandler { break; } - case 'current_model_update': { - // Notify UI about model change - try { - const model = (update as unknown as { model?: ModelInfo }).model; - if (model && this.callbacks.onModelChanged) { - this.callbacks.onModelChanged(model); - } - } catch (err) { - console.warn( - '[SessionUpdateHandler] Failed to handle model update', - err, - ); - } - break; - } - case 'available_commands_update': { // Notify UI about available commands try { @@ -213,13 +214,58 @@ export class QwenSessionUpdateHandler { } } - private emitUsageMeta(meta?: SessionUpdateMeta): void { + private getTextContent(content: unknown): string | undefined { + if (!content || typeof content !== 'object') { + return undefined; + } + const text = (content as { text?: unknown }).text; + return typeof text === 'string' ? text : undefined; + } + + private emitUsageMeta(meta?: SessionUpdateMeta | null): void { if (!meta || !this.callbacks.onUsageUpdate) { return; } + const raw = meta.usage as Record | null | undefined; + const usage = raw + ? { + // SDK field names + inputTokens: + (raw['inputTokens'] as number | null | undefined) ?? + (raw['promptTokens'] as number | null | undefined), + outputTokens: + (raw['outputTokens'] as number | null | undefined) ?? + (raw['completionTokens'] as number | null | undefined), + thoughtTokens: + (raw['thoughtTokens'] as number | null | undefined) ?? + (raw['thoughtsTokens'] as number | null | undefined), + totalTokens: raw['totalTokens'] as number | null | undefined, + cachedReadTokens: + (raw['cachedReadTokens'] as number | null | undefined) ?? + (raw['cachedTokens'] as number | null | undefined), + cachedWriteTokens: raw['cachedWriteTokens'] as + | number + | null + | undefined, + // Legacy compat + promptTokens: + (raw['promptTokens'] as number | null | undefined) ?? + (raw['inputTokens'] as number | null | undefined), + completionTokens: + (raw['completionTokens'] as number | null | undefined) ?? + (raw['outputTokens'] as number | null | undefined), + thoughtsTokens: + (raw['thoughtsTokens'] as number | null | undefined) ?? + (raw['thoughtTokens'] as number | null | undefined), + cachedTokens: + (raw['cachedTokens'] as number | null | undefined) ?? + (raw['cachedReadTokens'] as number | null | undefined), + } + : undefined; + const payload: UsageStatsPayload = { - usage: meta.usage || undefined, + usage, durationMs: meta.durationMs ?? undefined, }; diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 14304a386..e22e8a726 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -3,177 +3,33 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ + +import type { Usage } from '@agentclientprotocol/sdk'; + import type { ApprovalModeValue } from './approvalModeValueTypes.js'; -export const JSONRPC_VERSION = '2.0' as const; +// --------------------------------------------------------------------------- +// Private / Qwen-specific types (not part of ACP spec) +// --------------------------------------------------------------------------- + export const authMethod = 'qwen-oauth'; -export interface AcpRequest { - jsonrpc: typeof JSONRPC_VERSION; - id: number; - method: string; - params?: unknown; -} - -export interface AcpResponse { - jsonrpc: typeof JSONRPC_VERSION; - id: number; - result?: unknown; - capabilities?: { - [key: string]: unknown; +/** + * Authenticate update notification (Qwen extension, not ACP spec). + * Sent by agent during the OAuth flow. + */ +export interface AuthenticateUpdateNotification { + _meta: { + authUri: string; }; - error?: { - code: number; - message: string; - data?: unknown; - }; -} - -export interface AcpNotification { - jsonrpc: typeof JSONRPC_VERSION; - method: string; - params?: unknown; -} - -export interface BaseSessionUpdate { - sessionId: string; -} - -// Content block type (simplified version, use schema.ContentBlock for validation) -export interface ContentBlock { - type: 'text' | 'image'; - text?: string; - data?: string; - mimeType?: string; - uri?: string; -} - -export interface UsageMetadata { - promptTokens?: number | null; - completionTokens?: number | null; - thoughtsTokens?: number | null; - totalTokens?: number | null; - cachedTokens?: number | null; } export interface SessionUpdateMeta { - usage?: UsageMetadata | null; + usage?: Usage | null; durationMs?: number | null; timestamp?: number | null; } -export type AcpMeta = Record; -export type ModelId = string; - -export interface ModelInfo { - _meta?: AcpMeta | null; - description?: string | null; - modelId: ModelId; - name: string; -} - -export interface SessionModelState { - _meta?: AcpMeta | null; - availableModels: ModelInfo[]; - currentModelId: ModelId; -} - -export interface UserMessageChunkUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'user_message_chunk'; - content: ContentBlock; - _meta?: SessionUpdateMeta; - }; -} - -export interface AgentMessageChunkUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'agent_message_chunk'; - content: ContentBlock; - _meta?: SessionUpdateMeta; - }; -} - -export interface AgentThoughtChunkUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'agent_thought_chunk'; - content: ContentBlock; - _meta?: SessionUpdateMeta; - }; -} - -export interface ToolCallUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'tool_call'; - toolCallId: string; - status: 'pending' | 'in_progress' | 'completed' | 'failed'; - title: string; - kind: - | 'read' - | 'edit' - | 'execute' - | 'delete' - | 'move' - | 'search' - | 'fetch' - | 'think' - | 'other'; - rawInput?: unknown; - content?: Array<{ - type: 'content' | 'diff'; - content?: { - type: 'text'; - text: string; - }; - path?: string; - oldText?: string | null; - newText?: string; - }>; - locations?: Array<{ - path: string; - line?: number | null; - }>; - _meta?: SessionUpdateMeta; - }; -} - -export interface ToolCallStatusUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'tool_call_update'; - toolCallId: string; - status?: 'pending' | 'in_progress' | 'completed' | 'failed'; - title?: string; - kind?: string; - rawInput?: unknown; - content?: Array<{ - type: 'content' | 'diff'; - content?: { - type: 'text'; - text: string; - }; - path?: string; - oldText?: string | null; - newText?: string; - }>; - locations?: Array<{ - path: string; - line?: number | null; - }>; - _meta?: SessionUpdateMeta; - }; -} - -export interface PlanUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'plan'; - entries: Array<{ - content: string; - priority: 'high' | 'medium' | 'low'; - status: 'pending' | 'in_progress' | 'completed'; - }>; - }; -} - export { ApprovalMode, APPROVAL_MODE_MAP, @@ -181,91 +37,11 @@ export { getApprovalModeInfoFromString, } from './approvalModeTypes.js'; -// Cyclic next-mode mapping used by UI toggles and other consumers export const NEXT_APPROVAL_MODE: { [k in ApprovalModeValue]: ApprovalModeValue; } = { - // Hide "plan" from the public toggle sequence for now - // Cycle: default -> auto-edit -> yolo -> default default: 'auto-edit', 'auto-edit': 'yolo', plan: 'yolo', yolo: 'default', }; - -// Current mode update (sent by agent when mode changes) -export interface CurrentModeUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'current_mode_update'; - modeId: ApprovalModeValue; - }; -} - -// Current model update (sent by agent when model changes) -export interface CurrentModelUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'current_model_update'; - model: ModelInfo; - }; -} - -// Available command definition -export interface AvailableCommand { - name: string; - description: string; - input?: { - hint?: string; - } | null; -} - -// Available commands update (sent by agent after session creation) -export interface AvailableCommandsUpdate extends BaseSessionUpdate { - update: { - sessionUpdate: 'available_commands_update'; - availableCommands: AvailableCommand[]; - }; -} - -// Authenticate update (sent by agent during authentication process) -export interface AuthenticateUpdateNotification { - _meta: { - authUri: string; - }; -} - -export type AcpSessionUpdate = - | UserMessageChunkUpdate - | AgentMessageChunkUpdate - | AgentThoughtChunkUpdate - | ToolCallUpdate - | ToolCallStatusUpdate - | PlanUpdate - | CurrentModeUpdate - | CurrentModelUpdate - | AvailableCommandsUpdate; - -// Permission request (simplified version, use schema.RequestPermissionRequest for validation) -export interface AcpPermissionRequest { - sessionId: string; - options: Array<{ - optionId: string; - name: string; - kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; - }>; - toolCall: { - toolCallId: string; - rawInput?: { - command?: string; - description?: string; - [key: string]: unknown; - }; - title?: string; - kind?: string; - }; -} - -export type AcpMessage = - | AcpRequest - | AcpNotification - | AcpResponse - | AcpSessionUpdate; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index b92cb35e5..84c7bb9f8 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { - AcpPermissionRequest, ModelInfo, AvailableCommand, -} from './acpTypes.js'; + RequestPermissionRequest, +} from '@agentclientprotocol/sdk'; import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { - role: 'user' | 'assistant'; + role: 'user' | 'assistant' | 'thinking'; content: string; timestamp: number; } @@ -35,10 +35,17 @@ export interface ToolCallUpdateData { export interface UsageStatsPayload { usage?: { + // SDK field names (primary) + inputTokens?: number | null; + outputTokens?: number | null; + thoughtTokens?: number | null; + totalTokens?: number | null; + cachedReadTokens?: number | null; + cachedWriteTokens?: number | null; + // Legacy field names (compat with older CLI builds) promptTokens?: number | null; completionTokens?: number | null; thoughtsTokens?: number | null; - totalTokens?: number | null; cachedTokens?: number | null; } | null; durationMs?: number | null; @@ -51,7 +58,7 @@ export interface QwenAgentCallbacks { onThoughtChunk?: (chunk: string) => void; onToolCall?: (update: ToolCallUpdateData) => void; onPlan?: (entries: PlanEntry[]) => void; - onPermissionRequest?: (request: AcpPermissionRequest) => Promise; + onPermissionRequest?: (request: RequestPermissionRequest) => Promise; onEndTurn?: (reason?: string) => void; onModeInfo?: (info: { currentModeId?: ApprovalModeValue; diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts index 7ada3aedf..1f4fec2ae 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -6,10 +6,10 @@ import type { ChildProcess } from 'child_process'; import type { - AcpSessionUpdate, - AcpPermissionRequest, - AuthenticateUpdateNotification, -} from './acpTypes.js'; + RequestPermissionRequest, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import type { AuthenticateUpdateNotification } from './acpTypes.js'; export interface PendingRequest { resolve: (value: T) => void; @@ -19,8 +19,8 @@ export interface PendingRequest { } export interface AcpConnectionCallbacks { - onSessionUpdate: (data: AcpSessionUpdate) => void; - onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ + onSessionUpdate: (data: SessionNotification) => void; + onPermissionRequest: (data: RequestPermissionRequest) => Promise<{ optionId: string; }>; onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void; diff --git a/packages/vscode-ide-companion/src/utils/acpModelInfo.ts b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts index 45df8aa0c..cd598f7db 100644 --- a/packages/vscode-ide-companion/src/utils/acpModelInfo.ts +++ b/packages/vscode-ide-companion/src/utils/acpModelInfo.ts @@ -4,7 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AcpMeta, ModelInfo } from '../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; + +type AcpMeta = Record; const asMeta = (value: unknown): AcpMeta | null | undefined => { if (value === null) { diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8d2c0bfed..cb1409af1 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -44,7 +44,7 @@ import { InputForm } from './components/layout/InputForm.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '../types/acpTypes.js'; +import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import { DEFAULT_TOKEN_LIMIT, tokenLimit, diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index a202fffd9..eaf71f717 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -7,8 +7,10 @@ import * as vscode from 'vscode'; import { QwenAgentManager } from '../services/qwenAgentManager.js'; import { ConversationStore } from '../services/conversationStore.js'; -import type { AcpPermissionRequest } from '../types/acpTypes.js'; -import type { ModelInfo } from '../types/acpTypes.js'; +import type { + RequestPermissionRequest, + ModelInfo, +} from '@agentclientprotocol/sdk'; import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; import { PanelManager } from '../webview/PanelManager.js'; import { MessageHandler } from '../webview/MessageHandler.js'; @@ -27,7 +29,7 @@ export class WebViewProvider { // Track a pending permission request and its resolver so extension commands // can "simulate" user choice from the command palette (e.g. after accepting // a diff, auto-allow read/execute, or auto-reject on cancel). - private pendingPermissionRequest: AcpPermissionRequest | null = null; + private pendingPermissionRequest: RequestPermissionRequest | null = null; private pendingPermissionResolve: ((optionId: string) => void) | null = null; // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; @@ -137,7 +139,7 @@ export class WebViewProvider { }); }); - // Surface model changes (from ACP current_model_update or set_model response) + // Surface model changes (primarily from set_model response path) this.agentManager.onModelChanged((model) => { this.sendMessageToWebView({ type: 'modelChanged', @@ -218,7 +220,7 @@ export class WebViewProvider { }); this.agentManager.onPermissionRequest( - async (request: AcpPermissionRequest) => { + async (request: RequestPermissionRequest) => { // Auto-approve in auto/yolo mode (no UI, no diff) if (this.isAutoMode()) { const options = request.options || []; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 58163b691..cb747aff3 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -15,7 +15,7 @@ import type { } from '@qwen-code/webui'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; -import type { ModelInfo } from '../../../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; import { ModelSelector } from './ModelSelector.js'; /** diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx index 3d594f435..ebc1c2853 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/ModelSelector.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { FC } from 'react'; -import type { ModelInfo } from '../../../types/acpTypes.js'; +import type { ModelInfo } from '@agentclientprotocol/sdk'; import { PlanCompletedIcon } from '@qwen-code/webui'; interface ModelSelectorProps { diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 72278d62e..868838a1d 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -27,7 +27,6 @@ export class SessionMessageHandler extends BaseMessageHandler { 'newQwenSession', 'switchQwenSession', 'getQwenSessions', - 'saveSession', 'resumeSession', 'cancelStreaming', // UI action: open a new chat tab (new WebviewPanel) @@ -87,10 +86,6 @@ export class SessionMessageHandler extends BaseMessageHandler { ); break; - case 'saveSession': - await this.handleSaveSession((data?.tag as string) || ''); - break; - case 'resumeSession': await this.handleResumeSession((data?.sessionId as string) || ''); break; @@ -822,87 +817,6 @@ export class SessionMessageHandler extends BaseMessageHandler { } } - /** - * Handle save session request - */ - private async handleSaveSession(tag: string): Promise { - try { - if (!this.currentConversationId) { - throw new Error('No active conversation to save'); - } - - // Try ACP save first - try { - const response = await this.agentManager.saveSessionViaAcp( - this.currentConversationId, - tag, - ); - - this.sendToWebView({ - type: 'saveSessionResponse', - data: response, - }); - } catch (acpError) { - // Safely convert error to string - const errorMsg = acpError ? String(acpError) : 'Unknown error'; - // Check for authentication/session expiration errors - if ( - errorMsg.includes('Authentication required') || - errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) || - errorMsg.includes('Unauthorized') || - errorMsg.includes('Invalid token') || - errorMsg.includes('No active ACP session') - ) { - // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to save sessions.', - ); - - // Send a specific error to the webview for better UI handling - this.sendToWebView({ - type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, - }); - return; - } - } - - await this.handleGetQwenSessions(); - } catch (error) { - console.error('[SessionMessageHandler] Failed to save session:', error); - - // Safely convert error to string - const errorMsg = error ? String(error) : 'Unknown error'; - // Check for authentication/session expiration errors - if ( - errorMsg.includes('Authentication required') || - errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) || - errorMsg.includes('Unauthorized') || - errorMsg.includes('Invalid token') || - errorMsg.includes('No active ACP session') - ) { - // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to save sessions.', - ); - - // Send a specific error to the webview for better UI handling - this.sendToWebView({ - type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, - }); - } else { - this.sendToWebView({ - type: 'saveSessionResponse', - data: { - success: false, - message: `Failed to save session: ${error}`, - }, - }); - } - } - } - /** * Handle cancel streaming request */ diff --git a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts index 17fde331f..507da7e2a 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/message/useMessageHandling.ts @@ -107,24 +107,17 @@ export const useMessageHandling = () => { streamingMessageIndexRef.current = null; }, []); + const breakThinkingSegment = useCallback(() => { + thinkingMessageIndexRef.current = null; + }, []); + /** * End streaming response */ const endStreaming = useCallback(() => { - // Finalize streaming; content already lives in the placeholder message setIsStreaming(false); streamingMessageIndexRef.current = null; - // Remove the thinking message if it exists (collapse thoughts) - setMessages((prev) => { - const idx = thinkingMessageIndexRef.current; - thinkingMessageIndexRef.current = null; - if (idx === null || idx < 0 || idx >= prev.length) { - return prev; - } - const next = prev.slice(); - next.splice(idx, 1); - return next; - }); + thinkingMessageIndexRef.current = null; }, []); /** @@ -178,18 +171,10 @@ export const useMessageHandling = () => { }); }, clearThinking: () => { - setMessages((prev) => { - const idx = thinkingMessageIndexRef.current; - thinkingMessageIndexRef.current = null; - if (idx === null || idx < 0 || idx >= prev.length) { - return prev; - } - const next = prev.slice(); - next.splice(idx, 1); - return next; - }); + thinkingMessageIndexRef.current = null; }, breakAssistantSegment, + breakThinkingSegment, setWaitingForResponse, clearWaitingForResponse, setMessages, diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts index 9fba4a803..294836e77 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -20,7 +20,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { useState('Past Conversations'); const [showSessionSelector, setShowSessionSelector] = useState(false); const [sessionSearchQuery, setSessionSearchQuery] = useState(''); - const [savedSessionTags, setSavedSessionTags] = useState([]); const [nextCursor, setNextCursor] = useState(undefined); const [hasMore, setHasMore] = useState(true); const [isLoading, setIsLoading] = useState(false); @@ -97,38 +96,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { [currentSessionId, vscode], ); - /** - * Save session - */ - const handleSaveSession = useCallback( - (tag: string) => { - vscode.postMessage({ - type: 'saveSession', - data: { tag }, - }); - }, - [vscode], - ); - - /** - * Handle Save session response - */ - const handleSaveSessionResponse = useCallback( - (response: { success: boolean; message?: string }) => { - if (response.success) { - if (response.message) { - const tagMatch = response.message.match(/tag: (.+)$/); - if (tagMatch) { - setSavedSessionTags((prev) => [...prev, tagMatch[1]]); - } - } - } else { - console.error('Failed to save session:', response.message); - } - }, - [], - ); - return { // State qwenSessions, @@ -137,7 +104,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { showSessionSelector, sessionSearchQuery, filteredSessions, - savedSessionTags, nextCursor, hasMore, isLoading, @@ -148,7 +114,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { setCurrentSessionTitle, setShowSessionSelector, setSessionSearchQuery, - setSavedSessionTags, setNextCursor, setHasMore, setIsLoading, @@ -157,8 +122,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { handleLoadQwenSessions, handleNewQwenSession, handleSwitchSession, - handleSaveSession, - handleSaveSessionResponse, handleLoadMoreSessions, }; }; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 30a1166b0..0658aee20 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -14,7 +14,7 @@ import type { } from '../../types/chatTypes.js'; import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; -import type { ModelInfo, AvailableCommand } from '../../types/acpTypes.js'; +import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; const FORCE_CLEAR_STREAM_END_REASONS = new Set([ 'user_cancelled', @@ -41,10 +41,6 @@ interface UseWebViewMessagesProps { setNextCursor: (cursor: number | undefined) => void; setHasMore: (hasMore: boolean) => void; setIsLoading: (loading: boolean) => void; - handleSaveSessionResponse: (response: { - success: boolean; - message?: string; - }) => void; }; // File context @@ -91,6 +87,7 @@ interface UseWebViewMessagesProps { appendStreamChunk: (chunk: string) => void; endStreaming: () => void; breakAssistantSegment: () => void; + breakThinkingSegment: () => void; appendThinkingChunk: (chunk: string) => void; clearThinking: () => void; setWaitingForResponse: (message: string) => void; @@ -612,6 +609,7 @@ export const useWebViewMessages = ({ // Split assistant stream so subsequent chunks start a new assistant message handlers.messageHandling.breakAssistantSegment(); + handlers.messageHandling.breakThinkingSegment(); } break; } @@ -686,6 +684,7 @@ export const useWebViewMessages = ({ // Split assistant message segments, keep rendering blocks independent handlers.messageHandling.breakAssistantSegment?.(); + handlers.messageHandling.breakThinkingSegment?.(); } catch (_error) { console.warn( '[useWebViewMessages] failed to push/merge plan snapshot toolcall:', @@ -711,6 +710,7 @@ export const useWebViewMessages = ({ (status === 'completed' || status === 'failed'); if (isStart || isFinalUpdate) { handlers.messageHandling.breakAssistantSegment(); + handlers.messageHandling.breakThinkingSegment(); } // While long-running tools (e.g., execute/bash/command) are in progress, @@ -935,11 +935,6 @@ export const useWebViewMessages = ({ break; } - case 'saveSessionResponse': { - handlers.sessionManagement.handleSaveSessionResponse(message.data); - break; - } - case 'cancelStreaming': // Handle cancel streaming response from extension // Note: The "Interrupted" message is already added by handleCancel in App.tsx