refactor(acp): migrate to @agentclientprotocol/sdk and clean up handlers

- Replace deprecated ACP session manager with new SDK integration
- Add acpFileHandler test coverage
- Remove obsolete acpMessageHandler and acpSessionManager
- Update type definitions and connection handlers
- Apply code formatting fixes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mingholy.lmh 2026-03-05 19:05:48 +08:00
parent 180dcd8b36
commit c044d4dba1
28 changed files with 959 additions and 1959 deletions

2
package-lock.json generated
View file

@ -14293,7 +14293,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@ -22904,6 +22903,7 @@
"version": "0.12.0",
"license": "LICENSE",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/webui": "*",
"cors": "^2.8.5",

View file

@ -214,7 +214,16 @@ class QwenAgent implements Agent {
}
await this.createAndStoreSession(config, sessionData.conversation);
return null as unknown as LoadSessionResponse;
const modesData = this.buildModesData(config);
const availableModels = this.buildAvailableModels(config);
const configOptions = this.buildConfigOptions(config);
return {
modes: modesData,
models: availableModels,
configOptions,
};
}
async unstable_listSessions(
@ -323,6 +332,13 @@ class QwenAgent implements Agent {
await session.cancelPendingPrompt();
}
async extMethod(
method: string,
_params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
throw RequestError.methodNotFound(method);
}
// --- private helpers ---
private async newSessionConfig(

View file

@ -1,5 +1,202 @@
This file contains third-party software notices and license terms.
============================================================
@agentclientprotocol/sdk@0.14.1
(git+https://github.com/agentclientprotocol/typescript-sdk.git)
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025 Zed Industries, Inc. and contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
============================================================
@qwen-code/webui@undefined
(No repository found)

View file

@ -158,6 +158,7 @@
"vitest": "^3.2.4"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@qwen-code/webui": "*",
"@modelcontextprotocol/sdk": "^1.25.1",
"cors": "^2.8.5",

View file

@ -4,41 +4,30 @@
* SPDX-License-Identifier: Apache-2.0
*/
export const AGENT_METHODS = {
authenticate: 'authenticate',
initialize: 'initialize',
session_cancel: 'session/cancel',
session_list: 'session/list',
session_load: 'session/load',
session_new: 'session/new',
session_prompt: 'session/prompt',
session_save: 'session/save',
session_set_mode: 'session/set_mode',
session_set_model: 'session/set_model',
} as const;
export {
AGENT_METHODS,
CLIENT_METHODS,
PROTOCOL_VERSION,
} from '@agentclientprotocol/sdk';
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
export { RequestError } from '@agentclientprotocol/sdk';
// Local extension: authenticate/update is not part of the ACP spec.
// It is routed as an extension notification by our CLI.
export const EXT_CLIENT_METHODS = {
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;
// Re-export error codes in the shape that existing consumers expect.
// The numeric values match the SDK's ErrorCode type.
export const ACP_ERROR_CODES = {
// Parse error: invalid JSON received by server.
PARSE_ERROR: -32700,
// Invalid request: JSON is not a valid Request object.
INVALID_REQUEST: -32600,
// Method not found: method does not exist or is unavailable.
METHOD_NOT_FOUND: -32601,
// Invalid params: invalid method parameter(s).
INVALID_PARAMS: -32602,
// Internal error: implementation-defined server error.
INTERNAL_ERROR: -32603,
// Authentication required: must authenticate before operation.
REQUEST_CANCELLED: -32800,
AUTH_REQUIRED: -32000,
// Resource not found: e.g. missing file.
RESOURCE_NOT_FOUND: -32002,
} as const;

View file

@ -4,64 +4,59 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { JSONRPC_VERSION } from '../types/acpTypes.js';
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
} from '@agentclientprotocol/sdk';
import type {
AcpMessage,
AcpPermissionRequest,
AcpResponse,
AcpSessionUpdate,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
Client,
Agent,
SessionNotification,
RequestPermissionRequest,
RequestPermissionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
WriteTextFileRequest,
WriteTextFileResponse,
AuthenticateResponse,
NewSessionResponse,
LoadSessionResponse,
ListSessionsResponse,
PromptResponse,
SetSessionModeResponse,
SetSessionModelResponse,
} from '@agentclientprotocol/sdk';
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { ChildProcess, SpawnOptions } from 'child_process';
import { spawn } from 'child_process';
import type {
PendingRequest,
AcpConnectionCallbacks,
} from '../types/connectionTypes.js';
import { AcpMessageHandler } from './acpMessageHandler.js';
import { AcpSessionManager } from './acpSessionManager.js';
import { Readable, Writable } from 'node:stream';
import * as fs from 'node:fs';
import { AcpFileHandler } from './acpFileHandler.js';
/**
* ACP Connection Handler for VSCode Extension
*
* This class implements the client side of the ACP (Agent Communication Protocol).
* External API preserved for backward compatibility.
* Internally uses SDK ClientSideConnection + ndJsonStream for protocol handling.
*/
export class AcpConnection {
private child: ChildProcess | null = null;
private pendingRequests = new Map<number, PendingRequest<unknown>>();
private nextRequestId = { value: 0 };
// Remember the working dir provided at connect() so later ACP calls
// that require cwd (e.g. session/list) can include it.
private sdkConnection: ClientSideConnection | null = null;
private sessionId: string | null = null;
private workingDir: string = process.cwd();
private fileHandler = new AcpFileHandler();
private messageHandler: AcpMessageHandler;
private sessionManager: AcpSessionManager;
onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
onSessionUpdate: (data: SessionNotification) => void = () => {};
onPermissionRequest: (data: RequestPermissionRequest) => Promise<{
optionId: string;
}> = () => Promise.resolve({ optionId: 'allow' });
}> = () => Promise.resolve({ optionId: 'allow_once' });
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void =
() => {};
onEndTurn: () => void = () => {};
// Called after successful initialize() with the initialize result
onEndTurn: (reason?: string) => void = () => {};
onInitialized: (init: unknown) => void = () => {};
constructor() {
this.messageHandler = new AcpMessageHandler();
this.sessionManager = new AcpSessionManager();
}
/**
* Connect to Qwen ACP
*
* @param cliEntryPath - Path to the bundled CLI entrypoint (cli.js)
* @param workingDir - Working directory
* @param extraArgs - Extra command line arguments
*/
async connect(
cliEntryPath: string,
workingDir: string = process.cwd(),
@ -75,8 +70,6 @@ export class AcpConnection {
const env = { ...process.env };
// If proxy is configured in extraArgs, also set it as environment variable
// This ensures token refresh requests also use the proxy
const proxyArg = extraArgs.find(
(arg, i) => arg === '--proxy' && i + 1 < extraArgs.length,
);
@ -84,15 +77,12 @@ export class AcpConnection {
const proxyIndex = extraArgs.indexOf('--proxy');
const proxyUrl = extraArgs[proxyIndex + 1];
console.log('[ACP] Setting proxy environment variables:', proxyUrl);
env['HTTP_PROXY'] = proxyUrl;
env['HTTPS_PROXY'] = proxyUrl;
env['http_proxy'] = proxyUrl;
env['https_proxy'] = proxyUrl;
}
// Always run the bundled CLI using the VS Code extension host's Node runtime.
// This avoids PATH/NVM/global install problems and ensures deterministic behavior.
const spawnCommand: string = process.execPath;
const spawnArgs: string[] = [
cliEntryPath,
@ -113,7 +103,6 @@ export class AcpConnection {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env,
// We spawn node directly; no shell needed (and shell quoting can break paths).
shell: false,
};
@ -121,13 +110,10 @@ export class AcpConnection {
await this.setupChildProcessHandlers();
}
/**
* Set up child process handlers
*/
private async setupChildProcessHandlers(): Promise<void> {
let spawnError: Error | null = null;
this.child!.stderr?.on('data', (data) => {
this.child!.stderr?.on('data', (data: Buffer) => {
const message = data.toString();
if (
message.toLowerCase().includes('error') &&
@ -139,19 +125,16 @@ export class AcpConnection {
}
});
this.child!.on('error', (error) => {
this.child!.on('error', (error: Error) => {
spawnError = error;
});
this.child!.on('exit', (code, signal) => {
this.child!.on('exit', (code: number | null, signal: string | null) => {
console.error(
`[ACP qwen] Process exited with code: ${code}, signal: ${signal}`,
);
// Clear pending requests when process exits
this.pendingRequests.clear();
});
// Wait for process to start
await new Promise((resolve) => setTimeout(resolve, 1000));
if (spawnError) {
@ -162,291 +145,292 @@ export class AcpConnection {
throw new Error(`Qwen ACP process failed to start`);
}
// Handle messages from ACP server
let buffer = '';
this.child.stdout?.on('data', (data) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
// Convert Node.js child process streams to Web Streams for SDK
const stdout = Readable.toWeb(
this.child.stdout!,
) as ReadableStream<Uint8Array>;
const stdin = Writable.toWeb(this.child.stdin!) as WritableStream;
for (const line of lines) {
if (line.trim()) {
const stream = ndJsonStream(stdin, stdout);
// Build the SDK Client implementation that bridges to our callbacks
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
this.sdkConnection = new ClientSideConnection(
(_agent: Agent): Client => ({
sessionUpdate(params: SessionNotification): Promise<void> {
console.log(
'[ACP] >>> Processing session_update:',
JSON.stringify(params).substring(0, 300),
);
self.onSessionUpdate(params as unknown as SessionNotification);
return Promise.resolve();
},
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const permissionData = params as unknown as RequestPermissionRequest;
try {
const message = JSON.parse(line) as AcpMessage;
console.log(
'[ACP] <<< Received message:',
JSON.stringify(message).substring(0, 500 * 3),
);
this.handleMessage(message);
} catch (_error) {
// Ignore non-JSON lines
console.log(
'[ACP] <<< Non-JSON line (ignored):',
line.substring(0, 200),
);
}
}
}
});
const response = await self.onPermissionRequest(permissionData);
const optionId = response?.optionId;
console.log('[ACP] Permission request:', optionId);
let outcome: 'selected' | 'cancelled';
if (
optionId &&
(optionId.includes('reject') || optionId === 'cancel')
) {
outcome = 'cancelled';
} else {
outcome = 'selected';
}
console.log('[ACP] Permission outcome:', outcome);
// Initialize protocol
const res = await this.sessionManager.initialize(
this.child,
this.pendingRequests,
this.nextRequestId,
if (outcome === 'cancelled') {
return { outcome: { outcome: 'cancelled' } };
}
return {
outcome: {
outcome: 'selected',
optionId: optionId || 'allow_once',
},
};
} catch (_error) {
return { outcome: { outcome: 'cancelled' } };
}
},
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
const result = await self.fileHandler.handleReadTextFile({
path: params.path,
sessionId: params.sessionId,
line: params.line ?? null,
limit: params.limit ?? null,
});
return { content: result.content };
},
async writeTextFile(
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> {
await self.fileHandler.handleWriteTextFile({
path: params.path,
content: params.content,
sessionId: params.sessionId,
});
return {};
},
async extNotification(
method: string,
params: Record<string, unknown>,
): Promise<void> {
if (method === 'authenticate/update') {
console.log(
'[ACP] >>> Processing authenticate_update:',
JSON.stringify(params).substring(0, 300),
);
self.onAuthenticateUpdate(
params as unknown as AuthenticateUpdateNotification,
);
} else {
console.warn(`[ACP] Unhandled extension notification: ${method}`);
}
},
}),
stream,
);
console.log('[ACP] Initialization response:', res);
// Initialize protocol via SDK
console.log('[ACP] Sending initialize request...');
const initResponse = await this.sdkConnection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
console.log('[ACP] Initialize successful');
console.log('[ACP] Initialization response:', initResponse);
try {
this.onInitialized(res);
this.onInitialized(initResponse);
} catch (err) {
console.warn('[ACP] onInitialized callback error:', err);
}
}
/**
* Handle received messages
*
* @param message - ACP message
*/
private handleMessage(message: AcpMessage): void {
const callbacks: AcpConnectionCallbacks = {
onSessionUpdate: this.onSessionUpdate,
onPermissionRequest: this.onPermissionRequest,
onAuthenticateUpdate: this.onAuthenticateUpdate,
onEndTurn: this.onEndTurn,
};
// Handle message
if ('method' in message) {
// Request or notification
this.messageHandler
.handleIncomingRequest(message, callbacks)
.then((result) => {
if ('id' in message && typeof message.id === 'number') {
this.messageHandler.sendResponseMessage(this.child, {
jsonrpc: JSONRPC_VERSION,
id: message.id,
result,
});
}
})
.catch((error) => {
if ('id' in message && typeof message.id === 'number') {
const errorMessage =
error instanceof Error
? error.message
: typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: String(error);
let errorCode: number = ACP_ERROR_CODES.INTERNAL_ERROR;
const errorCodeValue =
typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
if (typeof errorCodeValue === 'number') {
errorCode = errorCodeValue;
} else if (errorCodeValue === 'ENOENT') {
errorCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND;
}
this.messageHandler.sendResponseMessage(this.child, {
jsonrpc: JSONRPC_VERSION,
id: message.id,
error: {
code: errorCode,
message: errorMessage,
},
});
}
});
} else {
// Response
this.messageHandler.handleMessage(
message,
this.pendingRequests,
callbacks,
);
private ensureConnection(): ClientSideConnection {
if (!this.sdkConnection) {
throw new Error('Not connected to ACP agent');
}
return this.sdkConnection;
}
/**
* Authenticate
*
* @param methodId - Authentication method ID
* @returns Authentication response
*/
async authenticate(methodId?: string): Promise<AcpResponse> {
return this.sessionManager.authenticate(
methodId,
this.child,
this.pendingRequests,
this.nextRequestId,
async authenticate(methodId?: string): Promise<AuthenticateResponse> {
const conn = this.ensureConnection();
const authMethodId = methodId || 'default';
console.log(
'[ACP] Sending authenticate request with methodId:',
authMethodId,
);
const response = await conn.authenticate({ methodId: authMethodId });
console.log('[ACP] Authenticate successful', response);
return response;
}
/**
* Create new session
*
* @param cwd - Working directory
* @returns New session response
*/
async newSession(cwd: string = process.cwd()): Promise<AcpResponse> {
return this.sessionManager.newSession(
async newSession(cwd: string = process.cwd()): Promise<NewSessionResponse> {
const conn = this.ensureConnection();
console.log('[ACP] Sending session/new request with cwd:', cwd);
const response: NewSessionResponse = await conn.newSession({
cwd,
this.child,
this.pendingRequests,
this.nextRequestId,
);
mcpServers: [],
});
this.sessionId = response.sessionId || null;
console.log('[ACP] Session created with ID:', this.sessionId);
return response;
}
/**
* Send prompt message
*
* @param prompt - Prompt content
* @returns Response
*/
async sendPrompt(prompt: string): Promise<AcpResponse> {
return this.sessionManager.sendPrompt(
prompt,
this.child,
this.pendingRequests,
this.nextRequestId,
);
async sendPrompt(prompt: string): Promise<PromptResponse> {
const conn = this.ensureConnection();
if (!this.sessionId) {
throw new Error('No active ACP session');
}
const response: PromptResponse = await conn.prompt({
sessionId: this.sessionId,
prompt: [{ type: 'text', text: prompt }],
});
// Emit end-of-turn from stopReason
if (response.stopReason) {
this.onEndTurn(response.stopReason);
} else {
this.onEndTurn();
}
return response;
}
/**
* Load existing session
*
* @param sessionId - Session ID
* @returns Load response
*/
async loadSession(
sessionId: string,
cwdOverride?: string,
): Promise<AcpResponse> {
return this.sessionManager.loadSession(
sessionId,
this.child,
this.pendingRequests,
this.nextRequestId,
cwdOverride || this.workingDir,
);
): Promise<LoadSessionResponse> {
const conn = this.ensureConnection();
console.log('[ACP] Sending session/load request for session:', sessionId);
const cwd = cwdOverride || this.workingDir;
try {
const response = await conn.loadSession({
sessionId,
cwd,
mcpServers: [],
});
console.log('[ACP] Session load succeeded');
this.sessionId = sessionId;
return response;
} catch (error) {
console.error(
'[ACP] Session load request failed:',
error instanceof Error ? error.message : String(error),
);
throw error;
}
}
/**
* Get session list
*
* @returns Session list response
*/
async listSessions(options?: {
cursor?: number;
size?: number;
}): Promise<AcpResponse> {
return this.sessionManager.listSessions(
this.child,
this.pendingRequests,
this.nextRequestId,
this.workingDir,
options,
}): Promise<ListSessionsResponse> {
const conn = this.ensureConnection();
console.log('[ACP] Requesting session list...');
try {
const params: Record<string, unknown> = { cwd: this.workingDir };
if (options?.cursor !== undefined) {
params['cursor'] = String(options.cursor);
}
if (options?.size !== undefined) {
params['size'] = options.size;
}
const response = await conn.unstable_listSessions(
params as Parameters<typeof conn.unstable_listSessions>[0],
);
console.log(
'[ACP] Session list response:',
JSON.stringify(response).substring(0, 200),
);
return response;
} catch (error) {
console.error('[ACP] Failed to get session list:', error);
throw error;
}
}
async switchSession(sessionId: string): Promise<void> {
console.log('[ACP] Switching to session:', sessionId);
this.sessionId = sessionId;
console.log(
'[ACP] Session ID updated locally (switch not supported by CLI)',
);
}
/**
* Switch to specified session
*
* @param sessionId - Session ID
* @returns Switch response
*/
async switchSession(sessionId: string): Promise<AcpResponse> {
return this.sessionManager.switchSession(sessionId, this.nextRequestId);
}
/**
* Cancel current session prompt generation
*/
async cancelSession(): Promise<void> {
await this.sessionManager.cancelSession(this.child);
const conn = this.ensureConnection();
if (!this.sessionId) {
console.warn('[ACP] No active session to cancel');
return;
}
console.log('[ACP] Cancelling session:', this.sessionId);
await conn.cancel({ sessionId: this.sessionId });
console.log('[ACP] Cancel notification sent');
}
/**
* Save current session
*
* @param tag - Save tag
* @returns Save response
*/
async saveSession(tag: string): Promise<AcpResponse> {
return this.sessionManager.saveSession(
tag,
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* Set approval mode
*/
async setMode(modeId: ApprovalModeValue): Promise<AcpResponse> {
return this.sessionManager.setMode(
async setMode(modeId: ApprovalModeValue): Promise<SetSessionModeResponse> {
const conn = this.ensureConnection();
if (!this.sessionId) {
throw new Error('No active ACP session');
}
console.log('[ACP] Sending session/set_mode:', modeId);
const res = await conn.setSessionMode({
sessionId: this.sessionId,
modeId,
this.child,
this.pendingRequests,
this.nextRequestId,
);
});
console.log('[ACP] set_mode response:', res);
return res;
}
/**
* Set model for current session
*
* @param modelId - Model ID
* @returns Set model response
*/
async setModel(modelId: string): Promise<AcpResponse> {
return this.sessionManager.setModel(
async setModel(modelId: string): Promise<SetSessionModelResponse> {
const conn = this.ensureConnection();
if (!this.sessionId) {
throw new Error('No active ACP session');
}
console.log('[ACP] Sending session/set_model:', modelId);
const res = await conn.unstable_setSessionModel({
sessionId: this.sessionId,
modelId,
this.child,
this.pendingRequests,
this.nextRequestId,
);
});
console.log('[ACP] set_model response:', res);
return res;
}
/**
* Disconnect
*/
disconnect(): void {
if (this.child) {
this.child.kill();
this.child = null;
}
this.pendingRequests.clear();
this.sessionManager.reset();
this.sdkConnection = null;
this.sessionId = null;
}
/**
* Check if connected
*/
get isConnected(): boolean {
return this.child !== null && !this.child.killed;
}
/**
* Check if there is an active session
*/
get hasActiveSession(): boolean {
return this.sessionManager.getCurrentSessionId() !== null;
return this.sessionId !== null;
}
/**
* Get current session ID
*/
get currentSessionId(): string | null {
return this.sessionManager.getCurrentSessionId();
return this.sessionId;
}
}

View file

@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AcpFileHandler } from './acpFileHandler.js';
import { promises as fs } from 'fs';
vi.mock('fs', () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
},
}));
describe('AcpFileHandler', () => {
let handler: AcpFileHandler;
beforeEach(() => {
handler = new AcpFileHandler();
vi.clearAllMocks();
});
describe('handleReadTextFile', () => {
it('returns full content when no line/limit specified', async () => {
vi.mocked(fs.readFile).mockResolvedValue('line1\nline2\nline3\n');
const result = await handler.handleReadTextFile({
path: '/test/file.txt',
sessionId: 'sid',
line: null,
limit: null,
});
expect(result.content).toBe('line1\nline2\nline3\n');
});
it('uses 1-based line indexing (ACP spec)', async () => {
vi.mocked(fs.readFile).mockResolvedValue(
'line1\nline2\nline3\nline4\nline5',
);
const result = await handler.handleReadTextFile({
path: '/test/file.txt',
sessionId: 'sid',
line: 2,
limit: 2,
});
expect(result.content).toBe('line2\nline3');
});
it('treats line=1 as first line', async () => {
vi.mocked(fs.readFile).mockResolvedValue('first\nsecond\nthird');
const result = await handler.handleReadTextFile({
path: '/test/file.txt',
sessionId: 'sid',
line: 1,
limit: 1,
});
expect(result.content).toBe('first');
});
it('defaults to line=1 when line is null but limit is set', async () => {
vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc\nd');
const result = await handler.handleReadTextFile({
path: '/test/file.txt',
sessionId: 'sid',
line: null,
limit: 2,
});
expect(result.content).toBe('a\nb');
});
it('clamps negative line values to 0', async () => {
vi.mocked(fs.readFile).mockResolvedValue('a\nb\nc');
const result = await handler.handleReadTextFile({
path: '/test/file.txt',
sessionId: 'sid',
line: -5,
limit: null,
});
expect(result.content).toBe('a\nb\nc');
});
it('propagates ENOENT errors', async () => {
const err = new Error('ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(err);
await expect(
handler.handleReadTextFile({
path: '/missing/file.txt',
sessionId: 'sid',
line: null,
limit: null,
}),
).rejects.toThrow('ENOENT');
});
});
describe('handleWriteTextFile', () => {
it('creates directories and writes file', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
const result = await handler.handleWriteTextFile({
path: '/test/dir/file.txt',
content: 'hello',
sessionId: 'sid',
});
expect(result).toBeNull();
expect(fs.mkdir).toHaveBeenCalledWith('/test/dir', { recursive: true });
expect(fs.writeFile).toHaveBeenCalledWith(
'/test/dir/file.txt',
'hello',
'utf-8',
);
});
});
});

View file

@ -48,10 +48,11 @@ export class AcpFileHandler {
`[ACP] Successfully read file: ${params.path} (${content.length} bytes)`,
);
// Handle line offset and limit
// Handle line offset and limit.
// ACP spec: `line` is 1-based (first line = 1).
if (params.line !== null || params.limit !== null) {
const lines = content.split('\n');
const startLine = params.line || 0;
const startLine = Math.max(0, (params.line ?? 1) - 1);
const endLine = params.limit ? startLine + params.limit : lines.length;
const selectedLines = lines.slice(startLine, endLine);
const result = { content: selectedLines.join('\n') };

View file

@ -1,253 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP Message Handler
*
* Responsible for receiving, parsing, and distributing messages in the ACP protocol
*/
import type {
AcpMessage,
AcpRequest,
AcpNotification,
AcpResponse,
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
} from '../types/acpTypes.js';
import { CLIENT_METHODS } from '../constants/acpSchema.js';
import type {
PendingRequest,
AcpConnectionCallbacks,
} from '../types/connectionTypes.js';
import { AcpFileHandler } from '../services/acpFileHandler.js';
import type { ChildProcess } from 'child_process';
import { isWindows } from '../utils/platform.js';
/**
* ACP Message Handler Class
* Responsible for receiving, parsing, and processing messages
*/
export class AcpMessageHandler {
private fileHandler: AcpFileHandler;
constructor() {
this.fileHandler = new AcpFileHandler();
}
/**
* Send response message to child process
*
* @param child - Child process instance
* @param response - Response message
*/
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
if (child?.stdin) {
const jsonString = JSON.stringify(response);
const lineEnding = isWindows ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}
/**
* Handle received messages
*
* @param message - ACP message
* @param pendingRequests - Pending requests map
* @param callbacks - Callback functions collection
*/
handleMessage(
message: AcpMessage,
pendingRequests: Map<number, PendingRequest<unknown>>,
callbacks: AcpConnectionCallbacks,
): void {
try {
if ('method' in message) {
// Request or notification
this.handleIncomingRequest(message, callbacks).catch(() => {});
} else if (
'id' in message &&
typeof message.id === 'number' &&
pendingRequests.has(message.id)
) {
// Response
this.handleResponse(message, pendingRequests, callbacks);
}
} catch (error) {
console.error('[ACP] Error handling message:', error);
}
}
/**
* Handle response message
*
* @param message - Response message
* @param pendingRequests - Pending requests map
* @param callbacks - Callback functions collection
*/
private handleResponse(
message: AcpMessage,
pendingRequests: Map<number, PendingRequest<unknown>>,
callbacks: AcpConnectionCallbacks,
): void {
if (!('id' in message) || typeof message.id !== 'number') {
return;
}
const pendingRequest = pendingRequests.get(message.id);
if (!pendingRequest) {
return;
}
const { resolve, reject, method } = pendingRequest;
pendingRequests.delete(message.id);
if ('result' in message) {
console.log(
`[ACP] Response for ${method}:`,
// JSON.stringify(message.result).substring(0, 200),
message.result,
);
if (message.result && typeof message.result === 'object') {
const stopReasonValue =
(message.result as { stopReason?: unknown }).stopReason ??
(message.result as { stop_reason?: unknown }).stop_reason;
if (typeof stopReasonValue === 'string') {
callbacks.onEndTurn(stopReasonValue);
} else if (
'stopReason' in message.result ||
'stop_reason' in message.result
) {
// stop_reason present but not a string (e.g., null) -> still emit
callbacks.onEndTurn();
}
}
resolve(message.result);
} else if ('error' in message) {
const errorCode = message.error?.code || 'unknown';
const errorMsg = message.error?.message || 'Unknown ACP error';
const errorData = message.error?.data
? JSON.stringify(message.error.data)
: '';
console.error(`[ACP] Error response for ${method}:`, {
code: errorCode,
message: errorMsg,
data: errorData,
});
reject(
new Error(
`${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`,
),
);
}
}
/**
* Handle incoming requests
*
* @param message - Request or notification message
* @param callbacks - Callback functions collection
* @returns Request processing result
*/
async handleIncomingRequest(
message: AcpRequest | AcpNotification,
callbacks: AcpConnectionCallbacks,
): Promise<unknown> {
const { method, params } = message;
let result = null;
switch (method) {
case CLIENT_METHODS.session_update:
console.log(
'[ACP] >>> Processing session_update:',
JSON.stringify(params).substring(0, 300),
);
callbacks.onSessionUpdate(params as AcpSessionUpdate);
break;
case CLIENT_METHODS.authenticate_update:
console.log(
'[ACP] >>> Processing authenticate_update:',
JSON.stringify(params).substring(0, 300),
);
callbacks.onAuthenticateUpdate(
params as AuthenticateUpdateNotification,
);
break;
case CLIENT_METHODS.session_request_permission:
result = await this.handlePermissionRequest(
params as AcpPermissionRequest,
callbacks,
);
break;
case CLIENT_METHODS.fs_read_text_file:
result = await this.fileHandler.handleReadTextFile(
params as {
path: string;
sessionId: string;
line: number | null;
limit: number | null;
},
);
break;
case CLIENT_METHODS.fs_write_text_file:
result = await this.fileHandler.handleWriteTextFile(
params as { path: string; content: string; sessionId: string },
);
break;
default:
console.warn(`[ACP] Unhandled method: ${method}`);
break;
}
return result;
}
/**
* Handle permission requests
*
* @param params - Permission request parameters
* @param callbacks - Callback functions collection
* @returns Permission request result
*/
private async handlePermissionRequest(
params: AcpPermissionRequest,
callbacks: AcpConnectionCallbacks,
): Promise<{
outcome: { outcome: string; optionId: string };
}> {
try {
const response = await callbacks.onPermissionRequest(params);
const optionId = response?.optionId;
console.log('[ACP] Permission request:', optionId);
// Handle cancel, deny, or allow
let outcome: string;
if (optionId && (optionId.includes('reject') || optionId === 'cancel')) {
outcome = 'cancelled';
} else {
outcome = 'selected';
}
console.log('[ACP] Permission outcome:', outcome);
return {
outcome: {
outcome,
// optionId: optionId === 'cancel' ? 'cancel' : optionId,
optionId,
},
};
} catch (_error) {
return {
outcome: {
outcome: 'rejected',
optionId: 'reject_once',
},
};
}
}
}

View file

@ -1,147 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AcpSessionManager } from './acpSessionManager.js';
import type { ChildProcess } from 'child_process';
import type { PendingRequest } from '../types/connectionTypes.js';
import { AGENT_METHODS } from '../constants/acpSchema.js';
describe('AcpSessionManager', () => {
let sessionManager: AcpSessionManager;
let mockChild: ChildProcess;
let pendingRequests: Map<number, PendingRequest<unknown>>;
let nextRequestId: { value: number };
let writtenMessages: string[];
beforeEach(() => {
sessionManager = new AcpSessionManager();
writtenMessages = [];
mockChild = {
stdin: {
write: vi.fn((msg: string) => {
writtenMessages.push(msg);
// Simulate async response
const parsed = JSON.parse(msg.trim());
const id = parsed.id;
setTimeout(() => {
const pending = pendingRequests.get(id);
if (pending) {
pending.resolve({ modeId: 'default', modelId: 'test-model' });
pendingRequests.delete(id);
}
}, 10);
}),
},
} as unknown as ChildProcess;
pendingRequests = new Map();
nextRequestId = { value: 0 };
});
describe('setModel', () => {
it('sends session/set_model request with correct parameters', async () => {
// First initialize the session
// @ts-expect-error - accessing private property for testing
sessionManager.sessionId = 'test-session-id';
const responsePromise = sessionManager.setModel(
'qwen3-coder-plus',
mockChild,
pendingRequests,
nextRequestId,
);
// Wait for the response
const response = await responsePromise;
// Verify the message was sent
expect(writtenMessages.length).toBe(1);
const sentMessage = JSON.parse(writtenMessages[0].trim());
expect(sentMessage.method).toBe(AGENT_METHODS.session_set_model);
expect(sentMessage.params).toEqual({
sessionId: 'test-session-id',
modelId: 'qwen3-coder-plus',
});
expect(response).toEqual({ modeId: 'default', modelId: 'test-model' });
});
it('throws error when no active session', async () => {
await expect(
sessionManager.setModel(
'qwen3-coder-plus',
mockChild,
pendingRequests,
nextRequestId,
),
).rejects.toThrow('No active ACP session');
});
it('increments request ID for each call', async () => {
// @ts-expect-error - accessing private property for testing
sessionManager.sessionId = 'test-session-id';
await sessionManager.setModel(
'model-1',
mockChild,
pendingRequests,
nextRequestId,
);
await sessionManager.setModel(
'model-2',
mockChild,
pendingRequests,
nextRequestId,
);
const firstMessage = JSON.parse(writtenMessages[0].trim());
const secondMessage = JSON.parse(writtenMessages[1].trim());
expect(firstMessage.id).toBe(0);
expect(secondMessage.id).toBe(1);
});
});
describe('setMode', () => {
it('sends session/set_mode request with correct parameters', async () => {
// @ts-expect-error - accessing private property for testing
sessionManager.sessionId = 'test-session-id';
const responsePromise = sessionManager.setMode(
'auto-edit',
mockChild,
pendingRequests,
nextRequestId,
);
const response = await responsePromise;
expect(writtenMessages.length).toBe(1);
const sentMessage = JSON.parse(writtenMessages[0].trim());
expect(sentMessage.method).toBe(AGENT_METHODS.session_set_mode);
expect(sentMessage.params).toEqual({
sessionId: 'test-session-id',
modeId: 'auto-edit',
});
expect(response).toBeDefined();
});
it('throws error when no active session', async () => {
await expect(
sessionManager.setMode(
'default',
mockChild,
pendingRequests,
nextRequestId,
),
).rejects.toThrow('No active ACP session');
});
});
});

View file

@ -1,511 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP Session Manager
*
* Responsible for managing ACP protocol session operations, including initialization, authentication, session creation, and switching
*/
import { JSONRPC_VERSION } from '../types/acpTypes.js';
import type {
AcpRequest,
AcpNotification,
AcpResponse,
} from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { AGENT_METHODS } from '../constants/acpSchema.js';
import type { PendingRequest } from '../types/connectionTypes.js';
import type { ChildProcess } from 'child_process';
import { isWindows } from '../utils/platform.js';
/**
* ACP Session Manager Class
* Provides session initialization, authentication, creation, loading, and switching functionality
*/
export class AcpSessionManager {
private sessionId: string | null = null;
private isInitialized = false;
/**
* Send request to ACP server
*
* @param method - Request method name
* @param params - Request parameters
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns Request response
*/
private sendRequest<T = unknown>(
method: string,
params: Record<string, unknown> | undefined,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<T> {
const id = nextRequestId.value++;
const message: AcpRequest = {
jsonrpc: JSONRPC_VERSION,
id,
method,
...(params && { params }),
};
return new Promise((resolve, reject) => {
// No timeout for session_prompt as LLM tasks can take 5-10 minutes or longer
// The request should always terminate with a stop_reason
let timeoutId: NodeJS.Timeout | undefined;
let timeoutDuration: number | undefined;
if (method !== AGENT_METHODS.session_prompt) {
// Set timeout for other methods
timeoutDuration = method === AGENT_METHODS.initialize ? 120000 : 60000;
timeoutId = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
}
const pendingRequest: PendingRequest<T> = {
resolve: (value: T) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve(value);
},
reject: (error: Error) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
reject(error);
},
timeoutId,
method,
};
pendingRequests.set(id, pendingRequest as PendingRequest<unknown>);
this.sendMessage(message, child);
});
}
/**
* Send message to child process
*
* @param message - Request or notification message
* @param child - Child process instance
*/
private sendMessage(
message: AcpRequest | AcpNotification,
child: ChildProcess | null,
): void {
if (child?.stdin) {
const jsonString = JSON.stringify(message);
const lineEnding = isWindows ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}
/**
* Initialize ACP protocol connection
*
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns Initialization response
*/
async initialize(
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
const initializeParams = {
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
};
console.log('[ACP] Sending initialize request...');
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.initialize,
initializeParams,
child,
pendingRequests,
nextRequestId,
);
this.isInitialized = true;
console.log('[ACP] Initialize successful');
return response;
}
/**
* Perform authentication
*
* @param methodId - Authentication method ID
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns Authentication response
*/
async authenticate(
methodId: string | undefined,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
const authMethodId = methodId || 'default';
console.log(
'[ACP] Sending authenticate request with methodId:',
authMethodId,
);
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.authenticate,
{
methodId: authMethodId,
},
child,
pendingRequests,
nextRequestId,
);
console.log('[ACP] Authenticate successful', response);
return response;
}
/**
* Create new session
*
* @param cwd - Working directory
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns New session response
*/
async newSession(
cwd: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
console.log('[ACP] Sending session/new request with cwd:', cwd);
const response = await this.sendRequest<
AcpResponse & { sessionId?: string }
>(
AGENT_METHODS.session_new,
{
cwd,
mcpServers: [],
},
child,
pendingRequests,
nextRequestId,
);
this.sessionId = (response && response.sessionId) || null;
console.log('[ACP] Session created with ID:', this.sessionId);
return response;
}
/**
* Send prompt message
*
* @param prompt - Prompt content
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns Response
* @throws Error when there is no active session
*/
async sendPrompt(
prompt: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
return await this.sendRequest(
AGENT_METHODS.session_prompt,
{
sessionId: this.sessionId,
prompt: [{ type: 'text', text: prompt }],
},
child,
pendingRequests,
nextRequestId,
);
}
/**
* Load existing session
*
* @param sessionId - Session ID
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns Load response
*/
async loadSession(
sessionId: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
cwd: string = process.cwd(),
): Promise<AcpResponse> {
console.log('[ACP] Sending session/load request for session:', sessionId);
console.log('[ACP] Request parameters:', {
sessionId,
cwd,
mcpServers: [],
});
try {
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.session_load,
{
sessionId,
cwd,
mcpServers: [],
},
child,
pendingRequests,
nextRequestId,
);
console.log(
'[ACP] Session load response:',
JSON.stringify(response).substring(0, 500),
);
// Check if response contains an error
if (response && response.error) {
console.error('[ACP] Session load returned error:', response.error);
} else {
console.log('[ACP] Session load succeeded');
// session/load returns null on success per schema; update local sessionId
// so subsequent prompts use the loaded session.
this.sessionId = sessionId;
}
return response;
} catch (error) {
console.error(
'[ACP] Session load request failed with exception:',
error instanceof Error ? error.message : String(error),
);
throw error;
}
}
/**
* Get session list
*
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns Session list response
*/
async listSessions(
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
cwd: string = process.cwd(),
options?: { cursor?: number; size?: number },
): Promise<AcpResponse> {
console.log('[ACP] Requesting session list...');
try {
// session/list requires cwd in params per ACP schema
const params: Record<string, unknown> = { cwd };
if (options?.cursor !== undefined) {
params.cursor = options.cursor;
}
if (options?.size !== undefined) {
params.size = options.size;
}
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.session_list,
params,
child,
pendingRequests,
nextRequestId,
);
console.log(
'[ACP] Session list response:',
JSON.stringify(response).substring(0, 200),
);
return response;
} catch (error) {
console.error('[ACP] Failed to get session list:', error);
throw error;
}
}
/**
* Set approval mode for current session (ACP session/set_mode)
*
* @param modeId - Approval mode value
*/
async setMode(
modeId: ApprovalModeValue,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
console.log('[ACP] Sending session/set_mode:', modeId);
const res = await this.sendRequest<AcpResponse>(
AGENT_METHODS.session_set_mode,
{ sessionId: this.sessionId, modeId },
child,
pendingRequests,
nextRequestId,
);
console.log('[ACP] set_mode response:', res);
return res;
}
/**
* Set model for current session (ACP session/set_model)
*
* @param modelId - Model ID
*/
async setModel(
modelId: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
console.log('[ACP] Sending session/set_model:', modelId);
const res = await this.sendRequest<AcpResponse>(
AGENT_METHODS.session_set_model,
{ sessionId: this.sessionId, modelId },
child,
pendingRequests,
nextRequestId,
);
console.log('[ACP] set_model response:', res);
return res;
}
/**
* Switch to specified session
*
* @param sessionId - Session ID
* @param nextRequestId - Request ID counter
* @returns Switch response
*/
async switchSession(
sessionId: string,
nextRequestId: { value: number },
): Promise<AcpResponse> {
console.log('[ACP] Switching to session:', sessionId);
this.sessionId = sessionId;
const mockResponse: AcpResponse = {
jsonrpc: JSONRPC_VERSION,
id: nextRequestId.value++,
result: { sessionId },
};
console.log(
'[ACP] Session ID updated locally (switch not supported by CLI)',
);
return mockResponse;
}
/**
* Cancel prompt generation for current session
*
* @param child - Child process instance
*/
async cancelSession(child: ChildProcess | null): Promise<void> {
if (!this.sessionId) {
console.warn('[ACP] No active session to cancel');
return;
}
console.log('[ACP] Cancelling session:', this.sessionId);
const cancelParams = {
sessionId: this.sessionId,
};
const message: AcpNotification = {
jsonrpc: JSONRPC_VERSION,
method: AGENT_METHODS.session_cancel,
params: cancelParams,
};
this.sendMessage(message, child);
console.log('[ACP] Cancel notification sent');
}
/**
* Save current session
*
* @param tag - Save tag
* @param child - Child process instance
* @param pendingRequests - Pending requests map
* @param nextRequestId - Request ID counter
* @returns Save response
*/
async saveSession(
tag: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
console.log('[ACP] Saving session with tag:', tag);
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.session_save,
{
sessionId: this.sessionId,
tag,
},
child,
pendingRequests,
nextRequestId,
);
console.log('[ACP] Session save response:', response);
return response;
}
/**
* Reset session manager state
*/
reset(): void {
this.sessionId = null;
this.isInitialized = false;
}
/**
* Get current session ID
*/
getCurrentSessionId(): string | null {
return this.sessionId;
}
/**
* Check if initialized
*/
getIsInitialized(): boolean {
return this.isInitialized;
}
}

View file

@ -5,12 +5,12 @@
*/
import { AcpConnection } from './acpConnection.js';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
ModelInfo,
AvailableCommand,
} from '../types/acpTypes.js';
RequestPermissionRequest,
SessionNotification,
} from '@agentclientprotocol/sdk';
import type { AuthenticateUpdateNotification } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
import { QwenSessionManager } from './qwenSessionManager.js';
@ -74,9 +74,13 @@ export class QwenAgentManager {
this.sessionUpdateHandler = new QwenSessionUpdateHandler({});
// Set ACP connection callbacks
this.connection.onSessionUpdate = (data: AcpSessionUpdate) => {
this.connection.onSessionUpdate = (data: SessionNotification) => {
// If we are rehydrating a loaded session, map message chunks into
// full messages for the UI, instead of streaming behavior.
// discrete messages for the UI instead of streaming behavior.
// During rehydration the webview is NOT in streaming mode, so
// streaming-only callbacks (onStreamChunk, onThoughtChunk) would be
// silently dropped by the UI. Route all text-bearing updates through
// onMessage which calls addMessage() regardless of streaming state.
try {
const targetId = this.rehydratingSessionId;
if (
@ -91,19 +95,18 @@ export class QwenAgentManager {
update: {
sessionUpdate: string;
content?: { text?: string };
_meta?: { timestamp?: number };
_meta?: Record<string, unknown>;
};
}
).update;
const text = update?.content?.text || '';
const metaObj = update?._meta ?? {};
const timestamp =
typeof update?._meta?.timestamp === 'number'
? update._meta.timestamp
typeof metaObj['timestamp'] === 'number'
? (metaObj['timestamp'] as number)
: Date.now();
if (update?.sessionUpdate === 'user_message_chunk' && text) {
console.log(
'[QwenAgentManager] Rehydration: routing user message chunk',
);
this.callbacks.onMessage?.({
role: 'user',
content: text,
@ -111,10 +114,8 @@ export class QwenAgentManager {
});
return;
}
if (update?.sessionUpdate === 'agent_message_chunk' && text) {
console.log(
'[QwenAgentManager] Rehydration: routing agent message chunk',
);
this.callbacks.onMessage?.({
role: 'assistant',
content: text,
@ -122,10 +123,44 @@ export class QwenAgentManager {
});
return;
}
// For other types during rehydration, fall through to normal handler
console.log(
'[QwenAgentManager] Rehydration: non-text update, forwarding to handler',
);
if (update?.sessionUpdate === 'agent_thought_chunk' && text) {
this.callbacks.onMessage?.({
role: 'thinking',
content: text,
timestamp,
});
return;
}
// Usage-only agent_message_chunk (empty text): forward usage but
// skip the empty stream chunk that would be discarded anyway.
if (
update?.sessionUpdate === 'agent_message_chunk' &&
!text &&
metaObj['usage']
) {
if (this.callbacks.onUsageUpdate) {
const raw = metaObj['usage'] as Record<string, unknown>;
this.callbacks.onUsageUpdate({
usage: {
inputTokens: raw['inputTokens'] as number | undefined,
outputTokens: raw['outputTokens'] as number | undefined,
totalTokens: raw['totalTokens'] as number | undefined,
thoughtTokens: raw['thoughtTokens'] as number | undefined,
cachedReadTokens: raw['cachedReadTokens'] as
| number
| undefined,
},
durationMs: metaObj['durationMs'] as number | undefined,
});
}
return;
}
// Tool calls, plans, mode/model updates: fall through to the
// normal handler which emits them via dedicated callbacks that
// the webview can process independently of streaming state.
}
} catch (err) {
console.warn('[QwenAgentManager] Rehydration routing failed:', err);
@ -136,7 +171,7 @@ export class QwenAgentManager {
};
this.connection.onPermissionRequest = async (
data: AcpPermissionRequest,
data: RequestPermissionRequest,
) => {
if (this.callbacks.onPermissionRequest) {
const optionId = await this.callbacks.onPermissionRequest(data);
@ -249,16 +284,9 @@ export class QwenAgentManager {
): Promise<ApprovalModeValue> {
const modeId = mode;
try {
const res = await this.connection.setMode(modeId);
// Optimistically notify UI using response
const result = (res?.result || {}) as { modeId?: string };
const confirmed =
(result.modeId as
| 'plan'
| 'default'
| 'auto-edit'
| 'yolo'
| undefined) || modeId;
await this.connection.setMode(modeId);
// set_mode response has no mode payload; use requested value.
const confirmed = modeId;
this.callbacks.onModeChanged?.(confirmed);
return confirmed;
} catch (err) {
@ -272,10 +300,8 @@ export class QwenAgentManager {
*/
async setModelFromUi(modelId: string): Promise<ModelInfo | null> {
try {
const res = await this.connection.setModel(modelId);
// Parse response and notify UI
const result = (res?.result || {}) as { modelId?: string };
const confirmedModelId = result.modelId || modelId;
await this.connection.setModel(modelId);
const confirmedModelId = modelId;
const modelInfo: ModelInfo = {
modelId: confirmedModelId,
name: confirmedModelId,
@ -338,19 +364,13 @@ export class QwenAgentManager {
const response = await this.connection.listSessions();
console.log('[QwenAgentManager] ACP session list response:', response);
// sendRequest resolves with the JSON-RPC "result" directly
// Newer CLI returns an object: { items: [...], nextCursor?, hasMore }
// Older prototypes might return an array. Support both.
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
// "result" directly (not the full AcpResponse). Treat it as unknown
// and carefully narrow before accessing `items` to satisfy strict TS.
if (res && typeof res === 'object' && 'items' in res) {
const itemsValue = (res as { items?: unknown }).items;
items = Array.isArray(itemsValue)
? (itemsValue as Array<Record<string, unknown>>)
if (res && typeof res === 'object' && 'sessions' in res) {
const sessionsValue = (res as { sessions?: unknown }).sessions;
items = Array.isArray(sessionsValue)
? (sessionsValue as Array<Record<string, unknown>>)
: [];
}
@ -366,7 +386,7 @@ export class QwenAgentManager {
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
lastUpdated: item.updatedAt || item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
@ -445,17 +465,14 @@ export class QwenAgentManager {
size,
...(cursor !== undefined ? { cursor } : {}),
});
// sendRequest resolves with the JSON-RPC "result" directly
const res: unknown = response;
let items: Array<Record<string, unknown>> = [];
if (Array.isArray(res)) {
items = res;
} else if (typeof res === 'object' && res !== null && 'items' in res) {
const responseObject = res as {
items?: Array<Record<string, unknown>>;
};
items = Array.isArray(responseObject.items) ? responseObject.items : [];
if (res && typeof res === 'object' && 'sessions' in res) {
const sessionsValue = (res as { sessions?: unknown }).sessions;
items = Array.isArray(sessionsValue)
? (sessionsValue as Array<Record<string, unknown>>)
: [];
}
const mapped = items.map((item) => ({
@ -464,25 +481,29 @@ export class QwenAgentManager {
title: item.title || item.name || item.prompt || 'Untitled Session',
name: item.title || item.name || item.prompt || 'Untitled Session',
startTime: item.startTime,
lastUpdated: item.mtime || item.lastUpdated,
lastUpdated: item.updatedAt || item.mtime || item.lastUpdated,
messageCount: item.messageCount || 0,
projectHash: item.projectHash,
filePath: item.filePath,
cwd: item.cwd,
}));
const nextCursor: number | undefined =
typeof res === 'object' && res !== null && 'nextCursor' in res
? typeof res.nextCursor === 'number'
? res.nextCursor
: undefined
: undefined;
const hasMore: boolean =
typeof res === 'object' && res !== null && 'hasMore' in res
? Boolean(res.hasMore)
: false;
// SDK returns nextCursor as string; convert to numeric cursor for paging
let nextCursorNum: number | undefined;
if (typeof res === 'object' && res !== null && 'nextCursor' in res) {
const raw = (res as { nextCursor?: unknown }).nextCursor;
if (typeof raw === 'number') {
nextCursorNum = raw;
} else if (typeof raw === 'string') {
const parsed = Number(raw);
if (!Number.isNaN(parsed)) {
nextCursorNum = parsed;
}
}
}
const hasMore = nextCursorNum !== undefined;
return { sessions: mapped, nextCursor, hasMore };
return { sessions: mapped, nextCursor: nextCursorNum, hasMore };
} catch (error) {
console.warn('[QwenAgentManager] Paged ACP session list failed:', error);
// fall through to file system
@ -893,63 +914,6 @@ export class QwenAgentManager {
}
}
/**
* Save session via /chat save command
* Since CLI doesn't support session/save ACP method, we send /chat save command directly
*
* @param sessionId - Session ID
* @param tag - Save tag
* @returns Save response
*/
async saveSessionViaCommand(
sessionId: string,
tag: string,
): Promise<{ success: boolean; message?: string }> {
try {
console.log(
'[QwenAgentManager] Saving session via /chat save command:',
sessionId,
'with tag:',
tag,
);
// Send /chat save command as a prompt
// The CLI will handle this as a special command
await this.connection.sendPrompt(`/chat save "${tag}"`);
console.log('[QwenAgentManager] /chat save command sent successfully');
return {
success: true,
message: `Session saved with tag: ${tag}`,
};
} catch (error) {
console.error('[QwenAgentManager] /chat save command failed:', error);
return {
success: false,
message: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Save session via ACP session/save method (deprecated, CLI doesn't support)
*
* @deprecated Use saveSessionViaCommand instead
* @param sessionId - Session ID
* @param tag - Save tag
* @returns Save response
*/
async saveSessionViaAcp(
sessionId: string,
tag: string,
): Promise<{ success: boolean; message?: string }> {
// Fallback to command-based save since CLI doesn't support session/save ACP method
console.warn(
'[QwenAgentManager] saveSessionViaAcp is deprecated, using command-based save instead',
);
return this.saveSessionViaCommand(sessionId, tag);
}
/**
* Try to load session via ACP session/load method
* This method will only be used if CLI version supports it
@ -980,6 +944,20 @@ export class QwenAgentManager {
'[QwenAgentManager] Session load succeeded. Response:',
JSON.stringify(response).substring(0, 200),
);
// Extract model/mode state from load response (same shape as newSession)
const modelInfo = extractModelInfoFromNewSessionResult(response);
if (modelInfo && this.callbacks.onModelInfo) {
this.callbacks.onModelInfo(modelInfo);
}
const modelState = extractSessionModelState(response);
if (
modelState?.availableModels &&
modelState.availableModels.length > 0
) {
this.callbacks.onAvailableModels?.(modelState.availableModels);
}
return response;
} catch (error) {
const errorMessage =
@ -1307,7 +1285,7 @@ export class QwenAgentManager {
* @param callback - Permission request callback function
*/
onPermissionRequest(
callback: (request: AcpPermissionRequest) => Promise<string>,
callback: (request: RequestPermissionRequest) => Promise<string>,
): void {
this.callbacks.onPermissionRequest = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
@ -1367,7 +1345,7 @@ export class QwenAgentManager {
}
/**
* Register callback for model changed updates (from ACP current_model_update)
* Register callback for model changed updates.
*/
onModelChanged(callback: (model: ModelInfo) => void): void {
this.callbacks.onModelChanged = callback;

View file

@ -17,7 +17,7 @@ import {
extractModelInfoFromNewSessionResult,
extractSessionModelState,
} from '../utils/acpModelInfo.js';
import type { ModelInfo } from '../types/acpTypes.js';
import type { ModelInfo } from '@agentclientprotocol/sdk';
export interface QwenConnectionResult {
sessionCreated: boolean;

View file

@ -9,17 +9,16 @@ import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js';
import type { QwenSession, QwenMessage } from './qwenSessionReader.js';
import type { QwenSession } from './qwenSessionReader.js';
/**
* Qwen Session Manager
*
* This service provides direct filesystem access to save and load sessions
* without relying on the CLI's ACP session/save method.
* This service provides direct filesystem access to load sessions.
*
* Note: This is primarily used as a fallback mechanism when ACP methods are
* unavailable or fail. In normal operation, ACP session/list and session/load
* should be preferred for consistency with the CLI.
* Note: Sessions are auto-saved by the CLI's ChatRecordingService.
* This class is primarily used as a fallback mechanism for loading sessions
* when ACP methods are unavailable or fail.
*/
export class QwenSessionManager {
private qwenDir: string;
@ -44,60 +43,6 @@ export class QwenSessionManager {
return crypto.randomUUID();
}
/**
* Save current conversation as a named session
*
* @param messages - Current conversation messages
* @param sessionName - Name/tag for the saved session
* @param workingDir - Current working directory
* @returns Session ID of the saved session
*/
async saveSession(
messages: QwenMessage[],
sessionName: string,
workingDir: string,
): Promise<string> {
try {
// Create session directory if it doesn't exist
const sessionDir = this.getSessionDir(workingDir);
if (!fs.existsSync(sessionDir)) {
fs.mkdirSync(sessionDir, { recursive: true });
}
// Generate session ID and filename using CLI's naming convention
const sessionId = this.generateSessionId();
const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars)
const now = new Date();
const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
const isoTime = now
.toISOString()
.split('T')[1]
.split(':')
.slice(0, 2)
.join('-'); // HH-MM
const filename = `session-${isoDate}T${isoTime}-${shortId}.json`;
const filePath = path.join(sessionDir, filename);
// Create session object
const session: QwenSession = {
sessionId,
projectHash: getProjectHash(workingDir),
startTime: messages[0]?.timestamp || new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages,
};
// Save session to file
fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8');
console.log(`[QwenSessionManager] Session saved: ${filePath}`);
return sessionId;
} catch (error) {
console.error('[QwenSessionManager] Failed to save session:', error);
throw error;
}
}
/**
* Load a saved session by name
*

View file

@ -6,7 +6,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
import type { AcpSessionUpdate } from '../types/acpTypes.js';
import type { SessionNotification } from '@agentclientprotocol/sdk';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
@ -28,81 +28,15 @@ describe('QwenSessionUpdateHandler', () => {
handler = new QwenSessionUpdateHandler(mockCallbacks);
});
describe('current_model_update handling', () => {
it('calls onModelChanged callback with model info', () => {
const modelUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: 'A powerful coding model',
},
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(modelUpdate);
expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({
modelId: 'qwen3-coder-plus',
name: 'Qwen3 Coder Plus',
description: 'A powerful coding model',
});
});
it('handles model update with _meta field', () => {
const modelUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'test-model',
name: 'Test Model',
_meta: { contextLimit: 128000 },
},
},
} as AcpSessionUpdate;
handler.handleSessionUpdate(modelUpdate);
expect(mockCallbacks.onModelChanged).toHaveBeenCalledWith({
modelId: 'test-model',
name: 'Test Model',
_meta: { contextLimit: 128000 },
});
});
it('does not call callback when onModelChanged is not set', () => {
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
const modelUpdate: AcpSessionUpdate = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'qwen3-coder',
name: 'Qwen3 Coder',
},
},
} as AcpSessionUpdate;
// Should not throw
expect(() =>
handlerWithoutCallback.handleSessionUpdate(modelUpdate),
).not.toThrow();
});
});
describe('current_mode_update handling', () => {
it('calls onModeChanged callback with mode id', () => {
const modeUpdate: AcpSessionUpdate = {
const modeUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_mode_update',
modeId: 'auto-edit' as ApprovalModeValue,
currentModeId: 'auto-edit' as ApprovalModeValue,
},
} as AcpSessionUpdate;
} as SessionNotification;
handler.handleSessionUpdate(modeUpdate);
@ -112,7 +46,7 @@ describe('QwenSessionUpdateHandler', () => {
describe('agent_message_chunk handling', () => {
it('calls onStreamChunk callback with text content', () => {
const messageUpdate: AcpSessionUpdate = {
const messageUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'agent_message_chunk',
@ -129,7 +63,7 @@ describe('QwenSessionUpdateHandler', () => {
});
it('emits usage metadata when present', () => {
const messageUpdate: AcpSessionUpdate = {
const messageUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'agent_message_chunk',
@ -152,18 +86,66 @@ describe('QwenSessionUpdateHandler', () => {
expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({
usage: {
inputTokens: 100,
outputTokens: 50,
thoughtTokens: undefined,
totalTokens: 150,
cachedReadTokens: undefined,
cachedWriteTokens: undefined,
promptTokens: 100,
completionTokens: 50,
totalTokens: 150,
thoughtsTokens: undefined,
cachedTokens: undefined,
},
durationMs: 1234,
});
});
it('maps SDK usage field names to both SDK and legacy fields', () => {
const messageUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Response',
},
_meta: {
usage: {
inputTokens: 200,
outputTokens: 80,
thoughtTokens: 30,
totalTokens: 310,
cachedReadTokens: 10,
} as never,
durationMs: 500,
},
},
};
handler.handleSessionUpdate(messageUpdate);
expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({
usage: {
inputTokens: 200,
outputTokens: 80,
thoughtTokens: 30,
totalTokens: 310,
cachedReadTokens: 10,
cachedWriteTokens: undefined,
promptTokens: 200,
completionTokens: 80,
thoughtsTokens: 30,
cachedTokens: 10,
},
durationMs: 500,
});
});
});
describe('tool_call handling', () => {
it('calls onToolCall callback with tool call data', () => {
const toolCallUpdate: AcpSessionUpdate = {
const toolCallUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'tool_call',
@ -191,7 +173,7 @@ describe('QwenSessionUpdateHandler', () => {
describe('plan handling', () => {
it('calls onPlan callback with plan entries', () => {
const planUpdate: AcpSessionUpdate = {
const planUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'plan',
@ -215,7 +197,7 @@ describe('QwenSessionUpdateHandler', () => {
onStreamChunk: vi.fn(),
});
const planUpdate: AcpSessionUpdate = {
const planUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'plan',
@ -231,7 +213,7 @@ describe('QwenSessionUpdateHandler', () => {
describe('available_commands_update handling', () => {
it('calls onAvailableCommands callback with commands', () => {
const commandsUpdate: AcpSessionUpdate = {
const commandsUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
@ -253,7 +235,7 @@ describe('QwenSessionUpdateHandler', () => {
},
],
},
} as AcpSessionUpdate;
} as SessionNotification;
handler.handleSessionUpdate(commandsUpdate);
@ -269,7 +251,7 @@ describe('QwenSessionUpdateHandler', () => {
});
it('handles commands with input hint', () => {
const commandsUpdate: AcpSessionUpdate = {
const commandsUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
@ -281,7 +263,7 @@ describe('QwenSessionUpdateHandler', () => {
},
],
},
} as AcpSessionUpdate;
} as SessionNotification;
handler.handleSessionUpdate(commandsUpdate);
@ -297,7 +279,7 @@ describe('QwenSessionUpdateHandler', () => {
it('does not call callback when onAvailableCommands is not set', () => {
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
const commandsUpdate: AcpSessionUpdate = {
const commandsUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
@ -305,7 +287,7 @@ describe('QwenSessionUpdateHandler', () => {
{ name: 'compress', description: 'Compress', input: null },
],
},
} as AcpSessionUpdate;
} as SessionNotification;
// Should not throw
expect(() =>
@ -314,13 +296,13 @@ describe('QwenSessionUpdateHandler', () => {
});
it('handles empty commands list', () => {
const commandsUpdate: AcpSessionUpdate = {
const commandsUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [],
},
} as AcpSessionUpdate;
} as SessionNotification;
handler.handleSessionUpdate(commandsUpdate);
@ -329,28 +311,25 @@ describe('QwenSessionUpdateHandler', () => {
});
describe('updateCallbacks', () => {
it('updates callbacks and uses new ones', () => {
const newOnModelChanged = vi.fn();
it('updates mode callback and uses new one', () => {
const newOnModeChanged = vi.fn();
handler.updateCallbacks({
...mockCallbacks,
onModelChanged: newOnModelChanged,
onModeChanged: newOnModeChanged,
});
const modelUpdate: AcpSessionUpdate = {
const modeUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'current_model_update',
model: {
modelId: 'new-model',
name: 'New Model',
},
sessionUpdate: 'current_mode_update',
currentModeId: 'yolo' as ApprovalModeValue,
},
} as AcpSessionUpdate;
} as SessionNotification;
handler.handleSessionUpdate(modelUpdate);
handler.handleSessionUpdate(modeUpdate);
expect(newOnModelChanged).toHaveBeenCalled();
expect(mockCallbacks.onModelChanged).not.toHaveBeenCalled();
expect(newOnModeChanged).toHaveBeenCalled();
expect(mockCallbacks.onModeChanged).not.toHaveBeenCalled();
});
it('updates onAvailableCommands callback', () => {
@ -360,7 +339,7 @@ describe('QwenSessionUpdateHandler', () => {
onAvailableCommands: newOnAvailableCommands,
});
const commandsUpdate: AcpSessionUpdate = {
const commandsUpdate: SessionNotification = {
sessionId: 'test-session',
update: {
sessionUpdate: 'available_commands_update',
@ -368,7 +347,7 @@ describe('QwenSessionUpdateHandler', () => {
{ name: 'test', description: 'Test command', input: null },
],
},
} as AcpSessionUpdate;
} as SessionNotification;
handler.handleSessionUpdate(commandsUpdate);

View file

@ -11,11 +11,10 @@
*/
import type {
AcpSessionUpdate,
SessionUpdateMeta,
ModelInfo,
SessionNotification,
AvailableCommand,
} from '../types/acpTypes.js';
} from '@agentclientprotocol/sdk';
import type { SessionUpdateMeta } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type {
QwenAgentCallbacks,
@ -47,41 +46,58 @@ export class QwenSessionUpdateHandler {
*
* @param data - ACP session update data
*/
handleSessionUpdate(data: AcpSessionUpdate): void {
handleSessionUpdate(data: SessionNotification): void {
const update = data.update;
const sessionUpdate = (update as { sessionUpdate?: string }).sessionUpdate;
console.log(
'[SessionUpdateHandler] Processing update type:',
update.sessionUpdate,
sessionUpdate,
);
switch (update.sessionUpdate) {
case 'user_message_chunk':
if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text);
switch (sessionUpdate) {
case 'user_message_chunk': {
const text = this.getTextContent(
(update as { content?: unknown }).content,
);
if (text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(text);
}
break;
}
case 'agent_message_chunk':
if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text);
case 'agent_message_chunk': {
const text = this.getTextContent(
(update as { content?: unknown }).content,
);
if (text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(text);
}
this.emitUsageMeta(update._meta);
this.emitUsageMeta(
(update as { _meta?: SessionUpdateMeta | null })._meta,
);
break;
}
case 'agent_thought_chunk':
if (update.content?.text) {
case 'agent_thought_chunk': {
const text = this.getTextContent(
(update as { content?: unknown }).content,
);
if (text) {
if (this.callbacks.onThoughtChunk) {
this.callbacks.onThoughtChunk(update.content.text);
this.callbacks.onThoughtChunk(text);
} else if (this.callbacks.onStreamChunk) {
// Fallback to regular stream processing
console.log(
'[SessionUpdateHandler] 🧠 Falling back to onStreamChunk',
);
this.callbacks.onStreamChunk(update.content.text);
this.callbacks.onStreamChunk(text);
}
}
this.emitUsageMeta(update._meta);
this.emitUsageMeta(
(update as { _meta?: SessionUpdateMeta | null })._meta,
);
break;
}
case 'tool_call': {
// Handle new tool call
@ -159,8 +175,9 @@ export class QwenSessionUpdateHandler {
case 'current_mode_update': {
// Notify UI about mode change
try {
const modeId = (update as unknown as { modeId?: ApprovalModeValue })
.modeId;
const modeId = (
update as unknown as { currentModeId?: ApprovalModeValue }
).currentModeId;
if (modeId && this.callbacks.onModeChanged) {
this.callbacks.onModeChanged(modeId);
}
@ -173,22 +190,6 @@ export class QwenSessionUpdateHandler {
break;
}
case 'current_model_update': {
// Notify UI about model change
try {
const model = (update as unknown as { model?: ModelInfo }).model;
if (model && this.callbacks.onModelChanged) {
this.callbacks.onModelChanged(model);
}
} catch (err) {
console.warn(
'[SessionUpdateHandler] Failed to handle model update',
err,
);
}
break;
}
case 'available_commands_update': {
// Notify UI about available commands
try {
@ -213,13 +214,58 @@ export class QwenSessionUpdateHandler {
}
}
private emitUsageMeta(meta?: SessionUpdateMeta): void {
private getTextContent(content: unknown): string | undefined {
if (!content || typeof content !== 'object') {
return undefined;
}
const text = (content as { text?: unknown }).text;
return typeof text === 'string' ? text : undefined;
}
private emitUsageMeta(meta?: SessionUpdateMeta | null): void {
if (!meta || !this.callbacks.onUsageUpdate) {
return;
}
const raw = meta.usage as Record<string, unknown> | null | undefined;
const usage = raw
? {
// SDK field names
inputTokens:
(raw['inputTokens'] as number | null | undefined) ??
(raw['promptTokens'] as number | null | undefined),
outputTokens:
(raw['outputTokens'] as number | null | undefined) ??
(raw['completionTokens'] as number | null | undefined),
thoughtTokens:
(raw['thoughtTokens'] as number | null | undefined) ??
(raw['thoughtsTokens'] as number | null | undefined),
totalTokens: raw['totalTokens'] as number | null | undefined,
cachedReadTokens:
(raw['cachedReadTokens'] as number | null | undefined) ??
(raw['cachedTokens'] as number | null | undefined),
cachedWriteTokens: raw['cachedWriteTokens'] as
| number
| null
| undefined,
// Legacy compat
promptTokens:
(raw['promptTokens'] as number | null | undefined) ??
(raw['inputTokens'] as number | null | undefined),
completionTokens:
(raw['completionTokens'] as number | null | undefined) ??
(raw['outputTokens'] as number | null | undefined),
thoughtsTokens:
(raw['thoughtsTokens'] as number | null | undefined) ??
(raw['thoughtTokens'] as number | null | undefined),
cachedTokens:
(raw['cachedTokens'] as number | null | undefined) ??
(raw['cachedReadTokens'] as number | null | undefined),
}
: undefined;
const payload: UsageStatsPayload = {
usage: meta.usage || undefined,
usage,
durationMs: meta.durationMs ?? undefined,
};

View file

@ -3,177 +3,33 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Usage } from '@agentclientprotocol/sdk';
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export const JSONRPC_VERSION = '2.0' as const;
// ---------------------------------------------------------------------------
// Private / Qwen-specific types (not part of ACP spec)
// ---------------------------------------------------------------------------
export const authMethod = 'qwen-oauth';
export interface AcpRequest {
jsonrpc: typeof JSONRPC_VERSION;
id: number;
method: string;
params?: unknown;
}
export interface AcpResponse {
jsonrpc: typeof JSONRPC_VERSION;
id: number;
result?: unknown;
capabilities?: {
[key: string]: unknown;
/**
* Authenticate update notification (Qwen extension, not ACP spec).
* Sent by agent during the OAuth flow.
*/
export interface AuthenticateUpdateNotification {
_meta: {
authUri: string;
};
error?: {
code: number;
message: string;
data?: unknown;
};
}
export interface AcpNotification {
jsonrpc: typeof JSONRPC_VERSION;
method: string;
params?: unknown;
}
export interface BaseSessionUpdate {
sessionId: string;
}
// Content block type (simplified version, use schema.ContentBlock for validation)
export interface ContentBlock {
type: 'text' | 'image';
text?: string;
data?: string;
mimeType?: string;
uri?: string;
}
export interface UsageMetadata {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
}
export interface SessionUpdateMeta {
usage?: UsageMetadata | null;
usage?: Usage | null;
durationMs?: number | null;
timestamp?: number | null;
}
export type AcpMeta = Record<string, unknown>;
export type ModelId = string;
export interface ModelInfo {
_meta?: AcpMeta | null;
description?: string | null;
modelId: ModelId;
name: string;
}
export interface SessionModelState {
_meta?: AcpMeta | null;
availableModels: ModelInfo[];
currentModelId: ModelId;
}
export interface UserMessageChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'user_message_chunk';
content: ContentBlock;
_meta?: SessionUpdateMeta;
};
}
export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'agent_message_chunk';
content: ContentBlock;
_meta?: SessionUpdateMeta;
};
}
export interface AgentThoughtChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'agent_thought_chunk';
content: ContentBlock;
_meta?: SessionUpdateMeta;
};
}
export interface ToolCallUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'tool_call';
toolCallId: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
title: string;
kind:
| 'read'
| 'edit'
| 'execute'
| 'delete'
| 'move'
| 'search'
| 'fetch'
| 'think'
| 'other';
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
content?: {
type: 'text';
text: string;
};
path?: string;
oldText?: string | null;
newText?: string;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
_meta?: SessionUpdateMeta;
};
}
export interface ToolCallStatusUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'tool_call_update';
toolCallId: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
title?: string;
kind?: string;
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
content?: {
type: 'text';
text: string;
};
path?: string;
oldText?: string | null;
newText?: string;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
_meta?: SessionUpdateMeta;
};
}
export interface PlanUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'plan';
entries: Array<{
content: string;
priority: 'high' | 'medium' | 'low';
status: 'pending' | 'in_progress' | 'completed';
}>;
};
}
export {
ApprovalMode,
APPROVAL_MODE_MAP,
@ -181,91 +37,11 @@ export {
getApprovalModeInfoFromString,
} from './approvalModeTypes.js';
// Cyclic next-mode mapping used by UI toggles and other consumers
export const NEXT_APPROVAL_MODE: {
[k in ApprovalModeValue]: ApprovalModeValue;
} = {
// Hide "plan" from the public toggle sequence for now
// Cycle: default -> auto-edit -> yolo -> default
default: 'auto-edit',
'auto-edit': 'yolo',
plan: 'yolo',
yolo: 'default',
};
// Current mode update (sent by agent when mode changes)
export interface CurrentModeUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'current_mode_update';
modeId: ApprovalModeValue;
};
}
// Current model update (sent by agent when model changes)
export interface CurrentModelUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'current_model_update';
model: ModelInfo;
};
}
// Available command definition
export interface AvailableCommand {
name: string;
description: string;
input?: {
hint?: string;
} | null;
}
// Available commands update (sent by agent after session creation)
export interface AvailableCommandsUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'available_commands_update';
availableCommands: AvailableCommand[];
};
}
// Authenticate update (sent by agent during authentication process)
export interface AuthenticateUpdateNotification {
_meta: {
authUri: string;
};
}
export type AcpSessionUpdate =
| UserMessageChunkUpdate
| AgentMessageChunkUpdate
| AgentThoughtChunkUpdate
| ToolCallUpdate
| ToolCallStatusUpdate
| PlanUpdate
| CurrentModeUpdate
| CurrentModelUpdate
| AvailableCommandsUpdate;
// Permission request (simplified version, use schema.RequestPermissionRequest for validation)
export interface AcpPermissionRequest {
sessionId: string;
options: Array<{
optionId: string;
name: string;
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
}>;
toolCall: {
toolCallId: string;
rawInput?: {
command?: string;
description?: string;
[key: string]: unknown;
};
title?: string;
kind?: string;
};
}
export type AcpMessage =
| AcpRequest
| AcpNotification
| AcpResponse
| AcpSessionUpdate;

View file

@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
AcpPermissionRequest,
ModelInfo,
AvailableCommand,
} from './acpTypes.js';
RequestPermissionRequest,
} from '@agentclientprotocol/sdk';
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
export interface ChatMessage {
role: 'user' | 'assistant';
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
}
@ -35,10 +35,17 @@ export interface ToolCallUpdateData {
export interface UsageStatsPayload {
usage?: {
// SDK field names (primary)
inputTokens?: number | null;
outputTokens?: number | null;
thoughtTokens?: number | null;
totalTokens?: number | null;
cachedReadTokens?: number | null;
cachedWriteTokens?: number | null;
// Legacy field names (compat with older CLI builds)
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
} | null;
durationMs?: number | null;
@ -51,7 +58,7 @@ export interface QwenAgentCallbacks {
onThoughtChunk?: (chunk: string) => void;
onToolCall?: (update: ToolCallUpdateData) => void;
onPlan?: (entries: PlanEntry[]) => void;
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
onPermissionRequest?: (request: RequestPermissionRequest) => Promise<string>;
onEndTurn?: (reason?: string) => void;
onModeInfo?: (info: {
currentModeId?: ApprovalModeValue;

View file

@ -6,10 +6,10 @@
import type { ChildProcess } from 'child_process';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
AuthenticateUpdateNotification,
} from './acpTypes.js';
RequestPermissionRequest,
SessionNotification,
} from '@agentclientprotocol/sdk';
import type { AuthenticateUpdateNotification } from './acpTypes.js';
export interface PendingRequest<T = unknown> {
resolve: (value: T) => void;
@ -19,8 +19,8 @@ export interface PendingRequest<T = unknown> {
}
export interface AcpConnectionCallbacks {
onSessionUpdate: (data: AcpSessionUpdate) => void;
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
onSessionUpdate: (data: SessionNotification) => void;
onPermissionRequest: (data: RequestPermissionRequest) => Promise<{
optionId: string;
}>;
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;

View file

@ -4,7 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AcpMeta, ModelInfo } from '../types/acpTypes.js';
import type { ModelInfo } from '@agentclientprotocol/sdk';
type AcpMeta = Record<string, unknown>;
const asMeta = (value: unknown): AcpMeta | null | undefined => {
if (value === null) {

View file

@ -44,7 +44,7 @@ import { InputForm } from './components/layout/InputForm.js';
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js';
import type { ModelInfo, AvailableCommand } from '../types/acpTypes.js';
import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk';
import {
DEFAULT_TOKEN_LIMIT,
tokenLimit,

View file

@ -7,8 +7,10 @@
import * as vscode from 'vscode';
import { QwenAgentManager } from '../services/qwenAgentManager.js';
import { ConversationStore } from '../services/conversationStore.js';
import type { AcpPermissionRequest } from '../types/acpTypes.js';
import type { ModelInfo } from '../types/acpTypes.js';
import type {
RequestPermissionRequest,
ModelInfo,
} from '@agentclientprotocol/sdk';
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
import { PanelManager } from '../webview/PanelManager.js';
import { MessageHandler } from '../webview/MessageHandler.js';
@ -27,7 +29,7 @@ export class WebViewProvider {
// Track a pending permission request and its resolver so extension commands
// can "simulate" user choice from the command palette (e.g. after accepting
// a diff, auto-allow read/execute, or auto-reject on cancel).
private pendingPermissionRequest: AcpPermissionRequest | null = null;
private pendingPermissionRequest: RequestPermissionRequest | null = null;
private pendingPermissionResolve: ((optionId: string) => void) | null = null;
// Track current ACP mode id to influence permission/diff behavior
private currentModeId: ApprovalModeValue | null = null;
@ -137,7 +139,7 @@ export class WebViewProvider {
});
});
// Surface model changes (from ACP current_model_update or set_model response)
// Surface model changes (primarily from set_model response path)
this.agentManager.onModelChanged((model) => {
this.sendMessageToWebView({
type: 'modelChanged',
@ -218,7 +220,7 @@ export class WebViewProvider {
});
this.agentManager.onPermissionRequest(
async (request: AcpPermissionRequest) => {
async (request: RequestPermissionRequest) => {
// Auto-approve in auto/yolo mode (no UI, no diff)
if (this.isAutoMode()) {
const options = request.options || [];

View file

@ -15,7 +15,7 @@ import type {
} from '@qwen-code/webui';
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
import type { ModelInfo } from '../../../types/acpTypes.js';
import type { ModelInfo } from '@agentclientprotocol/sdk';
import { ModelSelector } from './ModelSelector.js';
/**

View file

@ -6,7 +6,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FC } from 'react';
import type { ModelInfo } from '../../../types/acpTypes.js';
import type { ModelInfo } from '@agentclientprotocol/sdk';
import { PlanCompletedIcon } from '@qwen-code/webui';
interface ModelSelectorProps {

View file

@ -27,7 +27,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
'newQwenSession',
'switchQwenSession',
'getQwenSessions',
'saveSession',
'resumeSession',
'cancelStreaming',
// UI action: open a new chat tab (new WebviewPanel)
@ -87,10 +86,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
);
break;
case 'saveSession':
await this.handleSaveSession((data?.tag as string) || '');
break;
case 'resumeSession':
await this.handleResumeSession((data?.sessionId as string) || '');
break;
@ -822,87 +817,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
/**
* Handle save session request
*/
private async handleSaveSession(tag: string): Promise<void> {
try {
if (!this.currentConversationId) {
throw new Error('No active conversation to save');
}
// Try ACP save first
try {
const response = await this.agentManager.saveSessionViaAcp(
this.currentConversationId,
tag,
);
this.sendToWebView({
type: 'saveSessionResponse',
data: response,
});
} catch (acpError) {
// Safely convert error to string
const errorMsg = acpError ? String(acpError) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to save sessions.',
);
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
data: { message: 'Session expired. Please login again.' },
});
return;
}
}
await this.handleGetQwenSessions();
} catch (error) {
console.error('[SessionMessageHandler] Failed to save session:', error);
// Safely convert error to string
const errorMsg = error ? String(error) : 'Unknown error';
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
) {
// Show a more user-friendly error message for expired sessions
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to save sessions.',
);
// Send a specific error to the webview for better UI handling
this.sendToWebView({
type: 'sessionExpired',
data: { message: 'Session expired. Please login again.' },
});
} else {
this.sendToWebView({
type: 'saveSessionResponse',
data: {
success: false,
message: `Failed to save session: ${error}`,
},
});
}
}
}
/**
* Handle cancel streaming request
*/

View file

@ -107,24 +107,17 @@ export const useMessageHandling = () => {
streamingMessageIndexRef.current = null;
}, []);
const breakThinkingSegment = useCallback(() => {
thinkingMessageIndexRef.current = null;
}, []);
/**
* End streaming response
*/
const endStreaming = useCallback(() => {
// Finalize streaming; content already lives in the placeholder message
setIsStreaming(false);
streamingMessageIndexRef.current = null;
// Remove the thinking message if it exists (collapse thoughts)
setMessages((prev) => {
const idx = thinkingMessageIndexRef.current;
thinkingMessageIndexRef.current = null;
if (idx === null || idx < 0 || idx >= prev.length) {
return prev;
}
const next = prev.slice();
next.splice(idx, 1);
return next;
});
thinkingMessageIndexRef.current = null;
}, []);
/**
@ -178,18 +171,10 @@ export const useMessageHandling = () => {
});
},
clearThinking: () => {
setMessages((prev) => {
const idx = thinkingMessageIndexRef.current;
thinkingMessageIndexRef.current = null;
if (idx === null || idx < 0 || idx >= prev.length) {
return prev;
}
const next = prev.slice();
next.splice(idx, 1);
return next;
});
thinkingMessageIndexRef.current = null;
},
breakAssistantSegment,
breakThinkingSegment,
setWaitingForResponse,
clearWaitingForResponse,
setMessages,

View file

@ -20,7 +20,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
useState<string>('Past Conversations');
const [showSessionSelector, setShowSessionSelector] = useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
const [nextCursor, setNextCursor] = useState<number | undefined>(undefined);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isLoading, setIsLoading] = useState<boolean>(false);
@ -97,38 +96,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
[currentSessionId, vscode],
);
/**
* Save session
*/
const handleSaveSession = useCallback(
(tag: string) => {
vscode.postMessage({
type: 'saveSession',
data: { tag },
});
},
[vscode],
);
/**
* Handle Save session response
*/
const handleSaveSessionResponse = useCallback(
(response: { success: boolean; message?: string }) => {
if (response.success) {
if (response.message) {
const tagMatch = response.message.match(/tag: (.+)$/);
if (tagMatch) {
setSavedSessionTags((prev) => [...prev, tagMatch[1]]);
}
}
} else {
console.error('Failed to save session:', response.message);
}
},
[],
);
return {
// State
qwenSessions,
@ -137,7 +104,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
showSessionSelector,
sessionSearchQuery,
filteredSessions,
savedSessionTags,
nextCursor,
hasMore,
isLoading,
@ -148,7 +114,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
setCurrentSessionTitle,
setShowSessionSelector,
setSessionSearchQuery,
setSavedSessionTags,
setNextCursor,
setHasMore,
setIsLoading,
@ -157,8 +122,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
handleLoadQwenSessions,
handleNewQwenSession,
handleSwitchSession,
handleSaveSession,
handleSaveSessionResponse,
handleLoadMoreSessions,
};
};

View file

@ -14,7 +14,7 @@ import type {
} from '../../types/chatTypes.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import type { PlanEntry } from '../../types/chatTypes.js';
import type { ModelInfo, AvailableCommand } from '../../types/acpTypes.js';
import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk';
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
'user_cancelled',
@ -41,10 +41,6 @@ interface UseWebViewMessagesProps {
setNextCursor: (cursor: number | undefined) => void;
setHasMore: (hasMore: boolean) => void;
setIsLoading: (loading: boolean) => void;
handleSaveSessionResponse: (response: {
success: boolean;
message?: string;
}) => void;
};
// File context
@ -91,6 +87,7 @@ interface UseWebViewMessagesProps {
appendStreamChunk: (chunk: string) => void;
endStreaming: () => void;
breakAssistantSegment: () => void;
breakThinkingSegment: () => void;
appendThinkingChunk: (chunk: string) => void;
clearThinking: () => void;
setWaitingForResponse: (message: string) => void;
@ -612,6 +609,7 @@ export const useWebViewMessages = ({
// Split assistant stream so subsequent chunks start a new assistant message
handlers.messageHandling.breakAssistantSegment();
handlers.messageHandling.breakThinkingSegment();
}
break;
}
@ -686,6 +684,7 @@ export const useWebViewMessages = ({
// Split assistant message segments, keep rendering blocks independent
handlers.messageHandling.breakAssistantSegment?.();
handlers.messageHandling.breakThinkingSegment?.();
} catch (_error) {
console.warn(
'[useWebViewMessages] failed to push/merge plan snapshot toolcall:',
@ -711,6 +710,7 @@ export const useWebViewMessages = ({
(status === 'completed' || status === 'failed');
if (isStart || isFinalUpdate) {
handlers.messageHandling.breakAssistantSegment();
handlers.messageHandling.breakThinkingSegment();
}
// While long-running tools (e.g., execute/bash/command) are in progress,
@ -935,11 +935,6 @@ export const useWebViewMessages = ({
break;
}
case 'saveSessionResponse': {
handlers.sessionManagement.handleSaveSessionResponse(message.data);
break;
}
case 'cancelStreaming':
// Handle cancel streaming response from extension
// Note: The "Interrupted" message is already added by handleCancel in App.tsx