mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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:
parent
180dcd8b36
commit
c044d4dba1
28 changed files with 959 additions and 1959 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -14293,7 +14293,6 @@
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -22904,6 +22903,7 @@
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"license": "LICENSE",
|
"license": "LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.14.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"@qwen-code/webui": "*",
|
"@qwen-code/webui": "*",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,16 @@ class QwenAgent implements Agent {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.createAndStoreSession(config, sessionData.conversation);
|
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(
|
async unstable_listSessions(
|
||||||
|
|
@ -323,6 +332,13 @@ class QwenAgent implements Agent {
|
||||||
await session.cancelPendingPrompt();
|
await session.cancelPendingPrompt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async extMethod(
|
||||||
|
method: string,
|
||||||
|
_params: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
throw RequestError.methodNotFound(method);
|
||||||
|
}
|
||||||
|
|
||||||
// --- private helpers ---
|
// --- private helpers ---
|
||||||
|
|
||||||
private async newSessionConfig(
|
private async newSessionConfig(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,202 @@
|
||||||
This file contains third-party software notices and license terms.
|
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
|
@qwen-code/webui@undefined
|
||||||
(No repository found)
|
(No repository found)
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.14.1",
|
||||||
"@qwen-code/webui": "*",
|
"@qwen-code/webui": "*",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
|
||||||
|
|
@ -4,41 +4,30 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const AGENT_METHODS = {
|
export {
|
||||||
authenticate: 'authenticate',
|
AGENT_METHODS,
|
||||||
initialize: 'initialize',
|
CLIENT_METHODS,
|
||||||
session_cancel: 'session/cancel',
|
PROTOCOL_VERSION,
|
||||||
session_list: 'session/list',
|
} from '@agentclientprotocol/sdk';
|
||||||
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 const CLIENT_METHODS = {
|
export { RequestError } from '@agentclientprotocol/sdk';
|
||||||
fs_read_text_file: 'fs/read_text_file',
|
|
||||||
fs_write_text_file: 'fs/write_text_file',
|
// 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',
|
authenticate_update: 'authenticate/update',
|
||||||
session_request_permission: 'session/request_permission',
|
|
||||||
session_update: 'session/update',
|
|
||||||
} as const;
|
} 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 = {
|
export const ACP_ERROR_CODES = {
|
||||||
// Parse error: invalid JSON received by server.
|
|
||||||
PARSE_ERROR: -32700,
|
PARSE_ERROR: -32700,
|
||||||
// Invalid request: JSON is not a valid Request object.
|
|
||||||
INVALID_REQUEST: -32600,
|
INVALID_REQUEST: -32600,
|
||||||
// Method not found: method does not exist or is unavailable.
|
|
||||||
METHOD_NOT_FOUND: -32601,
|
METHOD_NOT_FOUND: -32601,
|
||||||
// Invalid params: invalid method parameter(s).
|
|
||||||
INVALID_PARAMS: -32602,
|
INVALID_PARAMS: -32602,
|
||||||
// Internal error: implementation-defined server error.
|
|
||||||
INTERNAL_ERROR: -32603,
|
INTERNAL_ERROR: -32603,
|
||||||
// Authentication required: must authenticate before operation.
|
REQUEST_CANCELLED: -32800,
|
||||||
AUTH_REQUIRED: -32000,
|
AUTH_REQUIRED: -32000,
|
||||||
// Resource not found: e.g. missing file.
|
|
||||||
RESOURCE_NOT_FOUND: -32002,
|
RESOURCE_NOT_FOUND: -32002,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,64 +4,59 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { JSONRPC_VERSION } from '../types/acpTypes.js';
|
import {
|
||||||
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
|
ClientSideConnection,
|
||||||
|
ndJsonStream,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
} from '@agentclientprotocol/sdk';
|
||||||
import type {
|
import type {
|
||||||
AcpMessage,
|
Client,
|
||||||
AcpPermissionRequest,
|
Agent,
|
||||||
AcpResponse,
|
SessionNotification,
|
||||||
AcpSessionUpdate,
|
RequestPermissionRequest,
|
||||||
AuthenticateUpdateNotification,
|
RequestPermissionResponse,
|
||||||
} from '../types/acpTypes.js';
|
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 { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
import type { ChildProcess, SpawnOptions } from 'child_process';
|
import type { ChildProcess, SpawnOptions } from 'child_process';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import type {
|
import { Readable, Writable } from 'node:stream';
|
||||||
PendingRequest,
|
|
||||||
AcpConnectionCallbacks,
|
|
||||||
} from '../types/connectionTypes.js';
|
|
||||||
import { AcpMessageHandler } from './acpMessageHandler.js';
|
|
||||||
import { AcpSessionManager } from './acpSessionManager.js';
|
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
|
import { AcpFileHandler } from './acpFileHandler.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ACP Connection Handler for VSCode Extension
|
* 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 {
|
export class AcpConnection {
|
||||||
private child: ChildProcess | null = null;
|
private child: ChildProcess | null = null;
|
||||||
private pendingRequests = new Map<number, PendingRequest<unknown>>();
|
private sdkConnection: ClientSideConnection | null = null;
|
||||||
private nextRequestId = { value: 0 };
|
private sessionId: string | null = null;
|
||||||
// Remember the working dir provided at connect() so later ACP calls
|
|
||||||
// that require cwd (e.g. session/list) can include it.
|
|
||||||
private workingDir: string = process.cwd();
|
private workingDir: string = process.cwd();
|
||||||
|
private fileHandler = new AcpFileHandler();
|
||||||
|
|
||||||
private messageHandler: AcpMessageHandler;
|
onSessionUpdate: (data: SessionNotification) => void = () => {};
|
||||||
private sessionManager: AcpSessionManager;
|
onPermissionRequest: (data: RequestPermissionRequest) => Promise<{
|
||||||
|
|
||||||
onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
|
|
||||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
|
||||||
optionId: string;
|
optionId: string;
|
||||||
}> = () => Promise.resolve({ optionId: 'allow' });
|
}> = () => Promise.resolve({ optionId: 'allow_once' });
|
||||||
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void =
|
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void =
|
||||||
() => {};
|
() => {};
|
||||||
onEndTurn: () => void = () => {};
|
onEndTurn: (reason?: string) => void = () => {};
|
||||||
// Called after successful initialize() with the initialize result
|
|
||||||
onInitialized: (init: unknown) => 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(
|
async connect(
|
||||||
cliEntryPath: string,
|
cliEntryPath: string,
|
||||||
workingDir: string = process.cwd(),
|
workingDir: string = process.cwd(),
|
||||||
|
|
@ -75,8 +70,6 @@ export class AcpConnection {
|
||||||
|
|
||||||
const env = { ...process.env };
|
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(
|
const proxyArg = extraArgs.find(
|
||||||
(arg, i) => arg === '--proxy' && i + 1 < extraArgs.length,
|
(arg, i) => arg === '--proxy' && i + 1 < extraArgs.length,
|
||||||
);
|
);
|
||||||
|
|
@ -84,15 +77,12 @@ export class AcpConnection {
|
||||||
const proxyIndex = extraArgs.indexOf('--proxy');
|
const proxyIndex = extraArgs.indexOf('--proxy');
|
||||||
const proxyUrl = extraArgs[proxyIndex + 1];
|
const proxyUrl = extraArgs[proxyIndex + 1];
|
||||||
console.log('[ACP] Setting proxy environment variables:', proxyUrl);
|
console.log('[ACP] Setting proxy environment variables:', proxyUrl);
|
||||||
|
|
||||||
env['HTTP_PROXY'] = proxyUrl;
|
env['HTTP_PROXY'] = proxyUrl;
|
||||||
env['HTTPS_PROXY'] = proxyUrl;
|
env['HTTPS_PROXY'] = proxyUrl;
|
||||||
env['http_proxy'] = proxyUrl;
|
env['http_proxy'] = proxyUrl;
|
||||||
env['https_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 spawnCommand: string = process.execPath;
|
||||||
const spawnArgs: string[] = [
|
const spawnArgs: string[] = [
|
||||||
cliEntryPath,
|
cliEntryPath,
|
||||||
|
|
@ -113,7 +103,6 @@ export class AcpConnection {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env,
|
env,
|
||||||
// We spawn node directly; no shell needed (and shell quoting can break paths).
|
|
||||||
shell: false,
|
shell: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -121,13 +110,10 @@ export class AcpConnection {
|
||||||
await this.setupChildProcessHandlers();
|
await this.setupChildProcessHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up child process handlers
|
|
||||||
*/
|
|
||||||
private async setupChildProcessHandlers(): Promise<void> {
|
private async setupChildProcessHandlers(): Promise<void> {
|
||||||
let spawnError: Error | null = null;
|
let spawnError: Error | null = null;
|
||||||
|
|
||||||
this.child!.stderr?.on('data', (data) => {
|
this.child!.stderr?.on('data', (data: Buffer) => {
|
||||||
const message = data.toString();
|
const message = data.toString();
|
||||||
if (
|
if (
|
||||||
message.toLowerCase().includes('error') &&
|
message.toLowerCase().includes('error') &&
|
||||||
|
|
@ -139,19 +125,16 @@ export class AcpConnection {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.child!.on('error', (error) => {
|
this.child!.on('error', (error: Error) => {
|
||||||
spawnError = error;
|
spawnError = error;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.child!.on('exit', (code, signal) => {
|
this.child!.on('exit', (code: number | null, signal: string | null) => {
|
||||||
console.error(
|
console.error(
|
||||||
`[ACP qwen] Process exited with code: ${code}, signal: ${signal}`,
|
`[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));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
if (spawnError) {
|
if (spawnError) {
|
||||||
|
|
@ -162,291 +145,292 @@ export class AcpConnection {
|
||||||
throw new Error(`Qwen ACP process failed to start`);
|
throw new Error(`Qwen ACP process failed to start`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle messages from ACP server
|
// Convert Node.js child process streams to Web Streams for SDK
|
||||||
let buffer = '';
|
const stdout = Readable.toWeb(
|
||||||
this.child.stdout?.on('data', (data) => {
|
this.child.stdout!,
|
||||||
buffer += data.toString();
|
) as ReadableStream<Uint8Array>;
|
||||||
const lines = buffer.split('\n');
|
const stdin = Writable.toWeb(this.child.stdin!) as WritableStream;
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
const stream = ndJsonStream(stdin, stdout);
|
||||||
if (line.trim()) {
|
|
||||||
|
// 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 {
|
try {
|
||||||
const message = JSON.parse(line) as AcpMessage;
|
const response = await self.onPermissionRequest(permissionData);
|
||||||
console.log(
|
const optionId = response?.optionId;
|
||||||
'[ACP] <<< Received message:',
|
console.log('[ACP] Permission request:', optionId);
|
||||||
JSON.stringify(message).substring(0, 500 * 3),
|
let outcome: 'selected' | 'cancelled';
|
||||||
);
|
if (
|
||||||
this.handleMessage(message);
|
optionId &&
|
||||||
} catch (_error) {
|
(optionId.includes('reject') || optionId === 'cancel')
|
||||||
// Ignore non-JSON lines
|
) {
|
||||||
console.log(
|
outcome = 'cancelled';
|
||||||
'[ACP] <<< Non-JSON line (ignored):',
|
} else {
|
||||||
line.substring(0, 200),
|
outcome = 'selected';
|
||||||
);
|
}
|
||||||
}
|
console.log('[ACP] Permission outcome:', outcome);
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize protocol
|
if (outcome === 'cancelled') {
|
||||||
const res = await this.sessionManager.initialize(
|
return { outcome: { outcome: 'cancelled' } };
|
||||||
this.child,
|
}
|
||||||
this.pendingRequests,
|
return {
|
||||||
this.nextRequestId,
|
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 {
|
try {
|
||||||
this.onInitialized(res);
|
this.onInitialized(initResponse);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ACP] onInitialized callback error:', err);
|
console.warn('[ACP] onInitialized callback error:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private ensureConnection(): ClientSideConnection {
|
||||||
* Handle received messages
|
if (!this.sdkConnection) {
|
||||||
*
|
throw new Error('Not connected to ACP agent');
|
||||||
* @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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return this.sdkConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async authenticate(methodId?: string): Promise<AuthenticateResponse> {
|
||||||
* Authenticate
|
const conn = this.ensureConnection();
|
||||||
*
|
const authMethodId = methodId || 'default';
|
||||||
* @param methodId - Authentication method ID
|
console.log(
|
||||||
* @returns Authentication response
|
'[ACP] Sending authenticate request with methodId:',
|
||||||
*/
|
authMethodId,
|
||||||
async authenticate(methodId?: string): Promise<AcpResponse> {
|
|
||||||
return this.sessionManager.authenticate(
|
|
||||||
methodId,
|
|
||||||
this.child,
|
|
||||||
this.pendingRequests,
|
|
||||||
this.nextRequestId,
|
|
||||||
);
|
);
|
||||||
|
const response = await conn.authenticate({ methodId: authMethodId });
|
||||||
|
console.log('[ACP] Authenticate successful', response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async newSession(cwd: string = process.cwd()): Promise<NewSessionResponse> {
|
||||||
* Create new session
|
const conn = this.ensureConnection();
|
||||||
*
|
console.log('[ACP] Sending session/new request with cwd:', cwd);
|
||||||
* @param cwd - Working directory
|
const response: NewSessionResponse = await conn.newSession({
|
||||||
* @returns New session response
|
|
||||||
*/
|
|
||||||
async newSession(cwd: string = process.cwd()): Promise<AcpResponse> {
|
|
||||||
return this.sessionManager.newSession(
|
|
||||||
cwd,
|
cwd,
|
||||||
this.child,
|
mcpServers: [],
|
||||||
this.pendingRequests,
|
});
|
||||||
this.nextRequestId,
|
this.sessionId = response.sessionId || null;
|
||||||
);
|
console.log('[ACP] Session created with ID:', this.sessionId);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async sendPrompt(prompt: string): Promise<PromptResponse> {
|
||||||
* Send prompt message
|
const conn = this.ensureConnection();
|
||||||
*
|
if (!this.sessionId) {
|
||||||
* @param prompt - Prompt content
|
throw new Error('No active ACP session');
|
||||||
* @returns Response
|
}
|
||||||
*/
|
const response: PromptResponse = await conn.prompt({
|
||||||
async sendPrompt(prompt: string): Promise<AcpResponse> {
|
sessionId: this.sessionId,
|
||||||
return this.sessionManager.sendPrompt(
|
prompt: [{ type: 'text', text: prompt }],
|
||||||
prompt,
|
});
|
||||||
this.child,
|
// Emit end-of-turn from stopReason
|
||||||
this.pendingRequests,
|
if (response.stopReason) {
|
||||||
this.nextRequestId,
|
this.onEndTurn(response.stopReason);
|
||||||
);
|
} else {
|
||||||
|
this.onEndTurn();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load existing session
|
|
||||||
*
|
|
||||||
* @param sessionId - Session ID
|
|
||||||
* @returns Load response
|
|
||||||
*/
|
|
||||||
async loadSession(
|
async loadSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
cwdOverride?: string,
|
cwdOverride?: string,
|
||||||
): Promise<AcpResponse> {
|
): Promise<LoadSessionResponse> {
|
||||||
return this.sessionManager.loadSession(
|
const conn = this.ensureConnection();
|
||||||
sessionId,
|
console.log('[ACP] Sending session/load request for session:', sessionId);
|
||||||
this.child,
|
const cwd = cwdOverride || this.workingDir;
|
||||||
this.pendingRequests,
|
try {
|
||||||
this.nextRequestId,
|
const response = await conn.loadSession({
|
||||||
cwdOverride || this.workingDir,
|
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?: {
|
async listSessions(options?: {
|
||||||
cursor?: number;
|
cursor?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
}): Promise<AcpResponse> {
|
}): Promise<ListSessionsResponse> {
|
||||||
return this.sessionManager.listSessions(
|
const conn = this.ensureConnection();
|
||||||
this.child,
|
console.log('[ACP] Requesting session list...');
|
||||||
this.pendingRequests,
|
try {
|
||||||
this.nextRequestId,
|
const params: Record<string, unknown> = { cwd: this.workingDir };
|
||||||
this.workingDir,
|
if (options?.cursor !== undefined) {
|
||||||
options,
|
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> {
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async setMode(modeId: ApprovalModeValue): Promise<SetSessionModeResponse> {
|
||||||
* Save current session
|
const conn = this.ensureConnection();
|
||||||
*
|
if (!this.sessionId) {
|
||||||
* @param tag - Save tag
|
throw new Error('No active ACP session');
|
||||||
* @returns Save response
|
}
|
||||||
*/
|
console.log('[ACP] Sending session/set_mode:', modeId);
|
||||||
async saveSession(tag: string): Promise<AcpResponse> {
|
const res = await conn.setSessionMode({
|
||||||
return this.sessionManager.saveSession(
|
sessionId: this.sessionId,
|
||||||
tag,
|
|
||||||
this.child,
|
|
||||||
this.pendingRequests,
|
|
||||||
this.nextRequestId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set approval mode
|
|
||||||
*/
|
|
||||||
async setMode(modeId: ApprovalModeValue): Promise<AcpResponse> {
|
|
||||||
return this.sessionManager.setMode(
|
|
||||||
modeId,
|
modeId,
|
||||||
this.child,
|
});
|
||||||
this.pendingRequests,
|
console.log('[ACP] set_mode response:', res);
|
||||||
this.nextRequestId,
|
return res;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async setModel(modelId: string): Promise<SetSessionModelResponse> {
|
||||||
* Set model for current session
|
const conn = this.ensureConnection();
|
||||||
*
|
if (!this.sessionId) {
|
||||||
* @param modelId - Model ID
|
throw new Error('No active ACP session');
|
||||||
* @returns Set model response
|
}
|
||||||
*/
|
console.log('[ACP] Sending session/set_model:', modelId);
|
||||||
async setModel(modelId: string): Promise<AcpResponse> {
|
const res = await conn.unstable_setSessionModel({
|
||||||
return this.sessionManager.setModel(
|
sessionId: this.sessionId,
|
||||||
modelId,
|
modelId,
|
||||||
this.child,
|
});
|
||||||
this.pendingRequests,
|
console.log('[ACP] set_model response:', res);
|
||||||
this.nextRequestId,
|
return res;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect
|
|
||||||
*/
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
this.child.kill();
|
this.child.kill();
|
||||||
this.child = null;
|
this.child = null;
|
||||||
}
|
}
|
||||||
|
this.sdkConnection = null;
|
||||||
this.pendingRequests.clear();
|
this.sessionId = null;
|
||||||
this.sessionManager.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if connected
|
|
||||||
*/
|
|
||||||
get isConnected(): boolean {
|
get isConnected(): boolean {
|
||||||
return this.child !== null && !this.child.killed;
|
return this.child !== null && !this.child.killed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there is an active session
|
|
||||||
*/
|
|
||||||
get hasActiveSession(): boolean {
|
get hasActiveSession(): boolean {
|
||||||
return this.sessionManager.getCurrentSessionId() !== null;
|
return this.sessionId !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current session ID
|
|
||||||
*/
|
|
||||||
get currentSessionId(): string | null {
|
get currentSessionId(): string | null {
|
||||||
return this.sessionManager.getCurrentSessionId();
|
return this.sessionId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -48,10 +48,11 @@ export class AcpFileHandler {
|
||||||
`[ACP] Successfully read file: ${params.path} (${content.length} bytes)`,
|
`[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) {
|
if (params.line !== null || params.limit !== null) {
|
||||||
const lines = content.split('\n');
|
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 endLine = params.limit ? startLine + params.limit : lines.length;
|
||||||
const selectedLines = lines.slice(startLine, endLine);
|
const selectedLines = lines.slice(startLine, endLine);
|
||||||
const result = { content: selectedLines.join('\n') };
|
const result = { content: selectedLines.join('\n') };
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
*/
|
*/
|
||||||
import { AcpConnection } from './acpConnection.js';
|
import { AcpConnection } from './acpConnection.js';
|
||||||
import type {
|
import type {
|
||||||
AcpSessionUpdate,
|
|
||||||
AcpPermissionRequest,
|
|
||||||
AuthenticateUpdateNotification,
|
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
AvailableCommand,
|
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 type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
|
import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js';
|
||||||
import { QwenSessionManager } from './qwenSessionManager.js';
|
import { QwenSessionManager } from './qwenSessionManager.js';
|
||||||
|
|
@ -74,9 +74,13 @@ export class QwenAgentManager {
|
||||||
this.sessionUpdateHandler = new QwenSessionUpdateHandler({});
|
this.sessionUpdateHandler = new QwenSessionUpdateHandler({});
|
||||||
|
|
||||||
// Set ACP connection callbacks
|
// 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
|
// 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 {
|
try {
|
||||||
const targetId = this.rehydratingSessionId;
|
const targetId = this.rehydratingSessionId;
|
||||||
if (
|
if (
|
||||||
|
|
@ -91,19 +95,18 @@ export class QwenAgentManager {
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: string;
|
sessionUpdate: string;
|
||||||
content?: { text?: string };
|
content?: { text?: string };
|
||||||
_meta?: { timestamp?: number };
|
_meta?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).update;
|
).update;
|
||||||
const text = update?.content?.text || '';
|
const text = update?.content?.text || '';
|
||||||
|
const metaObj = update?._meta ?? {};
|
||||||
const timestamp =
|
const timestamp =
|
||||||
typeof update?._meta?.timestamp === 'number'
|
typeof metaObj['timestamp'] === 'number'
|
||||||
? update._meta.timestamp
|
? (metaObj['timestamp'] as number)
|
||||||
: Date.now();
|
: Date.now();
|
||||||
|
|
||||||
if (update?.sessionUpdate === 'user_message_chunk' && text) {
|
if (update?.sessionUpdate === 'user_message_chunk' && text) {
|
||||||
console.log(
|
|
||||||
'[QwenAgentManager] Rehydration: routing user message chunk',
|
|
||||||
);
|
|
||||||
this.callbacks.onMessage?.({
|
this.callbacks.onMessage?.({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: text,
|
content: text,
|
||||||
|
|
@ -111,10 +114,8 @@ export class QwenAgentManager {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update?.sessionUpdate === 'agent_message_chunk' && text) {
|
if (update?.sessionUpdate === 'agent_message_chunk' && text) {
|
||||||
console.log(
|
|
||||||
'[QwenAgentManager] Rehydration: routing agent message chunk',
|
|
||||||
);
|
|
||||||
this.callbacks.onMessage?.({
|
this.callbacks.onMessage?.({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: text,
|
content: text,
|
||||||
|
|
@ -122,10 +123,44 @@ export class QwenAgentManager {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// For other types during rehydration, fall through to normal handler
|
|
||||||
console.log(
|
if (update?.sessionUpdate === 'agent_thought_chunk' && text) {
|
||||||
'[QwenAgentManager] Rehydration: non-text update, forwarding to handler',
|
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) {
|
} catch (err) {
|
||||||
console.warn('[QwenAgentManager] Rehydration routing failed:', err);
|
console.warn('[QwenAgentManager] Rehydration routing failed:', err);
|
||||||
|
|
@ -136,7 +171,7 @@ export class QwenAgentManager {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.connection.onPermissionRequest = async (
|
this.connection.onPermissionRequest = async (
|
||||||
data: AcpPermissionRequest,
|
data: RequestPermissionRequest,
|
||||||
) => {
|
) => {
|
||||||
if (this.callbacks.onPermissionRequest) {
|
if (this.callbacks.onPermissionRequest) {
|
||||||
const optionId = await this.callbacks.onPermissionRequest(data);
|
const optionId = await this.callbacks.onPermissionRequest(data);
|
||||||
|
|
@ -249,16 +284,9 @@ export class QwenAgentManager {
|
||||||
): Promise<ApprovalModeValue> {
|
): Promise<ApprovalModeValue> {
|
||||||
const modeId = mode;
|
const modeId = mode;
|
||||||
try {
|
try {
|
||||||
const res = await this.connection.setMode(modeId);
|
await this.connection.setMode(modeId);
|
||||||
// Optimistically notify UI using response
|
// set_mode response has no mode payload; use requested value.
|
||||||
const result = (res?.result || {}) as { modeId?: string };
|
const confirmed = modeId;
|
||||||
const confirmed =
|
|
||||||
(result.modeId as
|
|
||||||
| 'plan'
|
|
||||||
| 'default'
|
|
||||||
| 'auto-edit'
|
|
||||||
| 'yolo'
|
|
||||||
| undefined) || modeId;
|
|
||||||
this.callbacks.onModeChanged?.(confirmed);
|
this.callbacks.onModeChanged?.(confirmed);
|
||||||
return confirmed;
|
return confirmed;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -272,10 +300,8 @@ export class QwenAgentManager {
|
||||||
*/
|
*/
|
||||||
async setModelFromUi(modelId: string): Promise<ModelInfo | null> {
|
async setModelFromUi(modelId: string): Promise<ModelInfo | null> {
|
||||||
try {
|
try {
|
||||||
const res = await this.connection.setModel(modelId);
|
await this.connection.setModel(modelId);
|
||||||
// Parse response and notify UI
|
const confirmedModelId = modelId;
|
||||||
const result = (res?.result || {}) as { modelId?: string };
|
|
||||||
const confirmedModelId = result.modelId || modelId;
|
|
||||||
const modelInfo: ModelInfo = {
|
const modelInfo: ModelInfo = {
|
||||||
modelId: confirmedModelId,
|
modelId: confirmedModelId,
|
||||||
name: confirmedModelId,
|
name: confirmedModelId,
|
||||||
|
|
@ -338,19 +364,13 @@ export class QwenAgentManager {
|
||||||
const response = await this.connection.listSessions();
|
const response = await this.connection.listSessions();
|
||||||
console.log('[QwenAgentManager] ACP session list response:', response);
|
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;
|
const res: unknown = response;
|
||||||
let items: Array<Record<string, unknown>> = [];
|
let items: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
|
if (res && typeof res === 'object' && 'sessions' in res) {
|
||||||
// "result" directly (not the full AcpResponse). Treat it as unknown
|
const sessionsValue = (res as { sessions?: unknown }).sessions;
|
||||||
// and carefully narrow before accessing `items` to satisfy strict TS.
|
items = Array.isArray(sessionsValue)
|
||||||
if (res && typeof res === 'object' && 'items' in res) {
|
? (sessionsValue as Array<Record<string, unknown>>)
|
||||||
const itemsValue = (res as { items?: unknown }).items;
|
|
||||||
items = Array.isArray(itemsValue)
|
|
||||||
? (itemsValue as Array<Record<string, unknown>>)
|
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,7 +386,7 @@ export class QwenAgentManager {
|
||||||
title: item.title || item.name || item.prompt || 'Untitled Session',
|
title: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
name: item.title || item.name || item.prompt || 'Untitled Session',
|
name: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
startTime: item.startTime,
|
startTime: item.startTime,
|
||||||
lastUpdated: item.mtime || item.lastUpdated,
|
lastUpdated: item.updatedAt || item.mtime || item.lastUpdated,
|
||||||
messageCount: item.messageCount || 0,
|
messageCount: item.messageCount || 0,
|
||||||
projectHash: item.projectHash,
|
projectHash: item.projectHash,
|
||||||
filePath: item.filePath,
|
filePath: item.filePath,
|
||||||
|
|
@ -445,17 +465,14 @@ export class QwenAgentManager {
|
||||||
size,
|
size,
|
||||||
...(cursor !== undefined ? { cursor } : {}),
|
...(cursor !== undefined ? { cursor } : {}),
|
||||||
});
|
});
|
||||||
// sendRequest resolves with the JSON-RPC "result" directly
|
|
||||||
const res: unknown = response;
|
const res: unknown = response;
|
||||||
let items: Array<Record<string, unknown>> = [];
|
let items: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
if (Array.isArray(res)) {
|
if (res && typeof res === 'object' && 'sessions' in res) {
|
||||||
items = res;
|
const sessionsValue = (res as { sessions?: unknown }).sessions;
|
||||||
} else if (typeof res === 'object' && res !== null && 'items' in res) {
|
items = Array.isArray(sessionsValue)
|
||||||
const responseObject = res as {
|
? (sessionsValue as Array<Record<string, unknown>>)
|
||||||
items?: Array<Record<string, unknown>>;
|
: [];
|
||||||
};
|
|
||||||
items = Array.isArray(responseObject.items) ? responseObject.items : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = items.map((item) => ({
|
const mapped = items.map((item) => ({
|
||||||
|
|
@ -464,25 +481,29 @@ export class QwenAgentManager {
|
||||||
title: item.title || item.name || item.prompt || 'Untitled Session',
|
title: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
name: item.title || item.name || item.prompt || 'Untitled Session',
|
name: item.title || item.name || item.prompt || 'Untitled Session',
|
||||||
startTime: item.startTime,
|
startTime: item.startTime,
|
||||||
lastUpdated: item.mtime || item.lastUpdated,
|
lastUpdated: item.updatedAt || item.mtime || item.lastUpdated,
|
||||||
messageCount: item.messageCount || 0,
|
messageCount: item.messageCount || 0,
|
||||||
projectHash: item.projectHash,
|
projectHash: item.projectHash,
|
||||||
filePath: item.filePath,
|
filePath: item.filePath,
|
||||||
cwd: item.cwd,
|
cwd: item.cwd,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const nextCursor: number | undefined =
|
// SDK returns nextCursor as string; convert to numeric cursor for paging
|
||||||
typeof res === 'object' && res !== null && 'nextCursor' in res
|
let nextCursorNum: number | undefined;
|
||||||
? typeof res.nextCursor === 'number'
|
if (typeof res === 'object' && res !== null && 'nextCursor' in res) {
|
||||||
? res.nextCursor
|
const raw = (res as { nextCursor?: unknown }).nextCursor;
|
||||||
: undefined
|
if (typeof raw === 'number') {
|
||||||
: undefined;
|
nextCursorNum = raw;
|
||||||
const hasMore: boolean =
|
} else if (typeof raw === 'string') {
|
||||||
typeof res === 'object' && res !== null && 'hasMore' in res
|
const parsed = Number(raw);
|
||||||
? Boolean(res.hasMore)
|
if (!Number.isNaN(parsed)) {
|
||||||
: false;
|
nextCursorNum = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasMore = nextCursorNum !== undefined;
|
||||||
|
|
||||||
return { sessions: mapped, nextCursor, hasMore };
|
return { sessions: mapped, nextCursor: nextCursorNum, hasMore };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[QwenAgentManager] Paged ACP session list failed:', error);
|
console.warn('[QwenAgentManager] Paged ACP session list failed:', error);
|
||||||
// fall through to file system
|
// 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
|
* Try to load session via ACP session/load method
|
||||||
* This method will only be used if CLI version supports it
|
* This method will only be used if CLI version supports it
|
||||||
|
|
@ -980,6 +944,20 @@ export class QwenAgentManager {
|
||||||
'[QwenAgentManager] Session load succeeded. Response:',
|
'[QwenAgentManager] Session load succeeded. Response:',
|
||||||
JSON.stringify(response).substring(0, 200),
|
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;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
|
|
@ -1307,7 +1285,7 @@ export class QwenAgentManager {
|
||||||
* @param callback - Permission request callback function
|
* @param callback - Permission request callback function
|
||||||
*/
|
*/
|
||||||
onPermissionRequest(
|
onPermissionRequest(
|
||||||
callback: (request: AcpPermissionRequest) => Promise<string>,
|
callback: (request: RequestPermissionRequest) => Promise<string>,
|
||||||
): void {
|
): void {
|
||||||
this.callbacks.onPermissionRequest = callback;
|
this.callbacks.onPermissionRequest = callback;
|
||||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
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 {
|
onModelChanged(callback: (model: ModelInfo) => void): void {
|
||||||
this.callbacks.onModelChanged = callback;
|
this.callbacks.onModelChanged = callback;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
extractModelInfoFromNewSessionResult,
|
extractModelInfoFromNewSessionResult,
|
||||||
extractSessionModelState,
|
extractSessionModelState,
|
||||||
} from '../utils/acpModelInfo.js';
|
} from '../utils/acpModelInfo.js';
|
||||||
import type { ModelInfo } from '../types/acpTypes.js';
|
import type { ModelInfo } from '@agentclientprotocol/sdk';
|
||||||
|
|
||||||
export interface QwenConnectionResult {
|
export interface QwenConnectionResult {
|
||||||
sessionCreated: boolean;
|
sessionCreated: boolean;
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,16 @@ import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js';
|
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
|
* Qwen Session Manager
|
||||||
*
|
*
|
||||||
* This service provides direct filesystem access to save and load sessions
|
* This service provides direct filesystem access to load sessions.
|
||||||
* without relying on the CLI's ACP session/save method.
|
|
||||||
*
|
*
|
||||||
* Note: This is primarily used as a fallback mechanism when ACP methods are
|
* Note: Sessions are auto-saved by the CLI's ChatRecordingService.
|
||||||
* unavailable or fail. In normal operation, ACP session/list and session/load
|
* This class is primarily used as a fallback mechanism for loading sessions
|
||||||
* should be preferred for consistency with the CLI.
|
* when ACP methods are unavailable or fail.
|
||||||
*/
|
*/
|
||||||
export class QwenSessionManager {
|
export class QwenSessionManager {
|
||||||
private qwenDir: string;
|
private qwenDir: string;
|
||||||
|
|
@ -44,60 +43,6 @@ export class QwenSessionManager {
|
||||||
return crypto.randomUUID();
|
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
|
* Load a saved session by name
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js';
|
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 { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
|
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
|
||||||
|
|
||||||
|
|
@ -28,81 +28,15 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
handler = new QwenSessionUpdateHandler(mockCallbacks);
|
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', () => {
|
describe('current_mode_update handling', () => {
|
||||||
it('calls onModeChanged callback with mode id', () => {
|
it('calls onModeChanged callback with mode id', () => {
|
||||||
const modeUpdate: AcpSessionUpdate = {
|
const modeUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'current_mode_update',
|
sessionUpdate: 'current_mode_update',
|
||||||
modeId: 'auto-edit' as ApprovalModeValue,
|
currentModeId: 'auto-edit' as ApprovalModeValue,
|
||||||
},
|
},
|
||||||
} as AcpSessionUpdate;
|
} as SessionNotification;
|
||||||
|
|
||||||
handler.handleSessionUpdate(modeUpdate);
|
handler.handleSessionUpdate(modeUpdate);
|
||||||
|
|
||||||
|
|
@ -112,7 +46,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
|
|
||||||
describe('agent_message_chunk handling', () => {
|
describe('agent_message_chunk handling', () => {
|
||||||
it('calls onStreamChunk callback with text content', () => {
|
it('calls onStreamChunk callback with text content', () => {
|
||||||
const messageUpdate: AcpSessionUpdate = {
|
const messageUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'agent_message_chunk',
|
sessionUpdate: 'agent_message_chunk',
|
||||||
|
|
@ -129,7 +63,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits usage metadata when present', () => {
|
it('emits usage metadata when present', () => {
|
||||||
const messageUpdate: AcpSessionUpdate = {
|
const messageUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'agent_message_chunk',
|
sessionUpdate: 'agent_message_chunk',
|
||||||
|
|
@ -152,18 +86,66 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
|
|
||||||
expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({
|
expect(mockCallbacks.onUsageUpdate).toHaveBeenCalledWith({
|
||||||
usage: {
|
usage: {
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
thoughtTokens: undefined,
|
||||||
|
totalTokens: 150,
|
||||||
|
cachedReadTokens: undefined,
|
||||||
|
cachedWriteTokens: undefined,
|
||||||
promptTokens: 100,
|
promptTokens: 100,
|
||||||
completionTokens: 50,
|
completionTokens: 50,
|
||||||
totalTokens: 150,
|
thoughtsTokens: undefined,
|
||||||
|
cachedTokens: undefined,
|
||||||
},
|
},
|
||||||
durationMs: 1234,
|
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', () => {
|
describe('tool_call handling', () => {
|
||||||
it('calls onToolCall callback with tool call data', () => {
|
it('calls onToolCall callback with tool call data', () => {
|
||||||
const toolCallUpdate: AcpSessionUpdate = {
|
const toolCallUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'tool_call',
|
sessionUpdate: 'tool_call',
|
||||||
|
|
@ -191,7 +173,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
|
|
||||||
describe('plan handling', () => {
|
describe('plan handling', () => {
|
||||||
it('calls onPlan callback with plan entries', () => {
|
it('calls onPlan callback with plan entries', () => {
|
||||||
const planUpdate: AcpSessionUpdate = {
|
const planUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'plan',
|
sessionUpdate: 'plan',
|
||||||
|
|
@ -215,7 +197,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
onStreamChunk: vi.fn(),
|
onStreamChunk: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const planUpdate: AcpSessionUpdate = {
|
const planUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'plan',
|
sessionUpdate: 'plan',
|
||||||
|
|
@ -231,7 +213,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
|
|
||||||
describe('available_commands_update handling', () => {
|
describe('available_commands_update handling', () => {
|
||||||
it('calls onAvailableCommands callback with commands', () => {
|
it('calls onAvailableCommands callback with commands', () => {
|
||||||
const commandsUpdate: AcpSessionUpdate = {
|
const commandsUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'available_commands_update',
|
sessionUpdate: 'available_commands_update',
|
||||||
|
|
@ -253,7 +235,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as AcpSessionUpdate;
|
} as SessionNotification;
|
||||||
|
|
||||||
handler.handleSessionUpdate(commandsUpdate);
|
handler.handleSessionUpdate(commandsUpdate);
|
||||||
|
|
||||||
|
|
@ -269,7 +251,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles commands with input hint', () => {
|
it('handles commands with input hint', () => {
|
||||||
const commandsUpdate: AcpSessionUpdate = {
|
const commandsUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'available_commands_update',
|
sessionUpdate: 'available_commands_update',
|
||||||
|
|
@ -281,7 +263,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as AcpSessionUpdate;
|
} as SessionNotification;
|
||||||
|
|
||||||
handler.handleSessionUpdate(commandsUpdate);
|
handler.handleSessionUpdate(commandsUpdate);
|
||||||
|
|
||||||
|
|
@ -297,7 +279,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
it('does not call callback when onAvailableCommands is not set', () => {
|
it('does not call callback when onAvailableCommands is not set', () => {
|
||||||
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
|
const handlerWithoutCallback = new QwenSessionUpdateHandler({});
|
||||||
|
|
||||||
const commandsUpdate: AcpSessionUpdate = {
|
const commandsUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'available_commands_update',
|
sessionUpdate: 'available_commands_update',
|
||||||
|
|
@ -305,7 +287,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
{ name: 'compress', description: 'Compress', input: null },
|
{ name: 'compress', description: 'Compress', input: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as AcpSessionUpdate;
|
} as SessionNotification;
|
||||||
|
|
||||||
// Should not throw
|
// Should not throw
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|
@ -314,13 +296,13 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty commands list', () => {
|
it('handles empty commands list', () => {
|
||||||
const commandsUpdate: AcpSessionUpdate = {
|
const commandsUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'available_commands_update',
|
sessionUpdate: 'available_commands_update',
|
||||||
availableCommands: [],
|
availableCommands: [],
|
||||||
},
|
},
|
||||||
} as AcpSessionUpdate;
|
} as SessionNotification;
|
||||||
|
|
||||||
handler.handleSessionUpdate(commandsUpdate);
|
handler.handleSessionUpdate(commandsUpdate);
|
||||||
|
|
||||||
|
|
@ -329,28 +311,25 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateCallbacks', () => {
|
describe('updateCallbacks', () => {
|
||||||
it('updates callbacks and uses new ones', () => {
|
it('updates mode callback and uses new one', () => {
|
||||||
const newOnModelChanged = vi.fn();
|
const newOnModeChanged = vi.fn();
|
||||||
handler.updateCallbacks({
|
handler.updateCallbacks({
|
||||||
...mockCallbacks,
|
...mockCallbacks,
|
||||||
onModelChanged: newOnModelChanged,
|
onModeChanged: newOnModeChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
const modelUpdate: AcpSessionUpdate = {
|
const modeUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'current_model_update',
|
sessionUpdate: 'current_mode_update',
|
||||||
model: {
|
currentModeId: 'yolo' as ApprovalModeValue,
|
||||||
modelId: 'new-model',
|
|
||||||
name: 'New Model',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as AcpSessionUpdate;
|
} as SessionNotification;
|
||||||
|
|
||||||
handler.handleSessionUpdate(modelUpdate);
|
handler.handleSessionUpdate(modeUpdate);
|
||||||
|
|
||||||
expect(newOnModelChanged).toHaveBeenCalled();
|
expect(newOnModeChanged).toHaveBeenCalled();
|
||||||
expect(mockCallbacks.onModelChanged).not.toHaveBeenCalled();
|
expect(mockCallbacks.onModeChanged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates onAvailableCommands callback', () => {
|
it('updates onAvailableCommands callback', () => {
|
||||||
|
|
@ -360,7 +339,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
onAvailableCommands: newOnAvailableCommands,
|
onAvailableCommands: newOnAvailableCommands,
|
||||||
});
|
});
|
||||||
|
|
||||||
const commandsUpdate: AcpSessionUpdate = {
|
const commandsUpdate: SessionNotification = {
|
||||||
sessionId: 'test-session',
|
sessionId: 'test-session',
|
||||||
update: {
|
update: {
|
||||||
sessionUpdate: 'available_commands_update',
|
sessionUpdate: 'available_commands_update',
|
||||||
|
|
@ -368,7 +347,7 @@ describe('QwenSessionUpdateHandler', () => {
|
||||||
{ name: 'test', description: 'Test command', input: null },
|
{ name: 'test', description: 'Test command', input: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as AcpSessionUpdate;
|
} as SessionNotification;
|
||||||
|
|
||||||
handler.handleSessionUpdate(commandsUpdate);
|
handler.handleSessionUpdate(commandsUpdate);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AcpSessionUpdate,
|
SessionNotification,
|
||||||
SessionUpdateMeta,
|
|
||||||
ModelInfo,
|
|
||||||
AvailableCommand,
|
AvailableCommand,
|
||||||
} from '../types/acpTypes.js';
|
} from '@agentclientprotocol/sdk';
|
||||||
|
import type { SessionUpdateMeta } from '../types/acpTypes.js';
|
||||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
import type {
|
import type {
|
||||||
QwenAgentCallbacks,
|
QwenAgentCallbacks,
|
||||||
|
|
@ -47,41 +46,58 @@ export class QwenSessionUpdateHandler {
|
||||||
*
|
*
|
||||||
* @param data - ACP session update data
|
* @param data - ACP session update data
|
||||||
*/
|
*/
|
||||||
handleSessionUpdate(data: AcpSessionUpdate): void {
|
handleSessionUpdate(data: SessionNotification): void {
|
||||||
const update = data.update;
|
const update = data.update;
|
||||||
|
const sessionUpdate = (update as { sessionUpdate?: string }).sessionUpdate;
|
||||||
console.log(
|
console.log(
|
||||||
'[SessionUpdateHandler] Processing update type:',
|
'[SessionUpdateHandler] Processing update type:',
|
||||||
update.sessionUpdate,
|
sessionUpdate,
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (update.sessionUpdate) {
|
switch (sessionUpdate) {
|
||||||
case 'user_message_chunk':
|
case 'user_message_chunk': {
|
||||||
if (update.content?.text && this.callbacks.onStreamChunk) {
|
const text = this.getTextContent(
|
||||||
this.callbacks.onStreamChunk(update.content.text);
|
(update as { content?: unknown }).content,
|
||||||
|
);
|
||||||
|
if (text && this.callbacks.onStreamChunk) {
|
||||||
|
this.callbacks.onStreamChunk(text);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'agent_message_chunk':
|
case 'agent_message_chunk': {
|
||||||
if (update.content?.text && this.callbacks.onStreamChunk) {
|
const text = this.getTextContent(
|
||||||
this.callbacks.onStreamChunk(update.content.text);
|
(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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'agent_thought_chunk':
|
case 'agent_thought_chunk': {
|
||||||
if (update.content?.text) {
|
const text = this.getTextContent(
|
||||||
|
(update as { content?: unknown }).content,
|
||||||
|
);
|
||||||
|
if (text) {
|
||||||
if (this.callbacks.onThoughtChunk) {
|
if (this.callbacks.onThoughtChunk) {
|
||||||
this.callbacks.onThoughtChunk(update.content.text);
|
this.callbacks.onThoughtChunk(text);
|
||||||
} else if (this.callbacks.onStreamChunk) {
|
} else if (this.callbacks.onStreamChunk) {
|
||||||
// Fallback to regular stream processing
|
// Fallback to regular stream processing
|
||||||
console.log(
|
console.log(
|
||||||
'[SessionUpdateHandler] 🧠 Falling back to onStreamChunk',
|
'[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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'tool_call': {
|
case 'tool_call': {
|
||||||
// Handle new tool call
|
// Handle new tool call
|
||||||
|
|
@ -159,8 +175,9 @@ export class QwenSessionUpdateHandler {
|
||||||
case 'current_mode_update': {
|
case 'current_mode_update': {
|
||||||
// Notify UI about mode change
|
// Notify UI about mode change
|
||||||
try {
|
try {
|
||||||
const modeId = (update as unknown as { modeId?: ApprovalModeValue })
|
const modeId = (
|
||||||
.modeId;
|
update as unknown as { currentModeId?: ApprovalModeValue }
|
||||||
|
).currentModeId;
|
||||||
if (modeId && this.callbacks.onModeChanged) {
|
if (modeId && this.callbacks.onModeChanged) {
|
||||||
this.callbacks.onModeChanged(modeId);
|
this.callbacks.onModeChanged(modeId);
|
||||||
}
|
}
|
||||||
|
|
@ -173,22 +190,6 @@ export class QwenSessionUpdateHandler {
|
||||||
break;
|
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': {
|
case 'available_commands_update': {
|
||||||
// Notify UI about available commands
|
// Notify UI about available commands
|
||||||
try {
|
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) {
|
if (!meta || !this.callbacks.onUsageUpdate) {
|
||||||
return;
|
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 = {
|
const payload: UsageStatsPayload = {
|
||||||
usage: meta.usage || undefined,
|
usage,
|
||||||
durationMs: meta.durationMs ?? undefined,
|
durationMs: meta.durationMs ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,177 +3,33 @@
|
||||||
* Copyright 2025 Qwen Team
|
* Copyright 2025 Qwen Team
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Usage } from '@agentclientprotocol/sdk';
|
||||||
|
|
||||||
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
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 const authMethod = 'qwen-oauth';
|
||||||
|
|
||||||
export interface AcpRequest {
|
/**
|
||||||
jsonrpc: typeof JSONRPC_VERSION;
|
* Authenticate update notification (Qwen extension, not ACP spec).
|
||||||
id: number;
|
* Sent by agent during the OAuth flow.
|
||||||
method: string;
|
*/
|
||||||
params?: unknown;
|
export interface AuthenticateUpdateNotification {
|
||||||
}
|
_meta: {
|
||||||
|
authUri: string;
|
||||||
export interface AcpResponse {
|
|
||||||
jsonrpc: typeof JSONRPC_VERSION;
|
|
||||||
id: number;
|
|
||||||
result?: unknown;
|
|
||||||
capabilities?: {
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
};
|
||||||
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 {
|
export interface SessionUpdateMeta {
|
||||||
usage?: UsageMetadata | null;
|
usage?: Usage | null;
|
||||||
durationMs?: number | null;
|
durationMs?: number | null;
|
||||||
timestamp?: 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 {
|
export {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
APPROVAL_MODE_MAP,
|
APPROVAL_MODE_MAP,
|
||||||
|
|
@ -181,91 +37,11 @@ export {
|
||||||
getApprovalModeInfoFromString,
|
getApprovalModeInfoFromString,
|
||||||
} from './approvalModeTypes.js';
|
} from './approvalModeTypes.js';
|
||||||
|
|
||||||
// Cyclic next-mode mapping used by UI toggles and other consumers
|
|
||||||
export const NEXT_APPROVAL_MODE: {
|
export const NEXT_APPROVAL_MODE: {
|
||||||
[k in ApprovalModeValue]: ApprovalModeValue;
|
[k in ApprovalModeValue]: ApprovalModeValue;
|
||||||
} = {
|
} = {
|
||||||
// Hide "plan" from the public toggle sequence for now
|
|
||||||
// Cycle: default -> auto-edit -> yolo -> default
|
|
||||||
default: 'auto-edit',
|
default: 'auto-edit',
|
||||||
'auto-edit': 'yolo',
|
'auto-edit': 'yolo',
|
||||||
plan: 'yolo',
|
plan: 'yolo',
|
||||||
yolo: 'default',
|
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;
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
AcpPermissionRequest,
|
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
AvailableCommand,
|
AvailableCommand,
|
||||||
} from './acpTypes.js';
|
RequestPermissionRequest,
|
||||||
|
} from '@agentclientprotocol/sdk';
|
||||||
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
import type { ApprovalModeValue } from './approvalModeValueTypes.js';
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant' | 'thinking';
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
@ -35,10 +35,17 @@ export interface ToolCallUpdateData {
|
||||||
|
|
||||||
export interface UsageStatsPayload {
|
export interface UsageStatsPayload {
|
||||||
usage?: {
|
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;
|
promptTokens?: number | null;
|
||||||
completionTokens?: number | null;
|
completionTokens?: number | null;
|
||||||
thoughtsTokens?: number | null;
|
thoughtsTokens?: number | null;
|
||||||
totalTokens?: number | null;
|
|
||||||
cachedTokens?: number | null;
|
cachedTokens?: number | null;
|
||||||
} | null;
|
} | null;
|
||||||
durationMs?: number | null;
|
durationMs?: number | null;
|
||||||
|
|
@ -51,7 +58,7 @@ export interface QwenAgentCallbacks {
|
||||||
onThoughtChunk?: (chunk: string) => void;
|
onThoughtChunk?: (chunk: string) => void;
|
||||||
onToolCall?: (update: ToolCallUpdateData) => void;
|
onToolCall?: (update: ToolCallUpdateData) => void;
|
||||||
onPlan?: (entries: PlanEntry[]) => void;
|
onPlan?: (entries: PlanEntry[]) => void;
|
||||||
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
onPermissionRequest?: (request: RequestPermissionRequest) => Promise<string>;
|
||||||
onEndTurn?: (reason?: string) => void;
|
onEndTurn?: (reason?: string) => void;
|
||||||
onModeInfo?: (info: {
|
onModeInfo?: (info: {
|
||||||
currentModeId?: ApprovalModeValue;
|
currentModeId?: ApprovalModeValue;
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
|
|
||||||
import type { ChildProcess } from 'child_process';
|
import type { ChildProcess } from 'child_process';
|
||||||
import type {
|
import type {
|
||||||
AcpSessionUpdate,
|
RequestPermissionRequest,
|
||||||
AcpPermissionRequest,
|
SessionNotification,
|
||||||
AuthenticateUpdateNotification,
|
} from '@agentclientprotocol/sdk';
|
||||||
} from './acpTypes.js';
|
import type { AuthenticateUpdateNotification } from './acpTypes.js';
|
||||||
|
|
||||||
export interface PendingRequest<T = unknown> {
|
export interface PendingRequest<T = unknown> {
|
||||||
resolve: (value: T) => void;
|
resolve: (value: T) => void;
|
||||||
|
|
@ -19,8 +19,8 @@ export interface PendingRequest<T = unknown> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcpConnectionCallbacks {
|
export interface AcpConnectionCallbacks {
|
||||||
onSessionUpdate: (data: AcpSessionUpdate) => void;
|
onSessionUpdate: (data: SessionNotification) => void;
|
||||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
onPermissionRequest: (data: RequestPermissionRequest) => Promise<{
|
||||||
optionId: string;
|
optionId: string;
|
||||||
}>;
|
}>;
|
||||||
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;
|
onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 => {
|
const asMeta = (value: unknown): AcpMeta | null | undefined => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import { InputForm } from './components/layout/InputForm.js';
|
||||||
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
|
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
|
||||||
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js';
|
||||||
import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.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 {
|
import {
|
||||||
DEFAULT_TOKEN_LIMIT,
|
DEFAULT_TOKEN_LIMIT,
|
||||||
tokenLimit,
|
tokenLimit,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { QwenAgentManager } from '../services/qwenAgentManager.js';
|
import { QwenAgentManager } from '../services/qwenAgentManager.js';
|
||||||
import { ConversationStore } from '../services/conversationStore.js';
|
import { ConversationStore } from '../services/conversationStore.js';
|
||||||
import type { AcpPermissionRequest } from '../types/acpTypes.js';
|
import type {
|
||||||
import type { ModelInfo } from '../types/acpTypes.js';
|
RequestPermissionRequest,
|
||||||
|
ModelInfo,
|
||||||
|
} from '@agentclientprotocol/sdk';
|
||||||
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
|
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
|
||||||
import { PanelManager } from '../webview/PanelManager.js';
|
import { PanelManager } from '../webview/PanelManager.js';
|
||||||
import { MessageHandler } from '../webview/MessageHandler.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
|
// Track a pending permission request and its resolver so extension commands
|
||||||
// can "simulate" user choice from the command palette (e.g. after accepting
|
// can "simulate" user choice from the command palette (e.g. after accepting
|
||||||
// a diff, auto-allow read/execute, or auto-reject on cancel).
|
// 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;
|
private pendingPermissionResolve: ((optionId: string) => void) | null = null;
|
||||||
// Track current ACP mode id to influence permission/diff behavior
|
// Track current ACP mode id to influence permission/diff behavior
|
||||||
private currentModeId: ApprovalModeValue | null = null;
|
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.agentManager.onModelChanged((model) => {
|
||||||
this.sendMessageToWebView({
|
this.sendMessageToWebView({
|
||||||
type: 'modelChanged',
|
type: 'modelChanged',
|
||||||
|
|
@ -218,7 +220,7 @@ export class WebViewProvider {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.agentManager.onPermissionRequest(
|
this.agentManager.onPermissionRequest(
|
||||||
async (request: AcpPermissionRequest) => {
|
async (request: RequestPermissionRequest) => {
|
||||||
// Auto-approve in auto/yolo mode (no UI, no diff)
|
// Auto-approve in auto/yolo mode (no UI, no diff)
|
||||||
if (this.isAutoMode()) {
|
if (this.isAutoMode()) {
|
||||||
const options = request.options || [];
|
const options = request.options || [];
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import type {
|
||||||
} from '@qwen-code/webui';
|
} from '@qwen-code/webui';
|
||||||
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
|
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
|
||||||
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.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';
|
import { ModelSelector } from './ModelSelector.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { FC } 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';
|
import { PlanCompletedIcon } from '@qwen-code/webui';
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||||
'newQwenSession',
|
'newQwenSession',
|
||||||
'switchQwenSession',
|
'switchQwenSession',
|
||||||
'getQwenSessions',
|
'getQwenSessions',
|
||||||
'saveSession',
|
|
||||||
'resumeSession',
|
'resumeSession',
|
||||||
'cancelStreaming',
|
'cancelStreaming',
|
||||||
// UI action: open a new chat tab (new WebviewPanel)
|
// UI action: open a new chat tab (new WebviewPanel)
|
||||||
|
|
@ -87,10 +86,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'saveSession':
|
|
||||||
await this.handleSaveSession((data?.tag as string) || '');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'resumeSession':
|
case 'resumeSession':
|
||||||
await this.handleResumeSession((data?.sessionId as string) || '');
|
await this.handleResumeSession((data?.sessionId as string) || '');
|
||||||
break;
|
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
|
* Handle cancel streaming request
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -107,24 +107,17 @@ export const useMessageHandling = () => {
|
||||||
streamingMessageIndexRef.current = null;
|
streamingMessageIndexRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const breakThinkingSegment = useCallback(() => {
|
||||||
|
thinkingMessageIndexRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End streaming response
|
* End streaming response
|
||||||
*/
|
*/
|
||||||
const endStreaming = useCallback(() => {
|
const endStreaming = useCallback(() => {
|
||||||
// Finalize streaming; content already lives in the placeholder message
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
streamingMessageIndexRef.current = null;
|
streamingMessageIndexRef.current = null;
|
||||||
// Remove the thinking message if it exists (collapse thoughts)
|
thinkingMessageIndexRef.current = null;
|
||||||
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;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -178,18 +171,10 @@ export const useMessageHandling = () => {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
clearThinking: () => {
|
clearThinking: () => {
|
||||||
setMessages((prev) => {
|
thinkingMessageIndexRef.current = null;
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
breakAssistantSegment,
|
breakAssistantSegment,
|
||||||
|
breakThinkingSegment,
|
||||||
setWaitingForResponse,
|
setWaitingForResponse,
|
||||||
clearWaitingForResponse,
|
clearWaitingForResponse,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
||||||
useState<string>('Past Conversations');
|
useState<string>('Past Conversations');
|
||||||
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
||||||
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
|
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
|
||||||
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
|
|
||||||
const [nextCursor, setNextCursor] = useState<number | undefined>(undefined);
|
const [nextCursor, setNextCursor] = useState<number | undefined>(undefined);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
@ -97,38 +96,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
||||||
[currentSessionId, vscode],
|
[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 {
|
return {
|
||||||
// State
|
// State
|
||||||
qwenSessions,
|
qwenSessions,
|
||||||
|
|
@ -137,7 +104,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
||||||
showSessionSelector,
|
showSessionSelector,
|
||||||
sessionSearchQuery,
|
sessionSearchQuery,
|
||||||
filteredSessions,
|
filteredSessions,
|
||||||
savedSessionTags,
|
|
||||||
nextCursor,
|
nextCursor,
|
||||||
hasMore,
|
hasMore,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -148,7 +114,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
||||||
setCurrentSessionTitle,
|
setCurrentSessionTitle,
|
||||||
setShowSessionSelector,
|
setShowSessionSelector,
|
||||||
setSessionSearchQuery,
|
setSessionSearchQuery,
|
||||||
setSavedSessionTags,
|
|
||||||
setNextCursor,
|
setNextCursor,
|
||||||
setHasMore,
|
setHasMore,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
|
|
@ -157,8 +122,6 @@ export const useSessionManagement = (vscode: VSCodeAPI) => {
|
||||||
handleLoadQwenSessions,
|
handleLoadQwenSessions,
|
||||||
handleNewQwenSession,
|
handleNewQwenSession,
|
||||||
handleSwitchSession,
|
handleSwitchSession,
|
||||||
handleSaveSession,
|
|
||||||
handleSaveSessionResponse,
|
|
||||||
handleLoadMoreSessions,
|
handleLoadMoreSessions,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type {
|
||||||
} from '../../types/chatTypes.js';
|
} from '../../types/chatTypes.js';
|
||||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||||
import type { PlanEntry } from '../../types/chatTypes.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([
|
const FORCE_CLEAR_STREAM_END_REASONS = new Set([
|
||||||
'user_cancelled',
|
'user_cancelled',
|
||||||
|
|
@ -41,10 +41,6 @@ interface UseWebViewMessagesProps {
|
||||||
setNextCursor: (cursor: number | undefined) => void;
|
setNextCursor: (cursor: number | undefined) => void;
|
||||||
setHasMore: (hasMore: boolean) => void;
|
setHasMore: (hasMore: boolean) => void;
|
||||||
setIsLoading: (loading: boolean) => void;
|
setIsLoading: (loading: boolean) => void;
|
||||||
handleSaveSessionResponse: (response: {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
}) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// File context
|
// File context
|
||||||
|
|
@ -91,6 +87,7 @@ interface UseWebViewMessagesProps {
|
||||||
appendStreamChunk: (chunk: string) => void;
|
appendStreamChunk: (chunk: string) => void;
|
||||||
endStreaming: () => void;
|
endStreaming: () => void;
|
||||||
breakAssistantSegment: () => void;
|
breakAssistantSegment: () => void;
|
||||||
|
breakThinkingSegment: () => void;
|
||||||
appendThinkingChunk: (chunk: string) => void;
|
appendThinkingChunk: (chunk: string) => void;
|
||||||
clearThinking: () => void;
|
clearThinking: () => void;
|
||||||
setWaitingForResponse: (message: string) => void;
|
setWaitingForResponse: (message: string) => void;
|
||||||
|
|
@ -612,6 +609,7 @@ export const useWebViewMessages = ({
|
||||||
|
|
||||||
// Split assistant stream so subsequent chunks start a new assistant message
|
// Split assistant stream so subsequent chunks start a new assistant message
|
||||||
handlers.messageHandling.breakAssistantSegment();
|
handlers.messageHandling.breakAssistantSegment();
|
||||||
|
handlers.messageHandling.breakThinkingSegment();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -686,6 +684,7 @@ export const useWebViewMessages = ({
|
||||||
|
|
||||||
// Split assistant message segments, keep rendering blocks independent
|
// Split assistant message segments, keep rendering blocks independent
|
||||||
handlers.messageHandling.breakAssistantSegment?.();
|
handlers.messageHandling.breakAssistantSegment?.();
|
||||||
|
handlers.messageHandling.breakThinkingSegment?.();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[useWebViewMessages] failed to push/merge plan snapshot toolcall:',
|
'[useWebViewMessages] failed to push/merge plan snapshot toolcall:',
|
||||||
|
|
@ -711,6 +710,7 @@ export const useWebViewMessages = ({
|
||||||
(status === 'completed' || status === 'failed');
|
(status === 'completed' || status === 'failed');
|
||||||
if (isStart || isFinalUpdate) {
|
if (isStart || isFinalUpdate) {
|
||||||
handlers.messageHandling.breakAssistantSegment();
|
handlers.messageHandling.breakAssistantSegment();
|
||||||
|
handlers.messageHandling.breakThinkingSegment();
|
||||||
}
|
}
|
||||||
|
|
||||||
// While long-running tools (e.g., execute/bash/command) are in progress,
|
// While long-running tools (e.g., execute/bash/command) are in progress,
|
||||||
|
|
@ -935,11 +935,6 @@ export const useWebViewMessages = ({
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'saveSessionResponse': {
|
|
||||||
handlers.sessionManagement.handleSaveSessionResponse(message.data);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'cancelStreaming':
|
case 'cancelStreaming':
|
||||||
// Handle cancel streaming response from extension
|
// Handle cancel streaming response from extension
|
||||||
// Note: The "Interrupted" message is already added by handleCancel in App.tsx
|
// Note: The "Interrupted" message is already added by handleCancel in App.tsx
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue