diff --git a/integration-tests/acp-integration.test.ts b/integration-tests/acp-integration.test.ts index 0bafaeeb0..0f7770e6c 100644 --- a/integration-tests/acp-integration.test.ts +++ b/integration-tests/acp-integration.test.ts @@ -45,6 +45,7 @@ type SessionUpdateNotification = { text?: string; }; modeId?: string; + currentModeId?: string; _meta?: { usage?: UsageMetadata; }; @@ -313,7 +314,7 @@ function setupAcpTest( } }); - it('returns modes on initialize and allows setting mode and model', async () => { + it('initializes and allows setting mode', async () => { const rig = new TestRig(); rig.setup('acp mode and model'); @@ -326,41 +327,11 @@ function setupAcpTest( clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, }, - })) as { - protocolVersion: number; - modes: { - currentModeId: string; - availableModes: Array<{ - id: string; - name: string; - description: string; - }>; - }; - }; + })) as { protocolVersion: number }; expect(initResult).toBeDefined(); expect(initResult.protocolVersion).toBe(1); - // Verify modes data is present - expect(initResult.modes).toBeDefined(); - expect(initResult.modes.currentModeId).toBeDefined(); - expect(Array.isArray(initResult.modes.availableModes)).toBe(true); - expect(initResult.modes.availableModes.length).toBeGreaterThan(0); - - // Verify available modes have expected structure - const modeIds = initResult.modes.availableModes.map((m) => m.id); - expect(modeIds).toContain('default'); - expect(modeIds).toContain('yolo'); - expect(modeIds).toContain('auto-edit'); - expect(modeIds).toContain('plan'); - - // Verify each mode has required fields - for (const mode of initResult.modes.availableModes) { - expect(mode.id).toBeTruthy(); - expect(mode.name).toBeTruthy(); - expect(mode.description).toBeTruthy(); - } - // Test 2: Authenticate await sendRequest('authenticate', { methodId: 'openai' }); @@ -381,37 +352,22 @@ function setupAcpTest( const setModeResult = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'yolo', - })) as { modeId: string }; - expect(setModeResult).toBeDefined(); - expect(setModeResult.modeId).toBe('yolo'); + })) as unknown; + expect(setModeResult).toEqual({}); // Test 5: Set approval mode to 'auto-edit' const setModeResult2 = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'auto-edit', - })) as { modeId: string }; - expect(setModeResult2).toBeDefined(); - expect(setModeResult2.modeId).toBe('auto-edit'); + })) as unknown; + expect(setModeResult2).toEqual({}); // Test 6: Set approval mode back to 'default' const setModeResult3 = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'default', - })) as { modeId: string }; - expect(setModeResult3).toBeDefined(); - expect(setModeResult3.modeId).toBe('default'); - - // Test 7: Set model using openai model instead of first available model (index=0) which could be qwen-oauth requiring login - const openaiModel = newSession.models.availableModels.find((model) => - model.modelId.includes('openai'), - ); - expect(openaiModel).toBeDefined(); - const setModelResult = (await sendRequest('session/set_model', { - sessionId: newSession.sessionId, - modelId: openaiModel!.modelId, - })) as { modelId: string }; - expect(setModelResult).toBeDefined(); - expect(setModelResult.modelId).toBeTruthy(); + })) as unknown; + expect(setModeResult3).toEqual({}); } catch (e) { if (stderr.length) { console.error('Agent stderr:', stderr.join('')); @@ -422,7 +378,7 @@ function setupAcpTest( } }); - it('includes authMethods in error data when auth is required', async () => { + it('returns internal error details when model auth is required', async () => { const rig = new TestRig(); rig.setup('acp auth methods in error data'); @@ -447,18 +403,23 @@ function setupAcpTest( }; }; - // Attempt to set the first model (which might be qwen-oauth requiring login) without authenticating - // This should trigger an auth error with authMethods in the response - const firstModel = newSession.models.availableModels[0]; + // Choose a qwen-oauth model to trigger auth-required path deterministically. + const qwenOauthModel = newSession.models.availableModels.find((model) => + model.modelId.includes('qwen-oauth'), + ); + expect(qwenOauthModel).toBeDefined(); await expect( - sendRequest('session/set_model', { + sendRequest('session/set_config_option', { sessionId: newSession.sessionId, - modelId: firstModel.modelId, + configId: 'model', + value: qwenOauthModel!.modelId, }), ).rejects.toMatchObject({ response: { + code: -32603, + message: 'Internal error', data: { - authMethods: expect.any(Array), + details: expect.any(String), }, }, }); @@ -606,10 +567,7 @@ function setupAcpTest( ).rejects.toMatchObject({ response: { code: -32602, - message: 'Invalid params', - data: { - details: 'Unsupported configId: invalid_config', - }, + message: 'Invalid params: Unsupported configId: invalid_config', }, }); } catch (e) { @@ -726,8 +684,8 @@ function setupAcpTest( const setModeResult = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'plan', - })) as { modeId: string }; - expect(setModeResult.modeId).toBe('plan'); + })) as unknown; + expect(setModeResult).toEqual({}); // Send a prompt that should trigger the LLM to call exit_plan_mode // The prompt is designed to trigger planning behavior @@ -780,9 +738,9 @@ function setupAcpTest( // Verify mode update structure const modeUpdate = modeUpdateNotifications[0]; expect(modeUpdate.sessionId).toBe(newSession.sessionId); - expect(modeUpdate.update?.modeId).toBeDefined(); + expect(modeUpdate.update?.currentModeId).toBeDefined(); // Mode should be auto-edit since we approved with proceed_always - expect(modeUpdate.update?.modeId).toBe('auto-edit'); + expect(modeUpdate.update?.currentModeId).toBe('auto-edit'); } // Note: If the LLM didn't call exit_plan_mode, that's acceptable @@ -834,8 +792,8 @@ function setupAcpTest( const setModeResult = (await sendRequest('session/set_mode', { sessionId: newSession.sessionId, modeId: 'plan', - })) as { modeId: string }; - expect(setModeResult.modeId).toBe('plan'); + })) as unknown; + expect(setModeResult).toEqual({}); // Try to create a file - this should be blocked by plan mode const promptResult = await sendRequest('session/prompt', { diff --git a/package-lock.json b/package-lock.json index 5df32acc0..6f90131b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz", + "integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz", @@ -18793,6 +18802,7 @@ "name": "@qwen-code/qwen-code", "version": "0.12.0", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", @@ -20877,39 +20887,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "packages/sdk-typescript/node_modules/@vitest/browser": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.6.1.tgz", - "integrity": "sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vitest/utils": "1.6.1", - "magic-string": "^0.30.5", - "sirv": "^2.0.4" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "1.6.1", - "webdriverio": "*" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, "packages/sdk-typescript/node_modules/@vitest/coverage-v8": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", @@ -21717,23 +21694,6 @@ "url": "https://opencollective.com/express" } }, - "packages/sdk-typescript/node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "packages/sdk-typescript/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1a2e53a85..32073bb5c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,6 +36,7 @@ "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", @@ -100,10 +101,10 @@ "@teddyzhu/clipboard": "^0.0.5", "@teddyzhu/clipboard-darwin-arm64": "0.0.5", "@teddyzhu/clipboard-darwin-x64": "0.0.5", - "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", "@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5", - "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5", - "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5" + "@teddyzhu/clipboard-linux-x64-gnu": "0.0.5", + "@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5", + "@teddyzhu/clipboard-win32-x64-msvc": "0.0.5" }, "engines": { "node": ">=20" diff --git a/packages/cli/src/acp-integration/acp.ts b/packages/cli/src/acp-integration/acp.ts deleted file mode 100644 index 8c1dc0907..000000000 --- a/packages/cli/src/acp-integration/acp.ts +++ /dev/null @@ -1,503 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ - -import { z } from 'zod'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import * as schema from './schema.js'; -import { ACP_ERROR_CODES } from './errorCodes.js'; -import { pickAuthMethodsForDetails } from './authMethods.js'; -export * from './schema.js'; - -import type { WritableStream, ReadableStream } from 'node:stream/web'; - -const debugLogger = createDebugLogger('ACP_PROTOCOL'); -export class AgentSideConnection implements Client { - #connection: Connection; - - constructor( - toAgent: (conn: Client) => Agent, - input: WritableStream, - output: ReadableStream, - ) { - const agent = toAgent(this); - - const handler = async ( - method: string, - params: unknown, - ): Promise => { - switch (method) { - case schema.AGENT_METHODS.initialize: { - const validatedParams = schema.initializeRequestSchema.parse(params); - return agent.initialize(validatedParams); - } - case schema.AGENT_METHODS.session_new: { - const validatedParams = schema.newSessionRequestSchema.parse(params); - return agent.newSession(validatedParams); - } - case schema.AGENT_METHODS.session_load: { - if (!agent.loadSession) { - throw RequestError.methodNotFound(); - } - const validatedParams = schema.loadSessionRequestSchema.parse(params); - return agent.loadSession(validatedParams); - } - case schema.AGENT_METHODS.session_list: { - if (!agent.listSessions) { - throw RequestError.methodNotFound(); - } - const validatedParams = - schema.listSessionsRequestSchema.parse(params); - return agent.listSessions(validatedParams); - } - case schema.AGENT_METHODS.authenticate: { - const validatedParams = - schema.authenticateRequestSchema.parse(params); - return agent.authenticate(validatedParams); - } - case schema.AGENT_METHODS.session_prompt: { - const validatedParams = schema.promptRequestSchema.parse(params); - return agent.prompt(validatedParams); - } - case schema.AGENT_METHODS.session_cancel: { - const validatedParams = schema.cancelNotificationSchema.parse(params); - return agent.cancel(validatedParams); - } - case schema.AGENT_METHODS.session_set_mode: { - if (!agent.setMode) { - throw RequestError.methodNotFound(); - } - const validatedParams = schema.setModeRequestSchema.parse(params); - return agent.setMode(validatedParams); - } - case schema.AGENT_METHODS.session_set_model: { - if (!agent.setModel) { - throw RequestError.methodNotFound(); - } - const validatedParams = schema.setModelRequestSchema.parse(params); - return agent.setModel(validatedParams); - } - case schema.AGENT_METHODS.session_set_config_option: { - if (!agent.setConfigOption) { - throw RequestError.methodNotFound(); - } - const validatedParams = - schema.setConfigOptionRequestSchema.parse(params); - return agent.setConfigOption(validatedParams); - } - default: - throw RequestError.methodNotFound(method); - } - }; - - this.#connection = new Connection(handler, input, output); - } - - /** - * Streams new content to the client including text, tool calls, etc. - */ - async sessionUpdate(params: schema.SessionNotification): Promise { - return await this.#connection.sendNotification( - schema.CLIENT_METHODS.session_update, - params, - ); - } - - /** - * Streams authentication updates (e.g. Qwen OAuth authUri) to the client. - */ - async authenticateUpdate(params: schema.AuthenticateUpdate): Promise { - return await this.#connection.sendNotification( - schema.CLIENT_METHODS.authenticate_update, - params, - ); - } - - /** - * Sends a custom notification to the client. - * Used for extension-specific notifications that are not part of the core ACP protocol. - */ - async sendCustomNotification(method: string, params: T): Promise { - return await this.#connection.sendNotification(method, params); - } - - /** - * Request permission before running a tool - * - * The agent specifies a series of permission options with different granularity, - * and the client returns the chosen one. - */ - async requestPermission( - params: schema.RequestPermissionRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.session_request_permission, - params, - ); - } - - async readTextFile( - params: schema.ReadTextFileRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.fs_read_text_file, - params, - ); - } - - async writeTextFile( - params: schema.WriteTextFileRequest, - ): Promise { - return await this.#connection.sendRequest( - schema.CLIENT_METHODS.fs_write_text_file, - params, - ); - } -} - -type AnyMessage = AnyRequest | AnyResponse | AnyNotification; - -type AnyRequest = { - jsonrpc: '2.0'; - id: string | number; - method: string; - params?: unknown; -}; - -type AnyResponse = { - jsonrpc: '2.0'; - id: string | number; -} & Result; - -type AnyNotification = { - jsonrpc: '2.0'; - method: string; - params?: unknown; -}; - -type Result = - | { - result: T; - } - | { - error: ErrorResponse; - }; - -type ErrorResponse = { - code: number; - message: string; - data?: unknown; - authMethods?: schema.AuthMethod[]; -}; - -type PendingResponse = { - resolve: (response: unknown) => void; - reject: (error: ErrorResponse) => void; -}; - -type MethodHandler = (method: string, params: unknown) => Promise; - -class Connection { - #pendingResponses: Map = new Map(); - #nextRequestId: number = 0; - #handler: MethodHandler; - #peerInput: WritableStream; - #writeQueue: Promise = Promise.resolve(); - #textEncoder: TextEncoder; - - constructor( - handler: MethodHandler, - peerInput: WritableStream, - peerOutput: ReadableStream, - ) { - this.#handler = handler; - this.#peerInput = peerInput; - this.#textEncoder = new TextEncoder(); - this.#receive(peerOutput); - } - - async #receive(output: ReadableStream) { - let content = ''; - const decoder = new TextDecoder(); - for await (const chunk of output) { - content += decoder.decode(chunk, { stream: true }); - const lines = content.split('\n'); - content = lines.pop() || ''; - - for (const line of lines) { - const trimmedLine = line.trim(); - - if (trimmedLine) { - try { - const message = JSON.parse(trimmedLine); - this.#processMessage(message); - } catch (error) { - debugLogger.error('ACP parse error for inbound message.', { - code: ACP_ERROR_CODES.PARSE_ERROR, - line: trimmedLine, - error, - }); - } - } - } - } - } - - async #processMessage(message: AnyMessage) { - if ('method' in message && 'id' in message) { - // It's a request - const response = await this.#tryCallHandler( - message.method, - message.params, - ); - - await this.#sendMessage({ - jsonrpc: '2.0', - id: message.id, - ...response, - }); - } else if ('method' in message) { - // It's a notification - await this.#tryCallHandler(message.method, message.params); - } else if ('id' in message) { - // It's a response - this.#handleResponse(message as AnyResponse); - } - } - - async #tryCallHandler( - method: string, - params?: unknown, - ): Promise> { - try { - const result = await this.#handler(method, params); - return { result: result ?? null }; - } catch (error: unknown) { - if (error instanceof RequestError) { - debugLogger.debug('ACP handler returned request error.', { - method, - code: error.code, - message: error.message, - details: error.data?.details, - }); - return error.toResult(); - } - - if (error instanceof z.ZodError) { - const formattedDetails = JSON.stringify(error.format(), undefined, 2); - debugLogger.debug('ACP handler validation error.', { - method, - code: ACP_ERROR_CODES.INVALID_PARAMS, - details: formattedDetails, - }); - return RequestError.invalidParams(formattedDetails).toResult(); - } - - let errorName; - let details; - - if (error instanceof Error) { - errorName = error.name; - details = error.message; - } else if ( - typeof error === 'object' && - error != null && - 'message' in error && - typeof error.message === 'string' - ) { - details = error.message; - } - - if (errorName === 'TokenManagerError' || details?.includes('/auth')) { - return RequestError.authRequired( - details, - pickAuthMethodsForDetails(details), - ).toResult(); - } - - debugLogger.error( - 'ACP handler failed with internal error.', - { method, errorName, details }, - error, - ); - return RequestError.internalError(details).toResult(); - } - } - - #handleResponse(response: AnyResponse) { - const pendingResponse = this.#pendingResponses.get(response.id); - if (pendingResponse) { - if ('result' in response) { - pendingResponse.resolve(response.result); - } else if ('error' in response) { - const { error } = response; - debugLogger.warn('ACP response error received.', { - id: response.id, - code: error.code, - message: error.message, - data: error.data, - }); - pendingResponse.reject(error); - } - this.#pendingResponses.delete(response.id); - } - } - - async sendRequest(method: string, params?: Req): Promise { - const id = this.#nextRequestId++; - const responsePromise = new Promise((resolve, reject) => { - this.#pendingResponses.set(id, { resolve, reject }); - }); - await this.#sendMessage({ jsonrpc: '2.0', id, method, params }); - return responsePromise as Promise; - } - - async sendNotification(method: string, params?: N): Promise { - await this.#sendMessage({ jsonrpc: '2.0', method, params }); - } - - async #sendMessage(json: AnyMessage) { - const content = JSON.stringify(json) + '\n'; - this.#writeQueue = this.#writeQueue - .then(async () => { - const writer = this.#peerInput.getWriter(); - try { - await writer.write(this.#textEncoder.encode(content)); - } finally { - writer.releaseLock(); - } - }) - .catch((error) => { - // Continue processing writes on error - debugLogger.error('ACP write error:', error); - }); - return this.#writeQueue; - } -} - -export class RequestError extends Error { - data?: { details?: string; authMethods?: schema.AuthMethod[] }; - - constructor( - public code: number, - message: string, - details?: string, - authMethods?: schema.AuthMethod[], - ) { - super(message); - this.name = 'RequestError'; - if (details || authMethods) { - this.data = {}; - if (details) { - this.data.details = details; - } - if (authMethods) { - this.data.authMethods = authMethods; - } - } - } - - static parseError(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.PARSE_ERROR, - 'Parse error', - details, - ); - } - - static invalidRequest(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.INVALID_REQUEST, - 'Invalid request', - details, - ); - } - - static methodNotFound(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.METHOD_NOT_FOUND, - 'Method not found', - details, - ); - } - - static invalidParams(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.INVALID_PARAMS, - 'Invalid params', - details, - ); - } - - static internalError(details?: string): RequestError { - return new RequestError( - ACP_ERROR_CODES.INTERNAL_ERROR, - 'Internal error', - details, - ); - } - - static authRequired( - details?: string, - authMethods?: schema.AuthMethod[], - ): RequestError { - return new RequestError( - ACP_ERROR_CODES.AUTH_REQUIRED, - 'Authentication required', - details, - authMethods, - ); - } - - toResult(): Result { - return { - error: { - code: this.code, - message: this.message, - data: this.data, - }, - }; - } -} - -export interface Client { - requestPermission( - params: schema.RequestPermissionRequest, - ): Promise; - sessionUpdate(params: schema.SessionNotification): Promise; - authenticateUpdate(params: schema.AuthenticateUpdate): Promise; - sendCustomNotification(method: string, params: T): Promise; - writeTextFile( - params: schema.WriteTextFileRequest, - ): Promise; - readTextFile( - params: schema.ReadTextFileRequest, - ): Promise; -} - -export interface Agent { - initialize( - params: schema.InitializeRequest, - ): Promise; - newSession( - params: schema.NewSessionRequest, - ): Promise; - loadSession?( - params: schema.LoadSessionRequest, - ): Promise; - listSessions?( - params: schema.ListSessionsRequest, - ): Promise; - authenticate(params: schema.AuthenticateRequest): Promise; - prompt(params: schema.PromptRequest): Promise; - cancel(params: schema.CancelNotification): Promise; - setMode?(params: schema.SetModeRequest): Promise; - setModel?(params: schema.SetModelRequest): Promise; - setConfigOption?( - params: schema.SetConfigOptionRequest, - ): Promise; -} diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index faf89db90..c24c5cfd9 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -1,11 +1,9 @@ /** * @license - * Copyright 2025 Qwen + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ -import type { ReadableStream, WritableStream } from 'node:stream/web'; - import { APPROVAL_MODE_INFO, APPROVAL_MODES, @@ -21,8 +19,40 @@ import { type ConversationRecord, type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; -import type { ApprovalModeValue, ConfigOption } from './schema.js'; -import * as acp from './acp.js'; +import { + AgentSideConnection, + RequestError, + ndJsonStream, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; +import type { + Agent, + AuthenticateRequest, + AuthMethod, + CancelNotification, + ClientCapabilities, + InitializeRequest, + InitializeResponse, + ListSessionsRequest, + ListSessionsResponse, + LoadSessionRequest, + LoadSessionResponse, + McpServer, + McpServerStdio, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SessionConfigOption, + SessionInfo, + SessionModeState, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, +} from '@agentclientprotocol/sdk'; import { buildAuthMethods } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; import { Readable, Writable } from 'node:stream'; @@ -31,9 +61,8 @@ import { SettingScope } from '../config/settings.js'; import { z } from 'zod'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; - -// Import the modular Session class import { Session } from './session/Session.js'; +import type { ApprovalModeValue } from './session/types.js'; import { formatAcpModelId } from '../utils/acpModelUtils.js'; const debugLogger = createDebugLogger('ACP_AGENT'); @@ -52,54 +81,46 @@ export async function runAcpAgent( console.info = console.error; console.debug = console.error; - new acp.AgentSideConnection( - (client: acp.Client) => new GeminiAgent(config, settings, argv, client), - stdout, - stdin, + const stream = ndJsonStream(stdout, stdin); + const connection = new AgentSideConnection( + (conn) => new QwenAgent(config, settings, argv, conn), + stream, ); + + await connection.closed; } -class GeminiAgent { +function toStdioServer(server: McpServer): McpServerStdio | undefined { + if ('command' in server && 'args' in server && 'env' in server) { + return server as McpServerStdio; + } + return undefined; +} + +class QwenAgent implements Agent { private sessions: Map = new Map(); - private clientCapabilities: acp.ClientCapabilities | undefined; + private clientCapabilities: ClientCapabilities | undefined; constructor( private config: Config, private settings: LoadedSettings, private argv: CliArgs, - private client: acp.Client, + private connection: AgentSideConnection, ) {} - async initialize( - args: acp.InitializeRequest, - ): Promise { + async initialize(args: InitializeRequest): Promise { this.clientCapabilities = args.clientCapabilities; const authMethods = buildAuthMethods(); - - // Get current approval mode from config - const currentApprovalMode = this.config.getApprovalMode(); - - // Build available modes from shared APPROVAL_MODE_INFO - const availableModes = APPROVAL_MODES.map((mode) => ({ - id: mode as ApprovalModeValue, - name: APPROVAL_MODE_INFO[mode].name, - description: APPROVAL_MODE_INFO[mode].description, - })); - const version = process.env['CLI_VERSION'] || process.version; return { - protocolVersion: acp.PROTOCOL_VERSION, + protocolVersion: PROTOCOL_VERSION, agentInfo: { name: 'qwen-code', title: 'Qwen Code', version, }, authMethods, - modes: { - currentModeId: currentApprovalMode as ApprovalModeValue, - availableModes, - }, agentCapabilities: { loadSession: true, promptCapabilities: { @@ -115,14 +136,15 @@ class GeminiAgent { }; } - async authenticate({ methodId }: acp.AuthenticateRequest): Promise { + async authenticate({ methodId }: AuthenticateRequest): Promise { const method = z.nativeEnum(AuthType).parse(methodId); let authUri: string | undefined; const authUriHandler = (deviceAuth: DeviceAuthorizationData) => { authUri = deviceAuth.verification_uri_complete; - // Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking). - void this.client.authenticateUpdate({ _meta: { authUri } }); + void this.connection.extNotification('authenticate/update', { + _meta: { authUri }, + }); }; if (method === AuthType.QWEN_OAUTH) { @@ -138,19 +160,16 @@ class GeminiAgent { method, ); } finally { - // Ensure we don't leak listeners if auth fails early. if (method === AuthType.QWEN_OAUTH) { qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler); } } - - return; } async newSession({ cwd, mcpServers, - }: acp.NewSessionRequest): Promise { + }: NewSessionRequest): Promise { const config = await this.newSessionConfig(cwd, mcpServers); await this.ensureAuthenticated(config); this.setupFileSystem(config); @@ -168,58 +187,12 @@ class GeminiAgent { }; } - async newSessionConfig( - cwd: string, - mcpServers: acp.McpServer[], - sessionId?: string, - ): Promise { - const mergedMcpServers = { ...this.settings.merged.mcpServers }; - - for (const { command, args, env: rawEnv, name } of mcpServers) { - const env: Record = {}; - for (const { name: envName, value } of rawEnv) { - env[envName] = value; - } - mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd); - } - - const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; - - const argvForSession = { - ...this.argv, - resume: sessionId, - continue: false, - }; - - const config = await loadCliConfig(settings, argvForSession, cwd); - - await config.initialize(); - return config; - } - - async cancel(params: acp.CancelNotification): Promise { - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - await session.cancelPendingPrompt(); - } - - async prompt(params: acp.PromptRequest): Promise { - const session = this.sessions.get(params.sessionId); - if (!session) { - throw new Error(`Session not found: ${params.sessionId}`); - } - return session.prompt(params); - } - - async loadSession( - params: acp.LoadSessionRequest, - ): Promise { + async loadSession(params: LoadSessionRequest): Promise { const sessionService = new SessionService(params.cwd); const exists = await sessionService.sessionExists(params.sessionId); if (!exists) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${params.sessionId}`, ); } @@ -234,182 +207,177 @@ class GeminiAgent { const sessionData = config.getResumedSessionData(); if (!sessionData) { - throw acp.RequestError.internalError( + throw RequestError.internalError( + undefined, `Failed to load session data for id: ${params.sessionId}`, ); } await this.createAndStoreSession(config, sessionData.conversation); - - return null; + return null as unknown as LoadSessionResponse; } - async listSessions( - params: acp.ListSessionsRequest, - ): Promise { + async unstable_listSessions( + params: ListSessionsRequest, + ): Promise { const cwd = params.cwd || process.cwd(); const sessionService = new SessionService(cwd); + const numericCursor = params.cursor ? Number(params.cursor) : undefined; const result = await sessionService.listSessions({ - cursor: params.cursor, - size: params.size, + cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, }); - const sessions = result.items.map((item) => ({ + const sessions: SessionInfo[] = result.items.map((item) => ({ cwd: item.cwd, - filePath: item.filePath, - gitBranch: item.gitBranch, - messageCount: item.messageCount, - mtime: item.mtime, - prompt: item.prompt, sessionId: item.sessionId, - startTime: item.startTime, title: item.prompt || '(session)', updatedAt: new Date(item.mtime).toISOString(), })); return { - hasMore: result.hasMore, - items: sessions, - nextCursor: result.nextCursor, sessions, + nextCursor: + result.nextCursor != null ? String(result.nextCursor) : undefined, }; } - async setMode(params: acp.SetModeRequest): Promise { + async setSessionMode( + params: SetSessionModeRequest, + ): Promise { const session = this.sessions.get(params.sessionId); if (!session) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${params.sessionId}`, ); } return session.setMode(params); } - async setModel(params: acp.SetModelRequest): Promise { + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { const session = this.sessions.get(params.sessionId); if (!session) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${params.sessionId}`, ); } return await session.setModel(params); } - async setConfigOption( - params: acp.SetConfigOptionRequest, - ): Promise { + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { const { sessionId, configId, value } = params; - // Get the session's config const session = this.sessions.get(sessionId); if (!session) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Session not found for id: ${sessionId}`, ); } switch (configId) { case 'mode': { - await this.setMode({ + await this.setSessionMode({ sessionId, - modeId: value as ApprovalModeValue, + modeId: value as string, }); break; } case 'model': { - await this.setModel({ + await this.unstable_setSessionModel({ sessionId, modelId: value as string, }); break; } default: - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `Unsupported configId: ${configId}`, ); } - // Return all config options with current values return { configOptions: this.buildConfigOptions(session.getConfig()), }; } - private buildConfigOptions(config: Config): ConfigOption[] { - const currentApprovalMode = config.getApprovalMode(); - const allConfiguredModels = config.getAllConfiguredModels(); - const rawCurrentModelId = (config.getModel() || '').trim(); - const currentAuthType = config.getAuthType?.(); + async prompt(params: PromptRequest): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + return session.prompt(params); + } - // Check if current model is a runtime model - const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); - const currentModelId = activeRuntimeSnapshot - ? formatAcpModelId( - activeRuntimeSnapshot.id, - activeRuntimeSnapshot.authType, - ) - : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); + async cancel(params: CancelNotification): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) { + throw new Error(`Session not found: ${params.sessionId}`); + } + await session.cancelPendingPrompt(); + } - // Build mode config option - const modeOptions = APPROVAL_MODES.map((mode) => ({ - value: mode, - name: APPROVAL_MODE_INFO[mode].name, - description: APPROVAL_MODE_INFO[mode].description, - })); + // --- private helpers --- - const modeConfigOption: ConfigOption = { - id: 'mode', - name: 'Mode', - description: 'Session permission mode', - category: 'mode', - type: 'select', - currentValue: currentApprovalMode, - options: modeOptions, + private async newSessionConfig( + cwd: string, + mcpServers: McpServer[], + sessionId?: string, + ): Promise { + const mergedMcpServers = { ...this.settings.merged.mcpServers }; + + for (const server of mcpServers) { + const stdioServer = toStdioServer(server); + if (!stdioServer) continue; + + const env: Record = {}; + for (const { name: envName, value } of stdioServer.env) { + env[envName] = value; + } + mergedMcpServers[stdioServer.name] = new MCPServerConfig( + stdioServer.command, + stdioServer.args, + env, + cwd, + ); + } + + const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; + const argvForSession = { + ...this.argv, + resume: sessionId, + continue: false, }; - // Build model config option - const modelOptions = allConfiguredModels.map((model) => { - const effectiveModelId = - model.isRuntimeModel && model.runtimeSnapshotId - ? model.runtimeSnapshotId - : model.id; - return { - value: formatAcpModelId(effectiveModelId, model.authType), - name: model.label, - description: model.description ?? '', - }; - }); - - const modelConfigOption: ConfigOption = { - id: 'model', - name: 'Model', - description: 'AI model to use', - category: 'model', - type: 'select', - currentValue: currentModelId, - options: modelOptions, - }; - - return [modeConfigOption, modelConfigOption]; + const config = await loadCliConfig(settings, argvForSession, cwd); + await config.initialize(); + return config; } private async ensureAuthenticated(config: Config): Promise { const selectedType = config.getModelsConfig().getCurrentAuthType(); if (!selectedType) { - throw acp.RequestError.authRequired( + throw RequestError.authRequired( + { authMethods: this.pickAuthMethodsForAuthRequired() }, 'Use Qwen Code CLI to authenticate first.', - this.pickAuthMethodsForAuthRequired(), ); } try { - // Use true for the second argument to ensure only cached credentials are used await config.refreshAuth(selectedType, true); } catch (e) { debugLogger.error(`Authentication failed: ${e}`); - throw acp.RequestError.authRequired( + throw RequestError.authRequired( + { + authMethods: this.pickAuthMethodsForAuthRequired(selectedType, e), + }, 'Authentication failed: ' + (e as Error).message, - this.pickAuthMethodsForAuthRequired(selectedType, e), ); } } @@ -417,7 +385,7 @@ class GeminiAgent { private pickAuthMethodsForAuthRequired( selectedType?: AuthType | string, error?: unknown, - ): acp.AuthMethod[] { + ): AuthMethod[] { const authMethods = buildAuthMethods(); const errorMessage = this.extractErrorMessage(error); if ( @@ -425,25 +393,21 @@ class GeminiAgent { errorMessage?.includes('Qwen OAuth') ) { const qwenOAuthMethods = authMethods.filter( - (method) => method.id === AuthType.QWEN_OAUTH, + (m) => m.id === AuthType.QWEN_OAUTH, ); return qwenOAuthMethods.length ? qwenOAuthMethods : authMethods; } if (selectedType) { - const matchedMethods = authMethods.filter( - (method) => method.id === selectedType, - ); - return matchedMethods.length ? matchedMethods : authMethods; + const matched = authMethods.filter((m) => m.id === selectedType); + return matched.length ? matched : authMethods; } return authMethods; } private extractErrorMessage(error?: unknown): string | undefined { - if (error instanceof Error) { - return error.message; - } + if (error instanceof Error) return error.message; if ( typeof error === 'object' && error != null && @@ -452,19 +416,15 @@ class GeminiAgent { ) { return error.message; } - if (typeof error === 'string') { - return error; - } + if (typeof error === 'string') return error; return undefined; } private setupFileSystem(config: Config): void { - if (!this.clientCapabilities?.fs) { - return; - } + if (!this.clientCapabilities?.fs) return; const acpFileSystemService = new AcpFileSystemService( - this.client, + this.connection, config.getSessionId(), this.clientCapabilities.fs, config.getFileSystemService(), @@ -479,26 +439,17 @@ class GeminiAgent { const sessionId = config.getSessionId(); const geminiClient = config.getGeminiClient(); - // Use GeminiClient to manage chat lifecycle properly - // This ensures geminiClient.chat is in sync with the session's chat - // - // Note: When loading a session, config.initialize() has already been called - // in newSessionConfig(), which in turn calls geminiClient.initialize(). - // The GeminiClient.initialize() method checks config.getResumedSessionData() - // and automatically loads the conversation history into the chat instance. - // So we only need to initialize if it hasn't been done yet. if (!geminiClient.isInitialized()) { await geminiClient.initialize(); } - // Now get the chat instance that's managed by GeminiClient const chat = geminiClient.getChat(); const session = new Session( sessionId, chat, config, - this.client, + this.connection, this.settings, ); this.sessions.set(sessionId, session); @@ -514,9 +465,7 @@ class GeminiAgent { return session; } - private buildAvailableModels( - config: Config, - ): acp.NewSessionResponse['models'] { + private buildAvailableModels(config: Config): NewSessionResponse['models'] { const rawCurrentModelId = ( config.getModel() || this.config.getModel() || @@ -525,8 +474,6 @@ class GeminiAgent { const currentAuthType = config.getAuthType(); const allConfiguredModels = config.getAllConfiguredModels(); - // Check if current model is a runtime model - // Runtime models use $runtime|${authType}|${modelId} format const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); const currentModelId = activeRuntimeSnapshot ? formatAcpModelId( @@ -535,11 +482,7 @@ class GeminiAgent { ) : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); - const availableModels = allConfiguredModels; - - const mappedAvailableModels = availableModels.map((model) => { - // For runtime models, use runtimeSnapshotId as modelId for ACP protocol - // This allows ACP clients to correctly identify and switch to runtime models + const mappedAvailableModels = allConfiguredModels.map((model) => { const effectiveModelId = model.isRuntimeModel && model.runtimeSnapshotId ? model.runtimeSnapshotId @@ -561,7 +504,7 @@ class GeminiAgent { }; } - private buildModesData(config: Config): acp.ModesData { + private buildModesData(config: Config): SessionModeState { const currentApprovalMode = config.getApprovalMode(); const availableModes = APPROVAL_MODES.map((mode) => ({ @@ -576,14 +519,66 @@ class GeminiAgent { }; } + private buildConfigOptions(config: Config): SessionConfigOption[] { + const currentApprovalMode = config.getApprovalMode(); + const allConfiguredModels = config.getAllConfiguredModels(); + const rawCurrentModelId = (config.getModel() || '').trim(); + const currentAuthType = config.getAuthType?.(); + + const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); + const currentModelId = activeRuntimeSnapshot + ? formatAcpModelId( + activeRuntimeSnapshot.id, + activeRuntimeSnapshot.authType, + ) + : this.formatCurrentModelId(rawCurrentModelId, currentAuthType); + + const modeOptions = APPROVAL_MODES.map((mode) => ({ + value: mode, + name: APPROVAL_MODE_INFO[mode].name, + description: APPROVAL_MODE_INFO[mode].description, + })); + + const modeConfigOption: SessionConfigOption = { + id: 'mode', + name: 'Mode', + description: 'Session permission mode', + category: 'mode', + type: 'select' as const, + currentValue: currentApprovalMode, + options: modeOptions, + }; + + const modelOptions = allConfiguredModels.map((model) => { + const effectiveModelId = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : model.id; + return { + value: formatAcpModelId(effectiveModelId, model.authType), + name: model.label, + description: model.description ?? '', + }; + }); + + const modelConfigOption: SessionConfigOption = { + id: 'model', + name: 'Model', + description: 'AI model to use', + category: 'model', + type: 'select' as const, + currentValue: currentModelId, + options: modelOptions, + }; + + return [modeConfigOption, modelConfigOption]; + } + private formatCurrentModelId( baseModelId: string, authType?: AuthType, ): string { - if (!baseModelId) { - return baseModelId; - } - + if (!baseModelId) return baseModelId; return authType ? formatAcpModelId(baseModelId, authType) : baseModelId; } } diff --git a/packages/cli/src/acp-integration/authMethods.ts b/packages/cli/src/acp-integration/authMethods.ts index 35cafdc71..1eb0e7845 100644 --- a/packages/cli/src/acp-integration/authMethods.ts +++ b/packages/cli/src/acp-integration/authMethods.ts @@ -5,7 +5,7 @@ */ import { AuthType } from '@qwen-code/qwen-code-core'; -import type { AuthMethod } from './schema.js'; +import type { AuthMethod } from '@agentclientprotocol/sdk'; export function buildAuthMethods(): AuthMethod[] { return [ @@ -13,16 +13,20 @@ export function buildAuthMethods(): AuthMethod[] { id: AuthType.USE_OPENAI, name: 'Use OpenAI API key', description: 'Requires setting the `OPENAI_API_KEY` environment variable', - type: 'terminal', - args: ['--auth-type=openai'], + _meta: { + type: 'terminal', + args: ['--auth-type=openai'], + }, }, { id: AuthType.QWEN_OAUTH, name: 'Qwen OAuth', description: 'OAuth authentication for Qwen models with free daily requests', - type: 'terminal', - args: ['--auth-type=qwen-oauth'], + _meta: { + type: 'terminal', + args: ['--auth-type=qwen-oauth'], + }, }, ]; } diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts deleted file mode 100644 index 021bf7c93..000000000 --- a/packages/cli/src/acp-integration/schema.ts +++ /dev/null @@ -1,708 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { z } from 'zod'; - -export const AGENT_METHODS = { - authenticate: 'authenticate', - initialize: 'initialize', - session_cancel: 'session/cancel', - session_load: 'session/load', - session_new: 'session/new', - session_prompt: 'session/prompt', - session_list: 'session/list', - session_set_mode: 'session/set_mode', - session_set_model: 'session/set_model', - session_set_config_option: 'session/set_config_option', -}; - -export const CLIENT_METHODS = { - fs_read_text_file: 'fs/read_text_file', - fs_write_text_file: 'fs/write_text_file', - authenticate_update: 'authenticate/update', - session_request_permission: 'session/request_permission', - session_update: 'session/update', -}; - -export const PROTOCOL_VERSION = 1; - -export type WriteTextFileRequest = z.infer; - -export type ReadTextFileRequest = z.infer; - -export type PermissionOptionKind = z.infer; - -export type Role = z.infer; - -export type TextResourceContents = z.infer; - -export type BlobResourceContents = z.infer; - -export type ToolKind = z.infer; - -export type ToolCallStatus = z.infer; - -export type WriteTextFileResponse = z.infer; - -export type ReadTextFileResponse = z.infer; - -export type RequestPermissionOutcome = z.infer< - typeof requestPermissionOutcomeSchema ->; -export type SessionListItem = z.infer; -export type ListSessionsRequest = z.infer; -export type ListSessionsResponse = z.infer; - -export type CancelNotification = z.infer; - -export type AuthenticateRequest = z.infer; - -// Note: NewSessionResponse type is defined later after newSessionResponseSchema - -export type LoadSessionResponse = z.infer; - -export type StopReason = z.infer; - -export type PromptResponse = z.infer; - -export type ToolCallLocation = z.infer; - -export type PlanEntry = z.infer; - -export type PermissionOption = z.infer; - -export type Annotations = z.infer; - -export type RequestPermissionResponse = z.infer< - typeof requestPermissionResponseSchema ->; - -export type FileSystemCapability = z.infer; - -export type EnvVariable = z.infer; - -export type McpServer = z.infer; - -export type AgentCapabilities = z.infer; - -export type AuthMethod = z.infer; - -export type ModeInfo = z.infer; - -export type ModesData = z.infer; - -export type AgentInfo = z.infer; -export type ModelInfo = z.infer; - -export type PromptCapabilities = z.infer; - -export type ClientResponse = z.infer; - -export type ClientNotification = z.infer; - -export type EmbeddedResourceResource = z.infer< - typeof embeddedResourceResourceSchema ->; - -export type NewSessionRequest = z.infer; - -export type LoadSessionRequest = z.infer; - -export type InitializeResponse = z.infer; - -export type ContentBlock = z.infer; - -export type ToolCallContent = z.infer; - -export type ToolCall = z.infer; - -export type ClientCapabilities = z.infer; - -export type PromptRequest = z.infer; - -export type SessionUpdate = z.infer; - -export type AgentResponse = z.infer; - -export type RequestPermissionRequest = z.infer< - typeof requestPermissionRequestSchema ->; - -export type InitializeRequest = z.infer; - -export type SessionNotification = z.infer; - -export type ClientRequest = z.infer; - -export type AgentRequest = z.infer; - -export type AgentNotification = z.infer; - -export type ApprovalModeValue = z.infer; - -export type SetModeRequest = z.infer; - -export type SetModeResponse = z.infer; - -export type AvailableCommandInput = z.infer; - -export type AvailableCommand = z.infer; - -export type AvailableCommandsUpdate = z.infer< - typeof availableCommandsUpdateSchema ->; - -export const writeTextFileRequestSchema = z.object({ - content: z.string(), - path: z.string(), - sessionId: z.string(), -}); - -export const readTextFileRequestSchema = z.object({ - limit: z.number().optional().nullable(), - line: z.number().optional().nullable(), - path: z.string(), - sessionId: z.string(), -}); - -export const permissionOptionKindSchema = z.union([ - z.literal('allow_once'), - z.literal('allow_always'), - z.literal('reject_once'), - z.literal('reject_always'), -]); - -export const roleSchema = z.union([z.literal('assistant'), z.literal('user')]); - -export const textResourceContentsSchema = z.object({ - mimeType: z.string().optional().nullable(), - text: z.string(), - uri: z.string(), -}); - -export const blobResourceContentsSchema = z.object({ - blob: z.string(), - mimeType: z.string().optional().nullable(), - uri: z.string(), -}); - -export const toolKindSchema = z.union([ - z.literal('read'), - z.literal('edit'), - z.literal('delete'), - z.literal('move'), - z.literal('search'), - z.literal('execute'), - z.literal('think'), - z.literal('fetch'), - z.literal('switch_mode'), - z.literal('other'), -]); - -export const toolCallStatusSchema = z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - z.literal('failed'), -]); - -export const writeTextFileResponseSchema = z.null(); - -export const readTextFileResponseSchema = z.object({ - content: z.string(), -}); - -export const requestPermissionOutcomeSchema = z.union([ - z.object({ - outcome: z.literal('cancelled'), - }), - z.object({ - optionId: z.string(), - outcome: z.literal('selected'), - }), -]); - -export const cancelNotificationSchema = z.object({ - sessionId: z.string(), -}); - -export const approvalModeValueSchema = z.union([ - z.literal('plan'), - z.literal('default'), - z.literal('auto-edit'), - z.literal('yolo'), -]); - -export const setModeRequestSchema = z.object({ - sessionId: z.string(), - modeId: approvalModeValueSchema, -}); - -export const setModeResponseSchema = z.object({ - modeId: approvalModeValueSchema, -}); - -export const authenticateRequestSchema = z.object({ - methodId: z.string(), -}); - -export const authenticateUpdateSchema = z.object({ - _meta: z.object({ - authUri: z.string(), - }), -}); - -export type AuthenticateUpdate = z.infer; - -export const acpMetaSchema = z.record(z.unknown()).nullable().optional(); - -export const modelIdSchema = z.string(); - -export const modelInfoSchema = z.object({ - _meta: acpMetaSchema, - description: z.string().nullable().optional(), - modelId: modelIdSchema, - name: z.string(), -}); - -export const setModelRequestSchema = z.object({ - sessionId: z.string(), - modelId: z.string(), -}); - -export const setModelResponseSchema = z.object({ - modelId: z.string(), -}); - -export type SetModelRequest = z.infer; -export type SetModelResponse = z.infer; - -export const sessionModelStateSchema = z.object({ - _meta: acpMetaSchema, - availableModels: z.array(modelInfoSchema), - currentModelId: modelIdSchema, -}); - -// Note: newSessionResponseSchema is defined later in the file after modesDataSchema - -export const loadSessionResponseSchema = z.null(); - -export const sessionListItemSchema = z.object({ - cwd: z.string(), - filePath: z.string().optional(), - gitBranch: z.string().optional(), - messageCount: z.number().optional(), - mtime: z.number().optional(), - prompt: z.string().optional(), - sessionId: z.string(), - startTime: z.string().optional(), - title: z.string(), - updatedAt: z.string(), -}); - -export const listSessionsResponseSchema = z.object({ - hasMore: z.boolean().optional(), - items: z.array(sessionListItemSchema).optional(), - nextCursor: z.number().optional(), - sessions: z.array(sessionListItemSchema), -}); - -export const listSessionsRequestSchema = z.object({ - cursor: z.number().optional(), - cwd: z.string().optional(), - size: z.number().optional(), -}); - -export const stopReasonSchema = z.union([ - z.literal('end_turn'), - z.literal('max_tokens'), - z.literal('refusal'), - z.literal('cancelled'), -]); - -export const promptResponseSchema = z.object({ - stopReason: stopReasonSchema, -}); - -export const toolCallLocationSchema = z.object({ - line: z.number().optional().nullable(), - path: z.string(), -}); - -export const planEntrySchema = z.object({ - content: z.string(), - priority: z.union([z.literal('high'), z.literal('medium'), z.literal('low')]), - status: z.union([ - z.literal('pending'), - z.literal('in_progress'), - z.literal('completed'), - ]), -}); - -export const permissionOptionSchema = z.object({ - kind: permissionOptionKindSchema, - name: z.string(), - optionId: z.string(), -}); - -export const annotationsSchema = z.object({ - audience: z.array(roleSchema).optional().nullable(), - lastModified: z.string().optional().nullable(), - priority: z.number().optional().nullable(), -}); - -export const usageSchema = z.object({ - promptTokens: z.number().optional().nullable(), - completionTokens: z.number().optional().nullable(), - thoughtsTokens: z.number().optional().nullable(), - totalTokens: z.number().optional().nullable(), - cachedTokens: z.number().optional().nullable(), -}); - -export type Usage = z.infer; - -export const sessionUpdateMetaSchema = z.object({ - usage: usageSchema.optional().nullable(), - durationMs: z.number().optional().nullable(), - toolName: z.string().optional().nullable(), - parentToolCallId: z.string().optional().nullable(), - subagentType: z.string().optional().nullable(), - /** Server-side timestamp (ms since epoch) for correct message ordering */ - timestamp: z.number().optional().nullable(), -}); - -export type SessionUpdateMeta = z.infer; - -export const requestPermissionResponseSchema = z.object({ - outcome: requestPermissionOutcomeSchema, -}); - -export const fileSystemCapabilitySchema = z.object({ - readTextFile: z.boolean(), - writeTextFile: z.boolean(), -}); - -export const envVariableSchema = z.object({ - name: z.string(), - value: z.string(), -}); - -export const mcpServerSchema = z.object({ - args: z.array(z.string()), - command: z.string(), - env: z.array(envVariableSchema), - name: z.string(), -}); - -export const promptCapabilitiesSchema = z.object({ - audio: z.boolean().optional(), - embeddedContext: z.boolean().optional(), - image: z.boolean().optional(), -}); - -export const agentCapabilitiesSchema = z.object({ - loadSession: z.boolean().optional(), - promptCapabilities: promptCapabilitiesSchema.optional(), - sessionCapabilities: z - .object({ - list: z.object({}).optional(), - resume: z.object({}).optional(), - }) - .optional(), -}); - -export const authMethodSchema = z.object({ - args: z.array(z.string()).optional(), - description: z.string().nullable(), - env: z.record(z.string()).optional(), - id: z.string(), - name: z.string(), - type: z.string().optional(), -}); - -export const clientResponseSchema = z.union([ - writeTextFileResponseSchema, - readTextFileResponseSchema, - requestPermissionResponseSchema, -]); - -export const clientNotificationSchema = cancelNotificationSchema; - -export const embeddedResourceResourceSchema = z.union([ - textResourceContentsSchema, - blobResourceContentsSchema, -]); - -export const newSessionRequestSchema = z.object({ - cwd: z.string(), - mcpServers: z.array(mcpServerSchema), -}); - -export const loadSessionRequestSchema = z.object({ - cwd: z.string(), - mcpServers: z.array(mcpServerSchema), - sessionId: z.string(), -}); - -export const modeInfoSchema = z.object({ - id: approvalModeValueSchema, - name: z.string(), - description: z.string(), -}); - -export const modesDataSchema = z.object({ - currentModeId: approvalModeValueSchema, - availableModes: z.array(modeInfoSchema), -}); - -export const configOptionSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string(), - category: z.string(), - type: z.string(), - currentValue: z.string(), - options: z.array( - z.object({ - value: z.string(), - name: z.string(), - description: z.string(), - }), - ), -}); - -export type ConfigOption = z.infer; - -export const setConfigOptionRequestSchema = z.object({ - sessionId: z.string(), - configId: z.string(), - value: z.unknown(), -}); - -export const setConfigOptionResponseSchema = z.object({ - configOptions: z.array(configOptionSchema), -}); - -export type SetConfigOptionRequest = z.infer< - typeof setConfigOptionRequestSchema ->; -export type SetConfigOptionResponse = z.infer< - typeof setConfigOptionResponseSchema ->; - -// newSessionResponseSchema includes modes and configOptions for ACP/Zed integration -export const newSessionResponseSchema = z.object({ - sessionId: z.string(), - models: sessionModelStateSchema, - modes: modesDataSchema, - configOptions: z.array(configOptionSchema), -}); - -export type NewSessionResponse = z.infer; - -export const agentInfoSchema = z.object({ - name: z.string(), - title: z.string(), - version: z.string(), -}); - -export const initializeResponseSchema = z.object({ - agentCapabilities: agentCapabilitiesSchema, - agentInfo: agentInfoSchema, - authMethods: z.array(authMethodSchema), - modes: modesDataSchema, - protocolVersion: z.number(), -}); - -export const contentBlockSchema = z.union([ - z.object({ - annotations: annotationsSchema.optional().nullable(), - text: z.string(), - type: z.literal('text'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - data: z.string(), - mimeType: z.string(), - type: z.literal('image'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - data: z.string(), - mimeType: z.string(), - type: z.literal('audio'), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - description: z.string().optional().nullable(), - mimeType: z.string().optional().nullable(), - name: z.string(), - size: z.number().optional().nullable(), - title: z.string().optional().nullable(), - type: z.literal('resource_link'), - uri: z.string(), - }), - z.object({ - annotations: annotationsSchema.optional().nullable(), - resource: embeddedResourceResourceSchema, - type: z.literal('resource'), - }), -]); - -export const toolCallContentSchema = z.union([ - z.object({ - content: contentBlockSchema, - type: z.literal('content'), - }), - z.object({ - newText: z.string(), - oldText: z.string().nullable(), - path: z.string(), - type: z.literal('diff'), - }), -]); - -export const toolCallSchema = z.object({ - content: z.array(toolCallContentSchema).optional(), - kind: toolKindSchema, - locations: z.array(toolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - status: toolCallStatusSchema, - title: z.string(), - toolCallId: z.string(), -}); - -export const clientCapabilitiesSchema = z.object({ - fs: fileSystemCapabilitySchema, -}); - -export const promptRequestSchema = z.object({ - prompt: z.array(contentBlockSchema), - sessionId: z.string(), -}); - -export const availableCommandInputSchema = z.object({ - hint: z.string(), -}); - -export const availableCommandSchema = z.object({ - description: z.string(), - input: availableCommandInputSchema.nullable().optional(), - name: z.string(), -}); - -export const availableCommandsUpdateSchema = z.object({ - availableCommands: z.array(availableCommandSchema), - sessionUpdate: z.literal('available_commands_update'), -}); - -export const currentModeUpdateSchema = z.object({ - sessionUpdate: z.literal('current_mode_update'), - modeId: approvalModeValueSchema, -}); - -export type CurrentModeUpdate = z.infer; - -export const currentModelUpdateSchema = z.object({ - sessionUpdate: z.literal('current_model_update'), - model: modelInfoSchema, -}); - -export type CurrentModelUpdate = z.infer; - -export const sessionUpdateSchema = z.union([ - z.object({ - content: contentBlockSchema, - sessionUpdate: z.literal('user_message_chunk'), - _meta: sessionUpdateMetaSchema.optional().nullable(), - }), - z.object({ - content: contentBlockSchema, - sessionUpdate: z.literal('agent_message_chunk'), - _meta: sessionUpdateMetaSchema.optional().nullable(), - }), - z.object({ - content: contentBlockSchema, - sessionUpdate: z.literal('agent_thought_chunk'), - _meta: sessionUpdateMetaSchema.optional().nullable(), - }), - z.object({ - content: z.array(toolCallContentSchema).optional(), - kind: toolKindSchema, - locations: z.array(toolCallLocationSchema).optional(), - rawInput: z.unknown().optional(), - _meta: sessionUpdateMetaSchema.optional().nullable(), - sessionUpdate: z.literal('tool_call'), - status: toolCallStatusSchema, - title: z.string(), - toolCallId: z.string(), - }), - z.object({ - content: z.array(toolCallContentSchema).optional().nullable(), - kind: toolKindSchema.optional().nullable(), - locations: z.array(toolCallLocationSchema).optional().nullable(), - rawInput: z.unknown().optional(), - rawOutput: z.unknown().optional(), - _meta: sessionUpdateMetaSchema.optional().nullable(), - sessionUpdate: z.literal('tool_call_update'), - status: toolCallStatusSchema.optional().nullable(), - title: z.string().optional().nullable(), - toolCallId: z.string(), - }), - z.object({ - entries: z.array(planEntrySchema), - sessionUpdate: z.literal('plan'), - }), - currentModeUpdateSchema, - currentModelUpdateSchema, - availableCommandsUpdateSchema, -]); - -export const agentResponseSchema = z.union([ - initializeResponseSchema, - newSessionResponseSchema, - loadSessionResponseSchema, - promptResponseSchema, - listSessionsResponseSchema, - setModeResponseSchema, - setModelResponseSchema, -]); - -export const requestPermissionRequestSchema = z.object({ - options: z.array(permissionOptionSchema), - sessionId: z.string(), - toolCall: toolCallSchema, -}); - -export const initializeRequestSchema = z.object({ - clientCapabilities: clientCapabilitiesSchema, - protocolVersion: z.number(), -}); - -export const sessionNotificationSchema = z.object({ - sessionId: z.string(), - update: sessionUpdateSchema, -}); - -export const clientRequestSchema = z.union([ - writeTextFileRequestSchema, - readTextFileRequestSchema, - requestPermissionRequestSchema, -]); - -export const agentRequestSchema = z.union([ - initializeRequestSchema, - authenticateRequestSchema, - newSessionRequestSchema, - loadSessionRequestSchema, - promptRequestSchema, - listSessionsRequestSchema, - setModeRequestSchema, - setModelRequestSchema, - setConfigOptionRequestSchema, -]); - -export const agentNotificationSchema = sessionNotificationSchema; diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts index e8dc34968..628807fe2 100644 --- a/packages/cli/src/acp-integration/service/filesystem.test.ts +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -7,7 +7,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { FileSystemService } from '@qwen-code/qwen-code-core'; import { AcpFileSystemService } from './filesystem.js'; -import { ACP_ERROR_CODES } from '../errorCodes.js'; +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; + +const RESOURCE_NOT_FOUND_CODE = -32002; +const INTERNAL_ERROR_CODE = -32603; const createFallback = (): FileSystemService => ({ readTextFile: vi.fn(), @@ -26,7 +29,7 @@ describe('AcpFileSystemService', () => { readTextFile: vi .fn() .mockResolvedValue({ content: '\ufeff// BOM file' }), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -40,7 +43,6 @@ describe('AcpFileSystemService', () => { expect(client.readTextFile).toHaveBeenCalledWith({ path: '/test/file.txt', sessionId: 'session-1', - line: null, limit: 1, }); }); @@ -48,7 +50,7 @@ describe('AcpFileSystemService', () => { it('detects no BOM through ACP client when content does not start with U+FEFF', async () => { const client = { readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -64,7 +66,7 @@ describe('AcpFileSystemService', () => { it('falls back to local filesystem when ACP client fails', async () => { const client = { readTextFile: vi.fn().mockRejectedValue(new Error('Network error')), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const fallback = createFallback(); (fallback.detectFileBOM as ReturnType).mockResolvedValue( @@ -86,7 +88,7 @@ describe('AcpFileSystemService', () => { it('falls back to local filesystem when readTextFile capability is disabled', async () => { const client = { readTextFile: vi.fn(), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const fallback = createFallback(); (fallback.detectFileBOM as ReturnType).mockResolvedValue( @@ -110,12 +112,12 @@ describe('AcpFileSystemService', () => { describe('readTextFile ENOENT handling', () => { it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => { const resourceNotFoundError = { - code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND, + code: RESOURCE_NOT_FOUND_CODE, message: 'File not found', }; const client = { readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -133,12 +135,12 @@ describe('AcpFileSystemService', () => { it('re-throws other errors unchanged', async () => { const otherError = { - code: ACP_ERROR_CODES.INTERNAL_ERROR, + code: INTERNAL_ERROR_CODE, message: 'Internal error', }; const client = { readTextFile: vi.fn().mockRejectedValue(otherError), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const svc = new AcpFileSystemService( client, @@ -148,7 +150,7 @@ describe('AcpFileSystemService', () => { ); await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({ - code: ACP_ERROR_CODES.INTERNAL_ERROR, + code: INTERNAL_ERROR_CODE, message: 'Internal error', }); }); @@ -156,7 +158,7 @@ describe('AcpFileSystemService', () => { it('uses fallback when readTextFile capability is disabled', async () => { const client = { readTextFile: vi.fn(), - } as unknown as import('../acp.js').Client; + } as unknown as AgentSideConnection; const fallback = createFallback(); (fallback.readTextFile as ReturnType).mockResolvedValue( diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index b20d5f0ff..25ad296fb 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -1,24 +1,26 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ import type { - FileSystemService, + AgentSideConnection, + FileSystemCapability, +} from '@agentclientprotocol/sdk'; +import { RequestError } from '@agentclientprotocol/sdk'; +import type { FileReadResult, + FileSystemService, } from '@qwen-code/qwen-code-core'; -import type * as acp from '../acp.js'; -import { ACP_ERROR_CODES } from '../errorCodes.js'; -/** - * ACP client-based implementation of FileSystemService - */ +const RESOURCE_NOT_FOUND_CODE = -32002; + export class AcpFileSystemService implements FileSystemService { constructor( - private readonly client: acp.Client, + private readonly connection: AgentSideConnection, private readonly sessionId: string, - private readonly capabilities: acp.FileSystemCapability, + private readonly capabilities: FileSystemCapability, private readonly fallback: FileSystemService, ) {} @@ -29,19 +31,19 @@ export class AcpFileSystemService implements FileSystemService { let response: { content: string }; try { - response = await this.client.readTextFile({ + response = await this.connection.readTextFile({ path: filePath, sessionId: this.sessionId, - line: null, - limit: null, }); } catch (error) { const errorCode = - typeof error === 'object' && error !== null && 'code' in error - ? (error as { code?: unknown }).code - : undefined; + error instanceof RequestError + ? error.code + : typeof error === 'object' && error !== null && 'code' in error + ? (error as { code?: unknown }).code + : undefined; - if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) { + if (errorCode === RESOURCE_NOT_FOUND_CODE) { const err = new Error( `File not found: ${filePath}`, ) as NodeJS.ErrnoException; @@ -72,10 +74,9 @@ export class AcpFileSystemService implements FileSystemService { return this.fallback.writeTextFile(filePath, content, options); } - // Prepend BOM character if requested const finalContent = options?.bom ? '\uFEFF' + content : content; - await this.client.writeTextFile({ + await this.connection.writeTextFile({ path: filePath, content: finalContent, sessionId: this.sessionId, @@ -83,17 +84,13 @@ export class AcpFileSystemService implements FileSystemService { } async detectFileBOM(filePath: string): Promise { - // Try to detect BOM through ACP client first by reading first line if (this.capabilities.readTextFile) { try { - const response = await this.client.readTextFile({ + const response = await this.connection.readTextFile({ path: filePath, sessionId: this.sessionId, - line: null, limit: 1, }); - // Check if content starts with BOM character (U+FEFF) - // Use codePointAt for better Unicode support and check content length first return ( response.content.length > 0 && response.content.codePointAt(0) === 0xfeff @@ -102,7 +99,6 @@ export class AcpFileSystemService implements FileSystemService { // Fall through to fallback if ACP read fails } } - // Fall back to local filesystem detection return this.fallback.detectFileBOM(filePath); } diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index 9e8a5ddcc..d2a16fbc6 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -464,11 +464,11 @@ describe('HistoryReplayer', () => { content: { type: 'text', text: '' }, _meta: { usage: { - promptTokens: 100, - completionTokens: 50, - thoughtsTokens: undefined, + inputTokens: 100, + outputTokens: 50, totalTokens: 150, - cachedTokens: undefined, + thoughtTokens: undefined, + cachedReadTokens: undefined, }, }, }); diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e562d8b86..346537409 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -12,7 +12,10 @@ import { Session } from './Session.js'; import type { Config, GeminiChat } from '@qwen-code/qwen-code-core'; import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core'; import * as core from '@qwen-code/qwen-code-core'; -import type * as acp from '../acp.js'; +import type { + AgentSideConnection, + PromptRequest, +} from '@agentclientprotocol/sdk'; import type { LoadedSettings } from '../../config/settings.js'; import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; @@ -24,7 +27,7 @@ vi.mock('../../nonInteractiveCliCommands.js', () => ({ describe('Session', () => { let mockChat: GeminiChat; let mockConfig: Config; - let mockClient: acp.Client; + let mockClient: AgentSideConnection; let mockSettings: LoadedSettings; let session: Session; let currentModel: string; @@ -76,8 +79,8 @@ describe('Session', () => { requestPermission: vi.fn().mockResolvedValue({ outcome: { outcome: 'selected', optionId: 'proceed_once' }, }), - sendCustomNotification: vi.fn().mockResolvedValue(undefined), - } as unknown as acp.Client; + extNotification: vi.fn().mockResolvedValue(undefined), + } as unknown as AgentSideConnection; mockSettings = { merged: {}, @@ -103,20 +106,19 @@ describe('Session', () => { ['auto-edit', ApprovalMode.AUTO_EDIT], ['yolo', ApprovalMode.YOLO], ] as const)('maps %s mode', async (modeId, expected) => { - const result = await session.setMode({ + await session.setMode({ sessionId: 'test-session-id', modeId, }); expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected); - expect(result).toEqual({ modeId }); }); }); describe('setModel', () => { it('sets model via config and returns current model', async () => { const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`; - const result = await session.setModel({ + await session.setModel({ sessionId: 'test-session-id', modelId: ` ${requested} `, }); @@ -126,10 +128,6 @@ describe('Session', () => { 'qwen3-coder-plus', undefined, ); - expect(mockConfig.getModel).toHaveBeenCalled(); - expect(result).toEqual({ - modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`, - }); }); it('rejects empty/whitespace model IDs', async () => { @@ -221,7 +219,7 @@ describe('Session', () => { .fn() .mockResolvedValue((async function* () {})()); - const promptRequest: acp.PromptRequest = { + const promptRequest: PromptRequest = { sessionId: 'test-session-id', prompt: [ { type: 'text', text: 'Check this file' }, diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 702f66a07..77706030f 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -36,7 +36,24 @@ import { readManyFiles, } from '@qwen-code/qwen-code-core'; -import * as acp from '../acp.js'; +import { RequestError } from '@agentclientprotocol/sdk'; +import type { + AvailableCommand, + ContentBlock, + EmbeddedResourceResource, + PermissionOption, + PromptRequest, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + SessionUpdate, + SetSessionModeRequest, + SetSessionModeResponse, + SetSessionModelRequest, + SetSessionModelResponse, + ToolCallContent, + AgentSideConnection} from '@agentclientprotocol/sdk'; import type { LoadedSettings } from '../../config/settings.js'; import { z } from 'zod'; import { normalizePartList } from '../../utils/nonInteractiveHelpers.js'; @@ -45,24 +62,15 @@ import { getAvailableCommands, type NonInteractiveSlashCommandResult, } from '../../nonInteractiveCliCommands.js'; -import type { - AvailableCommand, - AvailableCommandsUpdate, - SetModeRequest, - SetModeResponse, - SetModelRequest, - SetModelResponse, - ApprovalModeValue, - CurrentModeUpdate, -} from '../schema.js'; import { isSlashCommand } from '../../ui/utils/commandUtils.js'; -import { - formatAcpModelId, - parseAcpModelOption, -} from '../../utils/acpModelUtils.js'; +import { parseAcpModelOption } from '../../utils/acpModelUtils.js'; // Import modular session components -import type { SessionContext, ToolCallStartParams } from './types.js'; +import type { + ApprovalModeValue, + SessionContext, + ToolCallStartParams, +} from './types.js'; import { HistoryReplayer } from './HistoryReplayer.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { PlanEmitter } from './emitters/PlanEmitter.js'; @@ -96,7 +104,7 @@ export class Session implements SessionContext { id: string, private readonly chat: GeminiChat, readonly config: Config, - private readonly client: acp.Client, + private readonly client: AgentSideConnection, private readonly settings: LoadedSettings, ) { this.sessionId = id; @@ -133,7 +141,7 @@ export class Session implements SessionContext { this.pendingPrompt = null; } - async prompt(params: acp.PromptRequest): Promise { + async prompt(params: PromptRequest): Promise { this.pendingPrompt?.abort(); const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; @@ -254,10 +262,7 @@ export class Session implements SessionContext { } } catch (error) { if (getErrorStatus(error) === 429) { - throw new acp.RequestError( - 429, - 'Rate limit exceeded. Try again later.', - ); + throw new RequestError(429, 'Rate limit exceeded. Try again later.'); } throw error; @@ -287,8 +292,8 @@ export class Session implements SessionContext { return { stopReason: 'end_turn' }; } - async sendUpdate(update: acp.SessionUpdate): Promise { - const params: acp.SessionNotification = { + async sendUpdate(update: SessionUpdate): Promise { + const params: SessionNotification = { sessionId: this.sessionId, update, }; @@ -314,7 +319,7 @@ export class Session implements SessionContext { }), ); - const update: AvailableCommandsUpdate = { + const update: SessionUpdate = { sessionUpdate: 'available_commands_update', availableCommands, }; @@ -331,8 +336,8 @@ export class Session implements SessionContext { * Used by SubAgentTracker for sub-agent approval requests. */ async requestPermission( - params: acp.RequestPermissionRequest, - ): Promise { + params: RequestPermissionRequest, + ): Promise { return this.client.requestPermission(params); } @@ -340,7 +345,9 @@ export class Session implements SessionContext { * Sets the approval mode for the current session. * Maps ACP approval mode values to core ApprovalMode enum. */ - async setMode(params: SetModeRequest): Promise { + async setMode( + params: SetSessionModeRequest, + ): Promise { const modeMap: Record = { plan: ApprovalMode.PLAN, default: ApprovalMode.DEFAULT, @@ -348,21 +355,21 @@ export class Session implements SessionContext { yolo: ApprovalMode.YOLO, }; - const approvalMode = modeMap[params.modeId]; + const approvalMode = modeMap[params.modeId as ApprovalModeValue]; this.config.setApprovalMode(approvalMode); - - return { modeId: params.modeId }; } /** * Sets the model for the current session. * Validates the model ID and switches the model via Config. */ - async setModel(params: SetModelRequest): Promise { + async setModel( + params: SetSessionModelRequest, + ): Promise { const rawModelId = params.modelId.trim(); if (!rawModelId) { - throw acp.RequestError.invalidParams('modelId cannot be empty'); + throw RequestError.invalidParams(undefined, 'modelId cannot be empty'); } const parsed = parseAcpModelOption(rawModelId); @@ -370,7 +377,8 @@ export class Session implements SessionContext { const selectedAuthType = parsed.authType ?? previousAuthType; if (!selectedAuthType) { - throw acp.RequestError.invalidParams( + throw RequestError.invalidParams( + undefined, `authType cannot be determined for modelId "${parsed.modelId}"`, ); } @@ -383,14 +391,6 @@ export class Session implements SessionContext { ? { requireCachedCredentials: true } : undefined, ); - - // Get updated model info - const currentModel = this.config.getModel(); - const currentAuthType = this.config.getAuthType?.() ?? selectedAuthType; - - return { - modelId: formatAcpModelId(currentModel, currentAuthType), - }; } /** @@ -413,9 +413,9 @@ export class Session implements SessionContext { break; } - const update: CurrentModeUpdate = { + const update: SessionUpdate = { sessionUpdate: 'current_mode_update', - modeId: newModeId, + currentModeId: newModeId, }; await this.sendUpdate(update); @@ -529,7 +529,7 @@ export class Session implements SessionContext { } if (confirmationDetails) { - const content: acp.ToolCallContent[] = []; + const content: ToolCallContent[] = []; if (confirmationDetails.type === 'edit') { content.push({ @@ -554,7 +554,7 @@ export class Session implements SessionContext { // Map tool kind, using switch_mode for exit_plan_mode per ACP spec const mappedKind = this.toolCallEmitter.mapToolKind(tool.kind, fc.name); - const params: acp.RequestPermissionRequest = { + const params: RequestPermissionRequest = { sessionId: this.sessionId, options: toPermissionOptions(confirmationDetails), toolCall: { @@ -732,7 +732,7 @@ export class Session implements SessionContext { */ async #processSlashCommandResult( result: NonInteractiveSlashCommandResult, - originalPrompt: acp.ContentBlock[], + originalPrompt: ContentBlock[], ): Promise { switch (result.type) { case 'submit_prompt': @@ -741,9 +741,7 @@ export class Session implements SessionContext { return normalizePartList(result.content); case 'message': { - // 'message' type is not ideal for ACP mode, but we handle it for compatibility - // by converting it to a stream_messages-like notification - await this.client.sendCustomNotification('_qwencode/slash_command', { + await this.client.extNotification('_qwencode/slash_command', { sessionId: this.sessionId, command: originalPrompt .filter((block) => block.type === 'text') @@ -770,7 +768,7 @@ export class Session implements SessionContext { // Stream all messages to the client for await (const msg of result.messages) { - await this.client.sendCustomNotification('_qwencode/slash_command', { + await this.client.extNotification('_qwencode/slash_command', { sessionId: this.sessionId, command, messageType: msg.messageType, @@ -812,12 +810,12 @@ export class Session implements SessionContext { } async #resolvePrompt( - message: acp.ContentBlock[], + message: ContentBlock[], abortSignal: AbortSignal, ): Promise { const FILE_URI_SCHEME = 'file://'; - const embeddedContext: acp.EmbeddedResourceResource[] = []; + const embeddedContext: EmbeddedResourceResource[] = []; const parts = message.map((part) => { switch (part.type) { @@ -966,7 +964,7 @@ const basicPermissionOptions = [ function toPermissionOptions( confirmation: ToolCallConfirmationDetails, -): acp.PermissionOption[] { +): PermissionOption[] { switch (confirmation.type) { case 'edit': return [ diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts index 96b8bd998..86832afdd 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts @@ -23,7 +23,7 @@ import { ToolConfirmationOutcome, TodoWriteTool, } from '@qwen-code/qwen-code-core'; -import type * as acp from '../acp.js'; +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; import { EventEmitter } from 'node:events'; // Helper to create a mock SubAgentToolCallEvent with required fields @@ -116,7 +116,7 @@ function createStreamTextEvent( describe('SubAgentTracker', () => { let mockContext: SessionContext; - let mockClient: acp.Client; + let mockClient: AgentSideConnection; let sendUpdateSpy: ReturnType; let requestPermissionSpy: ReturnType; let tracker: SubAgentTracker; @@ -143,7 +143,7 @@ describe('SubAgentTracker', () => { mockClient = { requestPermission: requestPermissionSpy, - } as unknown as acp.Client; + } as unknown as AgentSideConnection; tracker = new SubAgentTracker( mockContext, diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index d020f2a06..acbe95082 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -24,7 +24,12 @@ import { z } from 'zod'; import type { SessionContext } from './types.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { MessageEmitter } from './emitters/MessageEmitter.js'; -import type * as acp from '../acp.js'; +import type { + AgentSideConnection, + PermissionOption, + RequestPermissionRequest, + ToolCallContent, +} from '@agentclientprotocol/sdk'; const debugLogger = createDebugLogger('ACP_SUBAGENT_TRACKER'); @@ -80,7 +85,7 @@ export class SubAgentTracker { constructor( private readonly ctx: SessionContext, - private readonly client: acp.Client, + private readonly client: AgentSideConnection, private readonly parentToolCallId: string, private readonly subagentType: string, ) { @@ -214,7 +219,7 @@ export class SubAgentTracker { if (abortSignal.aborted) return; const state = this.toolStates.get(event.callId); - const content: acp.ToolCallContent[] = []; + const content: ToolCallContent[] = []; // Handle edit confirmation type - show diff if (event.confirmationDetails.type === 'edit') { @@ -243,7 +248,7 @@ export class SubAgentTracker { const { title, locations, kind } = this.toolCallEmitter.resolveToolMetadata(event.name, state?.args); - const params: acp.RequestPermissionRequest = { + const params: RequestPermissionRequest = { sessionId: this.ctx.sessionId, options: this.toPermissionOptions(fullConfirmationDetails), toolCall: { @@ -324,7 +329,7 @@ export class SubAgentTracker { */ private toPermissionOptions( confirmation: ToolCallConfirmationDetails, - ): acp.PermissionOption[] { + ): PermissionOption[] { switch (confirmation.type) { case 'edit': return [ diff --git a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts index b0b05e7e8..dd7529686 100644 --- a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts @@ -5,7 +5,7 @@ */ import type { SessionContext } from '../types.js'; -import type * as acp from '../../acp.js'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; /** * Abstract base class for all session event emitters. @@ -32,7 +32,7 @@ export abstract class BaseEmitter { /** * Sends a session update to the ACP client. */ - protected async sendUpdate(update: acp.SessionUpdate): Promise { + protected async sendUpdate(update: SessionUpdate): Promise { return this.ctx.sendUpdate(update); } diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index d0b1ae870..d820f6388 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -166,11 +166,11 @@ describe('MessageEmitter', () => { content: { type: 'text', text: '' }, _meta: { usage: { - promptTokens: 100, - completionTokens: 50, - thoughtsTokens: 25, + inputTokens: 100, + outputTokens: 50, totalTokens: 175, - cachedTokens: 10, + thoughtTokens: 25, + cachedReadTokens: 10, }, }, }); @@ -192,11 +192,11 @@ describe('MessageEmitter', () => { content: { type: 'text', text: 'done' }, _meta: { usage: { - promptTokens: 10, - completionTokens: 5, - thoughtsTokens: 2, + inputTokens: 10, + outputTokens: 5, totalTokens: 17, - cachedTokens: 1, + thoughtTokens: 2, + cachedReadTokens: 1, }, durationMs: 1234, }, diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index a81520be3..4b2bf82bf 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -5,7 +5,7 @@ */ import type { GenerateContentResponseUsageMetadata } from '@google/genai'; -import type { Usage } from '../../schema.js'; +import type { Usage } from '@agentclientprotocol/sdk'; import { BaseEmitter } from './BaseEmitter.js'; /** @@ -80,11 +80,11 @@ export class MessageEmitter extends BaseEmitter { subagentMeta?: import('../types.js').SubagentMeta, ): Promise { const usage: Usage = { - promptTokens: usageMetadata.promptTokenCount, - completionTokens: usageMetadata.candidatesTokenCount, - thoughtsTokens: usageMetadata.thoughtsTokenCount, - totalTokens: usageMetadata.totalTokenCount, - cachedTokens: usageMetadata.cachedContentTokenCount, + inputTokens: usageMetadata.promptTokenCount ?? 0, + outputTokens: usageMetadata.candidatesTokenCount ?? 0, + totalTokens: usageMetadata.totalTokenCount ?? 0, + thoughtTokens: usageMetadata.thoughtsTokenCount, + cachedReadTokens: usageMetadata.cachedContentTokenCount, }; const meta = diff --git a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts index f6453cffc..3556e0302 100644 --- a/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/PlanEmitter.ts @@ -6,7 +6,7 @@ import { BaseEmitter } from './BaseEmitter.js'; import type { TodoItem } from '../types.js'; -import type * as acp from '../../acp.js'; +import type { PlanEntry } from '@agentclientprotocol/sdk'; /** * Handles emission of plan/todo updates. @@ -22,7 +22,7 @@ export class PlanEmitter extends BaseEmitter { * @param todos - Array of todo items to send as plan entries */ async emitPlan(todos: TodoItem[]): Promise { - const entries: acp.PlanEntry[] = todos.map((todo) => ({ + const entries: PlanEntry[] = todos.map((todo) => ({ content: todo.content, priority: 'medium' as const, // Default priority since todos don't have priority status: todo.status, diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index dc60e18a2..cfdc02f24 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -13,7 +13,11 @@ import type { ResolvedToolMetadata, SubagentMeta, } from '../types.js'; -import type * as acp from '../../acp.js'; +import type { + ToolCallContent, + ToolCallLocation, + ToolKind, +} from '@agentclientprotocol/sdk'; import type { Part } from '@google/genai'; import { TodoWriteTool, @@ -103,7 +107,7 @@ export class ToolCallEmitter extends BaseEmitter { } // Determine content for the update - let contentArray: acp.ToolCallContent[] = []; + let contentArray: ToolCallContent[] = []; // Special case: diff result from edit tools (format from resultDisplay) const diffContent = this.extractDiffContent(params.resultDisplay); @@ -206,8 +210,8 @@ export class ToolCallEmitter extends BaseEmitter { const tool = toolRegistry.getTool(toolName); let title = tool?.displayName ?? toolName; - let locations: acp.ToolCallLocation[] = []; - let kind: acp.ToolKind = 'other'; + let locations: ToolCallLocation[] = []; + let kind: ToolKind = 'other'; if (tool && args) { try { @@ -234,13 +238,13 @@ export class ToolCallEmitter extends BaseEmitter { * @param kind - The core Kind enum value * @param toolName - Optional tool name to handle special cases like exit_plan_mode */ - mapToolKind(kind: Kind, toolName?: string): acp.ToolKind { + mapToolKind(kind: Kind, toolName?: string): ToolKind { // Special case: exit_plan_mode uses 'switch_mode' kind per ACP spec if (toolName && this.isExitPlanModeTool(toolName)) { return 'switch_mode'; } - const kindMap: Record = { + const kindMap: Record = { [Kind.Read]: 'read', [Kind.Edit]: 'edit', [Kind.Delete]: 'delete', @@ -260,9 +264,7 @@ export class ToolCallEmitter extends BaseEmitter { * Extracts diff content from resultDisplay if it's a diff type (edit tool result). * Returns null if not a diff. */ - private extractDiffContent( - resultDisplay: unknown, - ): acp.ToolCallContent | null { + private extractDiffContent(resultDisplay: unknown): ToolCallContent | null { if (!resultDisplay || typeof resultDisplay !== 'object') return null; const obj = resultDisplay as Record; @@ -284,10 +286,8 @@ export class ToolCallEmitter extends BaseEmitter { * Transforms Part[] to ToolCallContent[]. * Extracts text from functionResponse parts and text parts. */ - private transformPartsToToolCallContent( - parts: Part[], - ): acp.ToolCallContent[] { - const result: acp.ToolCallContent[] = []; + private transformPartsToToolCallContent(parts: Part[]): ToolCallContent[] { + const result: ToolCallContent[] = []; for (const part of parts) { // Handle text parts diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index 7b82f6e96..58bea4d42 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -6,14 +6,20 @@ import type { Config } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; -import type * as acp from '../acp.js'; +import type { + SessionUpdate, + ToolCallLocation, + ToolKind, +} from '@agentclientprotocol/sdk'; + +export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; /** * Interface for sending session updates to the ACP client. * Implemented by Session class and used by all emitters. */ export interface SessionUpdateSender { - sendUpdate(update: acp.SessionUpdate): Promise; + sendUpdate(update: SessionUpdate): Promise; } /** @@ -91,6 +97,6 @@ export interface TodoItem { */ export interface ResolvedToolMetadata { title: string; - locations: acp.ToolCallLocation[]; - kind: acp.ToolKind; + locations: ToolCallLocation[]; + kind: ToolKind; } diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index 30943eee9..112f38c7f 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'; import type { Config, ChatRecord } from '@qwen-code/qwen-code-core'; import type { SessionContext } from '../../../acp-integration/session/types.js'; -import type * as acp from '../../../acp-integration/acp.js'; +import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk'; import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js'; import type { ExportMessage, ExportSessionData } from './types.js'; @@ -34,7 +34,7 @@ class ExportSessionContext implements SessionContext { this.config = config; } - async sendUpdate(update: acp.SessionUpdate): Promise { + async sendUpdate(update: SessionUpdate): Promise { switch (update.sessionUpdate) { case 'user_message_chunk': this.handleMessageChunk('user', update.content); @@ -108,7 +108,7 @@ class ExportSessionContext implements SessionContext { } } - private handleToolCallStart(update: acp.ToolCall): void { + private handleToolCallStart(update: ToolCall): void { const toolCall: ExportMessage['toolCall'] = { toolCallId: update.toolCallId, kind: update.kind || 'other',