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