mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Session-Level Conversation History Management (#1113)
This commit is contained in:
385
packages/cli/src/acp-integration/acp.ts
Normal file
385
packages/cli/src/acp-integration/acp.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
|
||||
|
||||
import { z } from 'zod';
|
||||
import * as schema from './schema.js';
|
||||
export * from './schema.js';
|
||||
|
||||
import type { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
|
||||
export class AgentSideConnection implements Client {
|
||||
#connection: Connection;
|
||||
|
||||
constructor(
|
||||
toAgent: (conn: Client) => Agent,
|
||||
input: WritableStream<Uint8Array>,
|
||||
output: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
const agent = toAgent(this);
|
||||
|
||||
const handler = async (
|
||||
method: string,
|
||||
params: unknown,
|
||||
): Promise<unknown> => {
|
||||
switch (method) {
|
||||
case schema.AGENT_METHODS.initialize: {
|
||||
const validatedParams = schema.initializeRequestSchema.parse(params);
|
||||
return agent.initialize(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_new: {
|
||||
const validatedParams = schema.newSessionRequestSchema.parse(params);
|
||||
return agent.newSession(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_load: {
|
||||
if (!agent.loadSession) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.loadSessionRequestSchema.parse(params);
|
||||
return agent.loadSession(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_list: {
|
||||
if (!agent.listSessions) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams =
|
||||
schema.listSessionsRequestSchema.parse(params);
|
||||
return agent.listSessions(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.authenticate: {
|
||||
const validatedParams =
|
||||
schema.authenticateRequestSchema.parse(params);
|
||||
return agent.authenticate(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_prompt: {
|
||||
const validatedParams = schema.promptRequestSchema.parse(params);
|
||||
return agent.prompt(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_cancel: {
|
||||
const validatedParams = schema.cancelNotificationSchema.parse(params);
|
||||
return agent.cancel(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_mode: {
|
||||
if (!agent.setMode) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModeRequestSchema.parse(params);
|
||||
return agent.setMode(validatedParams);
|
||||
}
|
||||
default:
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
};
|
||||
|
||||
this.#connection = new Connection(handler, input, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams new content to the client including text, tool calls, etc.
|
||||
*/
|
||||
async sessionUpdate(params: schema.SessionNotification): Promise<void> {
|
||||
return await this.#connection.sendNotification(
|
||||
schema.CLIENT_METHODS.session_update,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission before running a tool
|
||||
*
|
||||
* The agent specifies a series of permission options with different granularity,
|
||||
* and the client returns the chosen one.
|
||||
*/
|
||||
async requestPermission(
|
||||
params: schema.RequestPermissionRequest,
|
||||
): Promise<schema.RequestPermissionResponse> {
|
||||
return await this.#connection.sendRequest(
|
||||
schema.CLIENT_METHODS.session_request_permission,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
async readTextFile(
|
||||
params: schema.ReadTextFileRequest,
|
||||
): Promise<schema.ReadTextFileResponse> {
|
||||
return await this.#connection.sendRequest(
|
||||
schema.CLIENT_METHODS.fs_read_text_file,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
async writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse> {
|
||||
return await this.#connection.sendRequest(
|
||||
schema.CLIENT_METHODS.fs_write_text_file,
|
||||
params,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type AnyMessage = AnyRequest | AnyResponse | AnyNotification;
|
||||
|
||||
type AnyRequest = {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type AnyResponse = {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
} & Result<unknown>;
|
||||
|
||||
type AnyNotification = {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type Result<T> =
|
||||
| {
|
||||
result: T;
|
||||
}
|
||||
| {
|
||||
error: ErrorResponse;
|
||||
};
|
||||
|
||||
type ErrorResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
type PendingResponse = {
|
||||
resolve: (response: unknown) => void;
|
||||
reject: (error: ErrorResponse) => void;
|
||||
};
|
||||
|
||||
type MethodHandler = (method: string, params: unknown) => Promise<unknown>;
|
||||
|
||||
class Connection {
|
||||
#pendingResponses: Map<string | number, PendingResponse> = new Map();
|
||||
#nextRequestId: number = 0;
|
||||
#handler: MethodHandler;
|
||||
#peerInput: WritableStream<Uint8Array>;
|
||||
#writeQueue: Promise<void> = Promise.resolve();
|
||||
#textEncoder: TextEncoder;
|
||||
|
||||
constructor(
|
||||
handler: MethodHandler,
|
||||
peerInput: WritableStream<Uint8Array>,
|
||||
peerOutput: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
this.#handler = handler;
|
||||
this.#peerInput = peerInput;
|
||||
this.#textEncoder = new TextEncoder();
|
||||
this.#receive(peerOutput);
|
||||
}
|
||||
|
||||
async #receive(output: ReadableStream<Uint8Array>) {
|
||||
let content = '';
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of output) {
|
||||
content += decoder.decode(chunk, { stream: true });
|
||||
const lines = content.split('\n');
|
||||
content = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine) {
|
||||
const message = JSON.parse(trimmedLine);
|
||||
this.#processMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #processMessage(message: AnyMessage) {
|
||||
if ('method' in message && 'id' in message) {
|
||||
// It's a request
|
||||
const response = await this.#tryCallHandler(
|
||||
message.method,
|
||||
message.params,
|
||||
);
|
||||
|
||||
await this.#sendMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
...response,
|
||||
});
|
||||
} else if ('method' in message) {
|
||||
// It's a notification
|
||||
await this.#tryCallHandler(message.method, message.params);
|
||||
} else if ('id' in message) {
|
||||
// It's a response
|
||||
this.#handleResponse(message as AnyResponse);
|
||||
}
|
||||
}
|
||||
|
||||
async #tryCallHandler(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<Result<unknown>> {
|
||||
try {
|
||||
const result = await this.#handler(method, params);
|
||||
return { result: result ?? null };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof RequestError) {
|
||||
return error.toResult();
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return RequestError.invalidParams(
|
||||
JSON.stringify(error.format(), undefined, 2),
|
||||
).toResult();
|
||||
}
|
||||
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
return RequestError.internalError(details).toResult();
|
||||
}
|
||||
}
|
||||
|
||||
#handleResponse(response: AnyResponse) {
|
||||
const pendingResponse = this.#pendingResponses.get(response.id);
|
||||
if (pendingResponse) {
|
||||
if ('result' in response) {
|
||||
pendingResponse.resolve(response.result);
|
||||
} else if ('error' in response) {
|
||||
pendingResponse.reject(response.error);
|
||||
}
|
||||
this.#pendingResponses.delete(response.id);
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> {
|
||||
const id = this.#nextRequestId++;
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
this.#pendingResponses.set(id, { resolve, reject });
|
||||
});
|
||||
await this.#sendMessage({ jsonrpc: '2.0', id, method, params });
|
||||
return responsePromise as Promise<Resp>;
|
||||
}
|
||||
|
||||
async sendNotification<N>(method: string, params?: N): Promise<void> {
|
||||
await this.#sendMessage({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
async #sendMessage(json: AnyMessage) {
|
||||
const content = JSON.stringify(json) + '\n';
|
||||
this.#writeQueue = this.#writeQueue
|
||||
.then(async () => {
|
||||
const writer = this.#peerInput.getWriter();
|
||||
try {
|
||||
await writer.write(this.#textEncoder.encode(content));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// Continue processing writes on error
|
||||
console.error('ACP write error:', error);
|
||||
});
|
||||
return this.#writeQueue;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestError extends Error {
|
||||
data?: { details?: string };
|
||||
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
details?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RequestError';
|
||||
if (details) {
|
||||
this.data = { details };
|
||||
}
|
||||
}
|
||||
|
||||
static parseError(details?: string): RequestError {
|
||||
return new RequestError(-32700, 'Parse error', details);
|
||||
}
|
||||
|
||||
static invalidRequest(details?: string): RequestError {
|
||||
return new RequestError(-32600, 'Invalid request', details);
|
||||
}
|
||||
|
||||
static methodNotFound(details?: string): RequestError {
|
||||
return new RequestError(-32601, 'Method not found', details);
|
||||
}
|
||||
|
||||
static invalidParams(details?: string): RequestError {
|
||||
return new RequestError(-32602, 'Invalid params', details);
|
||||
}
|
||||
|
||||
static internalError(details?: string): RequestError {
|
||||
return new RequestError(-32603, 'Internal error', details);
|
||||
}
|
||||
|
||||
static authRequired(details?: string): RequestError {
|
||||
return new RequestError(-32000, 'Authentication required', details);
|
||||
}
|
||||
|
||||
toResult<T>(): Result<T> {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
requestPermission(
|
||||
params: schema.RequestPermissionRequest,
|
||||
): Promise<schema.RequestPermissionResponse>;
|
||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||
writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse>;
|
||||
readTextFile(
|
||||
params: schema.ReadTextFileRequest,
|
||||
): Promise<schema.ReadTextFileResponse>;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
initialize(
|
||||
params: schema.InitializeRequest,
|
||||
): Promise<schema.InitializeResponse>;
|
||||
newSession(
|
||||
params: schema.NewSessionRequest,
|
||||
): Promise<schema.NewSessionResponse>;
|
||||
loadSession?(
|
||||
params: schema.LoadSessionRequest,
|
||||
): Promise<schema.LoadSessionResponse>;
|
||||
listSessions?(
|
||||
params: schema.ListSessionsRequest,
|
||||
): Promise<schema.ListSessionsResponse>;
|
||||
authenticate(params: schema.AuthenticateRequest): Promise<void>;
|
||||
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
}
|
||||
329
packages/cli/src/acp-integration/acpAgent.ts
Normal file
329
packages/cli/src/acp-integration/acpAgent.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ReadableStream, WritableStream } from 'node:stream/web';
|
||||
|
||||
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
APPROVAL_MODE_INFO,
|
||||
APPROVAL_MODES,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
MCPServerConfig,
|
||||
SessionService,
|
||||
buildApiHistoryFromConversation,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ApprovalModeValue } from './schema.js';
|
||||
import * as acp from './acp.js';
|
||||
import { AcpFileSystemService } from './service/filesystem.js';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { SettingScope } from '../config/settings.js';
|
||||
import { z } from 'zod';
|
||||
import { ExtensionStorage, type Extension } from '../config/extension.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
|
||||
|
||||
// Import the modular Session class
|
||||
import { Session } from './session/Session.js';
|
||||
|
||||
export async function runAcpAgent(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
extensions: Extension[],
|
||||
argv: CliArgs,
|
||||
) {
|
||||
const stdout = Writable.toWeb(process.stdout) as WritableStream;
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
// Stdout is used to send messages to the client, so console.log/console.info
|
||||
// messages to stderr so that they don't interfere with ACP.
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
new acp.AgentSideConnection(
|
||||
(client: acp.Client) =>
|
||||
new GeminiAgent(config, settings, extensions, argv, client),
|
||||
stdout,
|
||||
stdin,
|
||||
);
|
||||
}
|
||||
|
||||
class GeminiAgent {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private settings: LoadedSettings,
|
||||
private extensions: Extension[],
|
||||
private argv: CliArgs,
|
||||
private client: acp.Client,
|
||||
) {}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.USE_OPENAI,
|
||||
name: 'Use OpenAI API key',
|
||||
description:
|
||||
'Requires setting the `OPENAI_API_KEY` environment variable',
|
||||
},
|
||||
{
|
||||
id: AuthType.QWEN_OAUTH,
|
||||
name: 'Qwen OAuth',
|
||||
description:
|
||||
'OAuth authentication for Qwen models with 2000 daily requests',
|
||||
},
|
||||
];
|
||||
|
||||
// Get current approval mode from config
|
||||
const currentApprovalMode = this.config.getApprovalMode();
|
||||
|
||||
// Build available modes from shared APPROVAL_MODE_INFO
|
||||
const availableModes = APPROVAL_MODES.map((mode) => ({
|
||||
id: mode as ApprovalModeValue,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
|
||||
const version = process.env['CLI_VERSION'] || process.version;
|
||||
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
agentInfo: {
|
||||
name: 'qwen-code',
|
||||
title: 'Qwen Code',
|
||||
version,
|
||||
},
|
||||
authMethods,
|
||||
modes: {
|
||||
currentModeId: currentApprovalMode as ApprovalModeValue,
|
||||
availableModes,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
|
||||
await clearCachedCredentialFile();
|
||||
await this.config.refreshAuth(method);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
}
|
||||
|
||||
async newSession({
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const config = await this.newSessionConfig(cwd, mcpServers);
|
||||
await this.ensureAuthenticated(config);
|
||||
this.setupFileSystem(config);
|
||||
|
||||
const session = await this.createAndStoreSession(config);
|
||||
|
||||
return {
|
||||
sessionId: session.getId(),
|
||||
};
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
sessionId?: string,
|
||||
): Promise<Config> {
|
||||
const mergedMcpServers = { ...this.settings.merged.mcpServers };
|
||||
|
||||
for (const { command, args, env: rawEnv, name } of mcpServers) {
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of rawEnv) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd);
|
||||
}
|
||||
|
||||
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
|
||||
|
||||
const argvForSession = {
|
||||
...this.argv,
|
||||
resume: sessionId,
|
||||
continue: false,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
this.extensions,
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
this.argv.extensions,
|
||||
),
|
||||
argvForSession,
|
||||
cwd,
|
||||
);
|
||||
|
||||
await config.initialize();
|
||||
return config;
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
params: acp.LoadSessionRequest,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
const sessionService = new SessionService(params.cwd);
|
||||
const exists = await sessionService.sessionExists(params.sessionId);
|
||||
if (!exists) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const config = await this.newSessionConfig(
|
||||
params.cwd,
|
||||
params.mcpServers,
|
||||
params.sessionId,
|
||||
);
|
||||
await this.ensureAuthenticated(config);
|
||||
this.setupFileSystem(config);
|
||||
|
||||
const sessionData = config.getResumedSessionData();
|
||||
if (!sessionData) {
|
||||
throw acp.RequestError.internalError(
|
||||
`Failed to load session data for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.createAndStoreSession(config, sessionData.conversation);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async listSessions(
|
||||
params: acp.ListSessionsRequest,
|
||||
): Promise<acp.ListSessionsResponse> {
|
||||
const sessionService = new SessionService(params.cwd);
|
||||
const result = await sessionService.listSessions({
|
||||
cursor: params.cursor,
|
||||
size: params.size,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
sessionId: item.sessionId,
|
||||
cwd: item.cwd,
|
||||
startTime: item.startTime,
|
||||
mtime: item.mtime,
|
||||
prompt: item.prompt,
|
||||
gitBranch: item.gitBranch,
|
||||
filePath: item.filePath,
|
||||
messageCount: item.messageCount,
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.setMode(params);
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(selectedType);
|
||||
} catch (e) {
|
||||
console.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
}
|
||||
|
||||
private setupFileSystem(config: Config): void {
|
||||
if (!this.clientCapabilities?.fs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.client,
|
||||
config.getSessionId(),
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
private async createAndStoreSession(
|
||||
config: Config,
|
||||
conversation?: ConversationRecord,
|
||||
): Promise<Session> {
|
||||
const sessionId = config.getSessionId();
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
const history = conversation
|
||||
? buildApiHistoryFromConversation(conversation)
|
||||
: undefined;
|
||||
const chat = history
|
||||
? await geminiClient.startChat(history)
|
||||
: await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.client,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
setTimeout(async () => {
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
}, 0);
|
||||
|
||||
if (conversation && conversation.messages) {
|
||||
await session.replayHistory(conversation.messages);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
579
packages/cli/src/acp-integration/schema.ts
Normal file
579
packages/cli/src/acp-integration/schema.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AGENT_METHODS = {
|
||||
authenticate: 'authenticate',
|
||||
initialize: 'initialize',
|
||||
session_cancel: 'session/cancel',
|
||||
session_load: 'session/load',
|
||||
session_new: 'session/new',
|
||||
session_prompt: 'session/prompt',
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
session_request_permission: 'session/request_permission',
|
||||
session_update: 'session/update',
|
||||
};
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export type WriteTextFileRequest = z.infer<typeof writeTextFileRequestSchema>;
|
||||
|
||||
export type ReadTextFileRequest = z.infer<typeof readTextFileRequestSchema>;
|
||||
|
||||
export type PermissionOptionKind = z.infer<typeof permissionOptionKindSchema>;
|
||||
|
||||
export type Role = z.infer<typeof roleSchema>;
|
||||
|
||||
export type TextResourceContents = z.infer<typeof textResourceContentsSchema>;
|
||||
|
||||
export type BlobResourceContents = z.infer<typeof blobResourceContentsSchema>;
|
||||
|
||||
export type ToolKind = z.infer<typeof toolKindSchema>;
|
||||
|
||||
export type ToolCallStatus = z.infer<typeof toolCallStatusSchema>;
|
||||
|
||||
export type WriteTextFileResponse = z.infer<typeof writeTextFileResponseSchema>;
|
||||
|
||||
export type ReadTextFileResponse = z.infer<typeof readTextFileResponseSchema>;
|
||||
|
||||
export type RequestPermissionOutcome = z.infer<
|
||||
typeof requestPermissionOutcomeSchema
|
||||
>;
|
||||
export type SessionListItem = z.infer<typeof sessionListItemSchema>;
|
||||
export type ListSessionsRequest = z.infer<typeof listSessionsRequestSchema>;
|
||||
export type ListSessionsResponse = z.infer<typeof listSessionsResponseSchema>;
|
||||
|
||||
export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
|
||||
|
||||
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
|
||||
|
||||
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
|
||||
|
||||
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
|
||||
|
||||
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
|
||||
|
||||
export type StopReason = z.infer<typeof stopReasonSchema>;
|
||||
|
||||
export type PromptResponse = z.infer<typeof promptResponseSchema>;
|
||||
|
||||
export type ToolCallLocation = z.infer<typeof toolCallLocationSchema>;
|
||||
|
||||
export type PlanEntry = z.infer<typeof planEntrySchema>;
|
||||
|
||||
export type PermissionOption = z.infer<typeof permissionOptionSchema>;
|
||||
|
||||
export type Annotations = z.infer<typeof annotationsSchema>;
|
||||
|
||||
export type RequestPermissionResponse = z.infer<
|
||||
typeof requestPermissionResponseSchema
|
||||
>;
|
||||
|
||||
export type FileSystemCapability = z.infer<typeof fileSystemCapabilitySchema>;
|
||||
|
||||
export type EnvVariable = z.infer<typeof envVariableSchema>;
|
||||
|
||||
export type McpServer = z.infer<typeof mcpServerSchema>;
|
||||
|
||||
export type AgentCapabilities = z.infer<typeof agentCapabilitiesSchema>;
|
||||
|
||||
export type AuthMethod = z.infer<typeof authMethodSchema>;
|
||||
|
||||
export type ModeInfo = z.infer<typeof modeInfoSchema>;
|
||||
|
||||
export type ModesData = z.infer<typeof modesDataSchema>;
|
||||
|
||||
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
||||
|
||||
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
|
||||
|
||||
export type ClientResponse = z.infer<typeof clientResponseSchema>;
|
||||
|
||||
export type ClientNotification = z.infer<typeof clientNotificationSchema>;
|
||||
|
||||
export type EmbeddedResourceResource = z.infer<
|
||||
typeof embeddedResourceResourceSchema
|
||||
>;
|
||||
|
||||
export type NewSessionRequest = z.infer<typeof newSessionRequestSchema>;
|
||||
|
||||
export type LoadSessionRequest = z.infer<typeof loadSessionRequestSchema>;
|
||||
|
||||
export type InitializeResponse = z.infer<typeof initializeResponseSchema>;
|
||||
|
||||
export type ContentBlock = z.infer<typeof contentBlockSchema>;
|
||||
|
||||
export type ToolCallContent = z.infer<typeof toolCallContentSchema>;
|
||||
|
||||
export type ToolCall = z.infer<typeof toolCallSchema>;
|
||||
|
||||
export type ClientCapabilities = z.infer<typeof clientCapabilitiesSchema>;
|
||||
|
||||
export type PromptRequest = z.infer<typeof promptRequestSchema>;
|
||||
|
||||
export type SessionUpdate = z.infer<typeof sessionUpdateSchema>;
|
||||
|
||||
export type AgentResponse = z.infer<typeof agentResponseSchema>;
|
||||
|
||||
export type RequestPermissionRequest = z.infer<
|
||||
typeof requestPermissionRequestSchema
|
||||
>;
|
||||
|
||||
export type InitializeRequest = z.infer<typeof initializeRequestSchema>;
|
||||
|
||||
export type SessionNotification = z.infer<typeof sessionNotificationSchema>;
|
||||
|
||||
export type ClientRequest = z.infer<typeof clientRequestSchema>;
|
||||
|
||||
export type AgentRequest = z.infer<typeof agentRequestSchema>;
|
||||
|
||||
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
|
||||
|
||||
export type ApprovalModeValue = z.infer<typeof approvalModeValueSchema>;
|
||||
|
||||
export type SetModeRequest = z.infer<typeof setModeRequestSchema>;
|
||||
|
||||
export type SetModeResponse = z.infer<typeof setModeResponseSchema>;
|
||||
|
||||
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
|
||||
|
||||
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
|
||||
|
||||
export type AvailableCommandsUpdate = z.infer<
|
||||
typeof availableCommandsUpdateSchema
|
||||
>;
|
||||
|
||||
export const writeTextFileRequestSchema = z.object({
|
||||
content: z.string(),
|
||||
path: z.string(),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const readTextFileRequestSchema = z.object({
|
||||
limit: z.number().optional().nullable(),
|
||||
line: z.number().optional().nullable(),
|
||||
path: z.string(),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const permissionOptionKindSchema = z.union([
|
||||
z.literal('allow_once'),
|
||||
z.literal('allow_always'),
|
||||
z.literal('reject_once'),
|
||||
z.literal('reject_always'),
|
||||
]);
|
||||
|
||||
export const roleSchema = z.union([z.literal('assistant'), z.literal('user')]);
|
||||
|
||||
export const textResourceContentsSchema = z.object({
|
||||
mimeType: z.string().optional().nullable(),
|
||||
text: z.string(),
|
||||
uri: z.string(),
|
||||
});
|
||||
|
||||
export const blobResourceContentsSchema = z.object({
|
||||
blob: z.string(),
|
||||
mimeType: z.string().optional().nullable(),
|
||||
uri: z.string(),
|
||||
});
|
||||
|
||||
export const toolKindSchema = z.union([
|
||||
z.literal('read'),
|
||||
z.literal('edit'),
|
||||
z.literal('delete'),
|
||||
z.literal('move'),
|
||||
z.literal('search'),
|
||||
z.literal('execute'),
|
||||
z.literal('think'),
|
||||
z.literal('fetch'),
|
||||
z.literal('switch_mode'),
|
||||
z.literal('other'),
|
||||
]);
|
||||
|
||||
export const toolCallStatusSchema = z.union([
|
||||
z.literal('pending'),
|
||||
z.literal('in_progress'),
|
||||
z.literal('completed'),
|
||||
z.literal('failed'),
|
||||
]);
|
||||
|
||||
export const writeTextFileResponseSchema = z.null();
|
||||
|
||||
export const readTextFileResponseSchema = z.object({
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const requestPermissionOutcomeSchema = z.union([
|
||||
z.object({
|
||||
outcome: z.literal('cancelled'),
|
||||
}),
|
||||
z.object({
|
||||
optionId: z.string(),
|
||||
outcome: z.literal('selected'),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const cancelNotificationSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const approvalModeValueSchema = z.union([
|
||||
z.literal('plan'),
|
||||
z.literal('default'),
|
||||
z.literal('auto-edit'),
|
||||
z.literal('yolo'),
|
||||
]);
|
||||
|
||||
export const setModeRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export const setModeResponseSchema = z.object({
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export const authenticateRequestSchema = z.object({
|
||||
methodId: z.string(),
|
||||
});
|
||||
|
||||
export const authenticateResponseSchema = z.null();
|
||||
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const loadSessionResponseSchema = z.null();
|
||||
|
||||
export const sessionListItemSchema = z.object({
|
||||
cwd: z.string(),
|
||||
filePath: z.string(),
|
||||
gitBranch: z.string().optional(),
|
||||
messageCount: z.number(),
|
||||
mtime: z.number(),
|
||||
prompt: z.string(),
|
||||
sessionId: z.string(),
|
||||
startTime: z.string(),
|
||||
});
|
||||
|
||||
export const listSessionsResponseSchema = z.object({
|
||||
hasMore: z.boolean(),
|
||||
items: z.array(sessionListItemSchema),
|
||||
nextCursor: z.number().optional(),
|
||||
});
|
||||
|
||||
export const listSessionsRequestSchema = z.object({
|
||||
cursor: z.number().optional(),
|
||||
cwd: z.string(),
|
||||
size: z.number().optional(),
|
||||
});
|
||||
|
||||
export const stopReasonSchema = z.union([
|
||||
z.literal('end_turn'),
|
||||
z.literal('max_tokens'),
|
||||
z.literal('refusal'),
|
||||
z.literal('cancelled'),
|
||||
]);
|
||||
|
||||
export const promptResponseSchema = z.object({
|
||||
stopReason: stopReasonSchema,
|
||||
});
|
||||
|
||||
export const toolCallLocationSchema = z.object({
|
||||
line: z.number().optional().nullable(),
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
export const planEntrySchema = z.object({
|
||||
content: z.string(),
|
||||
priority: z.union([z.literal('high'), z.literal('medium'), z.literal('low')]),
|
||||
status: z.union([
|
||||
z.literal('pending'),
|
||||
z.literal('in_progress'),
|
||||
z.literal('completed'),
|
||||
]),
|
||||
});
|
||||
|
||||
export const permissionOptionSchema = z.object({
|
||||
kind: permissionOptionKindSchema,
|
||||
name: z.string(),
|
||||
optionId: z.string(),
|
||||
});
|
||||
|
||||
export const annotationsSchema = z.object({
|
||||
audience: z.array(roleSchema).optional().nullable(),
|
||||
lastModified: z.string().optional().nullable(),
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
|
||||
export const fileSystemCapabilitySchema = z.object({
|
||||
readTextFile: z.boolean(),
|
||||
writeTextFile: z.boolean(),
|
||||
});
|
||||
|
||||
export const envVariableSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const mcpServerSchema = z.object({
|
||||
args: z.array(z.string()),
|
||||
command: z.string(),
|
||||
env: z.array(envVariableSchema),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const promptCapabilitiesSchema = z.object({
|
||||
audio: z.boolean().optional(),
|
||||
embeddedContext: z.boolean().optional(),
|
||||
image: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const agentCapabilitiesSchema = z.object({
|
||||
loadSession: z.boolean().optional(),
|
||||
promptCapabilities: promptCapabilitiesSchema.optional(),
|
||||
});
|
||||
|
||||
export const authMethodSchema = z.object({
|
||||
description: z.string().nullable(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const clientResponseSchema = z.union([
|
||||
writeTextFileResponseSchema,
|
||||
readTextFileResponseSchema,
|
||||
requestPermissionResponseSchema,
|
||||
]);
|
||||
|
||||
export const clientNotificationSchema = cancelNotificationSchema;
|
||||
|
||||
export const embeddedResourceResourceSchema = z.union([
|
||||
textResourceContentsSchema,
|
||||
blobResourceContentsSchema,
|
||||
]);
|
||||
|
||||
export const newSessionRequestSchema = z.object({
|
||||
cwd: z.string(),
|
||||
mcpServers: z.array(mcpServerSchema),
|
||||
});
|
||||
|
||||
export const loadSessionRequestSchema = z.object({
|
||||
cwd: z.string(),
|
||||
mcpServers: z.array(mcpServerSchema),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const modeInfoSchema = z.object({
|
||||
id: approvalModeValueSchema,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
export const modesDataSchema = z.object({
|
||||
currentModeId: approvalModeValueSchema,
|
||||
availableModes: z.array(modeInfoSchema),
|
||||
});
|
||||
|
||||
export const agentInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
export const initializeResponseSchema = z.object({
|
||||
agentCapabilities: agentCapabilitiesSchema,
|
||||
agentInfo: agentInfoSchema,
|
||||
authMethods: z.array(authMethodSchema),
|
||||
modes: modesDataSchema,
|
||||
protocolVersion: z.number(),
|
||||
});
|
||||
|
||||
export const contentBlockSchema = z.union([
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
text: z.string(),
|
||||
type: z.literal('text'),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
data: z.string(),
|
||||
mimeType: z.string(),
|
||||
type: z.literal('image'),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
data: z.string(),
|
||||
mimeType: z.string(),
|
||||
type: z.literal('audio'),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
mimeType: z.string().optional().nullable(),
|
||||
name: z.string(),
|
||||
size: z.number().optional().nullable(),
|
||||
title: z.string().optional().nullable(),
|
||||
type: z.literal('resource_link'),
|
||||
uri: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
resource: embeddedResourceResourceSchema,
|
||||
type: z.literal('resource'),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const toolCallContentSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
type: z.literal('content'),
|
||||
}),
|
||||
z.object({
|
||||
newText: z.string(),
|
||||
oldText: z.string().nullable(),
|
||||
path: z.string(),
|
||||
type: z.literal('diff'),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const toolCallSchema = z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
kind: toolKindSchema,
|
||||
locations: z.array(toolCallLocationSchema).optional(),
|
||||
rawInput: z.unknown().optional(),
|
||||
status: toolCallStatusSchema,
|
||||
title: z.string(),
|
||||
toolCallId: z.string(),
|
||||
});
|
||||
|
||||
export const clientCapabilitiesSchema = z.object({
|
||||
fs: fileSystemCapabilitySchema,
|
||||
});
|
||||
|
||||
export const promptRequestSchema = z.object({
|
||||
prompt: z.array(contentBlockSchema),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandInputSchema = z.object({
|
||||
hint: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandSchema = z.object({
|
||||
description: z.string(),
|
||||
input: availableCommandInputSchema.nullable().optional(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandsUpdateSchema = z.object({
|
||||
availableCommands: z.array(availableCommandSchema),
|
||||
sessionUpdate: z.literal('available_commands_update'),
|
||||
});
|
||||
|
||||
export const currentModeUpdateSchema = z.object({
|
||||
sessionUpdate: z.literal('current_mode_update'),
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
|
||||
|
||||
export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('user_message_chunk'),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
kind: toolKindSchema,
|
||||
locations: z.array(toolCallLocationSchema).optional(),
|
||||
rawInput: z.unknown().optional(),
|
||||
sessionUpdate: z.literal('tool_call'),
|
||||
status: toolCallStatusSchema,
|
||||
title: z.string(),
|
||||
toolCallId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional().nullable(),
|
||||
kind: toolKindSchema.optional().nullable(),
|
||||
locations: z.array(toolCallLocationSchema).optional().nullable(),
|
||||
rawInput: z.unknown().optional(),
|
||||
rawOutput: z.unknown().optional(),
|
||||
sessionUpdate: z.literal('tool_call_update'),
|
||||
status: toolCallStatusSchema.optional().nullable(),
|
||||
title: z.string().optional().nullable(),
|
||||
toolCallId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
entries: z.array(planEntrySchema),
|
||||
sessionUpdate: z.literal('plan'),
|
||||
}),
|
||||
currentModeUpdateSchema,
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
initializeResponseSchema,
|
||||
authenticateResponseSchema,
|
||||
newSessionResponseSchema,
|
||||
loadSessionResponseSchema,
|
||||
promptResponseSchema,
|
||||
listSessionsResponseSchema,
|
||||
setModeResponseSchema,
|
||||
]);
|
||||
|
||||
export const requestPermissionRequestSchema = z.object({
|
||||
options: z.array(permissionOptionSchema),
|
||||
sessionId: z.string(),
|
||||
toolCall: toolCallSchema,
|
||||
});
|
||||
|
||||
export const initializeRequestSchema = z.object({
|
||||
clientCapabilities: clientCapabilitiesSchema,
|
||||
protocolVersion: z.number(),
|
||||
});
|
||||
|
||||
export const sessionNotificationSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
update: sessionUpdateSchema,
|
||||
});
|
||||
|
||||
export const clientRequestSchema = z.union([
|
||||
writeTextFileRequestSchema,
|
||||
readTextFileRequestSchema,
|
||||
requestPermissionRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentRequestSchema = z.union([
|
||||
initializeRequestSchema,
|
||||
authenticateRequestSchema,
|
||||
newSessionRequestSchema,
|
||||
loadSessionRequestSchema,
|
||||
promptRequestSchema,
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
50
packages/cli/src/acp-integration/service/filesystem.ts
Normal file
50
packages/cli/src/acp-integration/service/filesystem.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
*/
|
||||
export class AcpFileSystemService implements FileSystemService {
|
||||
constructor(
|
||||
private readonly client: acp.Client,
|
||||
private readonly sessionId: string,
|
||||
private readonly capabilities: acp.FileSystemCapability,
|
||||
private readonly fallback: FileSystemService,
|
||||
) {}
|
||||
|
||||
async readTextFile(filePath: string): Promise<string> {
|
||||
if (!this.capabilities.readTextFile) {
|
||||
return this.fallback.readTextFile(filePath);
|
||||
}
|
||||
|
||||
const response = await this.client.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: null,
|
||||
});
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
async writeTextFile(filePath: string, content: string): Promise<void> {
|
||||
if (!this.capabilities.writeTextFile) {
|
||||
return this.fallback.writeTextFile(filePath, content);
|
||||
}
|
||||
|
||||
await this.client.writeTextFile({
|
||||
path: filePath,
|
||||
content,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
}
|
||||
findFiles(fileName: string, searchPaths: readonly string[]): string[] {
|
||||
return this.fallback.findFiles(fileName, searchPaths);
|
||||
}
|
||||
}
|
||||
414
packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Normal file
414
packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import type { SessionContext } from './types.js';
|
||||
import type {
|
||||
Config,
|
||||
ChatRecord,
|
||||
ToolRegistry,
|
||||
ToolResultDisplay,
|
||||
TodoResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('HistoryReplayer', () => {
|
||||
let mockContext: SessionContext;
|
||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
let replayer: HistoryReplayer;
|
||||
|
||||
beforeEach(() => {
|
||||
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const mockToolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue(null),
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
mockContext = {
|
||||
sessionId: 'test-session-id',
|
||||
config: {
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config,
|
||||
sendUpdate: sendUpdateSpy,
|
||||
};
|
||||
|
||||
replayer = new HistoryReplayer(mockContext);
|
||||
});
|
||||
|
||||
const createUserRecord = (text: string): ChatRecord => ({
|
||||
uuid: 'user-uuid',
|
||||
parentUuid: null,
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'user',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [{ text }],
|
||||
},
|
||||
});
|
||||
|
||||
const createAssistantRecord = (
|
||||
text: string,
|
||||
thought = false,
|
||||
): ChatRecord => ({
|
||||
uuid: 'assistant-uuid',
|
||||
parentUuid: 'user-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'assistant',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text, thought }],
|
||||
},
|
||||
});
|
||||
|
||||
const createToolResultRecord = (
|
||||
toolName: string,
|
||||
resultDisplay?: ToolResultDisplay,
|
||||
hasError = false,
|
||||
): ChatRecord => ({
|
||||
uuid: 'tool-uuid',
|
||||
parentUuid: 'assistant-uuid',
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'tool_result',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
response: { result: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCallResult: {
|
||||
callId: 'call-123',
|
||||
responseParts: [],
|
||||
resultDisplay,
|
||||
error: hasError ? new Error('Tool failed') : undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
describe('replay', () => {
|
||||
it('should replay empty records array', async () => {
|
||||
await replayer.replay([]);
|
||||
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should replay records in order', async () => {
|
||||
const records = [
|
||||
createUserRecord('Hello'),
|
||||
createAssistantRecord('Hi there'),
|
||||
];
|
||||
|
||||
await replayer.replay(records);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
||||
expect(sendUpdateSpy.mock.calls[0][0].sessionUpdate).toBe(
|
||||
'user_message_chunk',
|
||||
);
|
||||
expect(sendUpdateSpy.mock.calls[1][0].sessionUpdate).toBe(
|
||||
'agent_message_chunk',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user message replay', () => {
|
||||
it('should emit user_message_chunk for user records', async () => {
|
||||
const records = [createUserRecord('Hello, world!')];
|
||||
|
||||
await replayer.replay(records);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'Hello, world!' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip user records without message', async () => {
|
||||
const record: ChatRecord = {
|
||||
...createUserRecord('test'),
|
||||
message: undefined,
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant message replay', () => {
|
||||
it('should emit agent_message_chunk for assistant records', async () => {
|
||||
const records = [createAssistantRecord('I can help with that.')];
|
||||
|
||||
await replayer.replay(records);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'I can help with that.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit agent_thought_chunk for thought parts', async () => {
|
||||
const records = [createAssistantRecord('Thinking about this...', true)];
|
||||
|
||||
await replayer.replay(records);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'Thinking about this...' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle assistant records with multiple parts', async () => {
|
||||
const record: ChatRecord = {
|
||||
...createAssistantRecord('First'),
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'First part' },
|
||||
{ text: 'Second part', thought: true },
|
||||
{ text: 'Third part' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
|
||||
expect(sendUpdateSpy.mock.calls[0][0]).toEqual({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'First part' },
|
||||
});
|
||||
expect(sendUpdateSpy.mock.calls[1][0]).toEqual({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'Second part' },
|
||||
});
|
||||
expect(sendUpdateSpy.mock.calls[2][0]).toEqual({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Third part' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('function call replay', () => {
|
||||
it('should emit tool_call for function call parts', async () => {
|
||||
const record: ChatRecord = {
|
||||
...createAssistantRecord(''),
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'read_file',
|
||||
args: { path: '/test.ts' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
status: 'in_progress',
|
||||
title: 'read_file',
|
||||
rawInput: { path: '/test.ts' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use function call id as callId when available', async () => {
|
||||
const record: ChatRecord = {
|
||||
...createAssistantRecord(''),
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'custom-call-id',
|
||||
name: 'read_file',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolCallId: 'custom-call-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool result replay', () => {
|
||||
it('should emit tool_call_update for tool result records', async () => {
|
||||
const records = [
|
||||
createToolResultRecord('read_file', 'File contents here'),
|
||||
];
|
||||
|
||||
await replayer.replay(records);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-123',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
// Content comes from functionResponse.response (stringified)
|
||||
content: { type: 'text', text: '{"result":"ok"}' },
|
||||
},
|
||||
],
|
||||
// resultDisplay is included as rawOutput
|
||||
rawOutput: 'File contents here',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit failed status for tool results with errors', async () => {
|
||||
const records = [createToolResultRecord('failing_tool', undefined, true)];
|
||||
|
||||
await replayer.replay(records);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'failed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit plan update for TodoWriteTool results', async () => {
|
||||
const todoDisplay: TodoResultDisplay = {
|
||||
type: 'todo_list',
|
||||
todos: [
|
||||
{ id: '1', content: 'Task 1', status: 'pending' },
|
||||
{ id: '2', content: 'Task 2', status: 'completed' },
|
||||
],
|
||||
};
|
||||
const record = createToolResultRecord('todo_write', todoDisplay);
|
||||
// Override the function response name
|
||||
record.message = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'todo_write',
|
||||
response: { result: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'Task 1', priority: 'medium', status: 'pending' },
|
||||
{ content: 'Task 2', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use record uuid as callId when toolCallResult.callId is missing', async () => {
|
||||
const record: ChatRecord = {
|
||||
...createToolResultRecord('test_tool'),
|
||||
uuid: 'fallback-uuid',
|
||||
toolCallResult: {
|
||||
callId: undefined as unknown as string,
|
||||
responseParts: [],
|
||||
resultDisplay: 'Result',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await replayer.replay([record]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolCallId: 'fallback-uuid',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system records', () => {
|
||||
it('should skip system records', async () => {
|
||||
const systemRecord: ChatRecord = {
|
||||
uuid: 'system-uuid',
|
||||
parentUuid: null,
|
||||
sessionId: 'test-session',
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'system',
|
||||
subtype: 'chat_compression',
|
||||
cwd: '/test',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
await replayer.replay([systemRecord]);
|
||||
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed record types', () => {
|
||||
it('should handle a complete conversation replay', async () => {
|
||||
const records: ChatRecord[] = [
|
||||
createUserRecord('Read the file test.ts'),
|
||||
{
|
||||
...createAssistantRecord(''),
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: "I'll read that file for you.", thought: true },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call-read',
|
||||
name: 'read_file',
|
||||
args: { path: 'test.ts' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
createToolResultRecord('read_file', 'export const x = 1;'),
|
||||
createAssistantRecord('The file contains a simple export.'),
|
||||
];
|
||||
|
||||
await replayer.replay(records);
|
||||
|
||||
// Verify order and types of updates
|
||||
const updateTypes = sendUpdateSpy.mock.calls.map(
|
||||
(call: unknown[]) =>
|
||||
(call[0] as { sessionUpdate: string }).sessionUpdate,
|
||||
);
|
||||
expect(updateTypes).toEqual([
|
||||
'user_message_chunk',
|
||||
'agent_thought_chunk',
|
||||
'tool_call',
|
||||
'tool_call_update',
|
||||
'agent_message_chunk',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
137
packages/cli/src/acp-integration/session/HistoryReplayer.ts
Normal file
137
packages/cli/src/acp-integration/session/HistoryReplayer.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
|
||||
/**
|
||||
* Handles replaying session history on session load.
|
||||
*
|
||||
* Uses the unified emitters to ensure consistency with normal flow.
|
||||
* This ensures that replayed history looks identical to how it would
|
||||
* have appeared during the original session.
|
||||
*/
|
||||
export class HistoryReplayer {
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
|
||||
constructor(ctx: SessionContext) {
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays all chat records from a loaded session.
|
||||
*
|
||||
* @param records - Array of chat records to replay
|
||||
*/
|
||||
async replay(records: ChatRecord[]): Promise<void> {
|
||||
for (const record of records) {
|
||||
await this.replayRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a single chat record.
|
||||
*/
|
||||
private async replayRecord(record: ChatRecord): Promise<void> {
|
||||
switch (record.type) {
|
||||
case 'user':
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'user');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assistant':
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
await this.replayToolResult(record);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Skip system records (compression, telemetry, slash commands)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays content from a message (user or assistant).
|
||||
* Handles text parts, thought parts, and function calls.
|
||||
*/
|
||||
private async replayContent(
|
||||
content: Content,
|
||||
role: 'user' | 'assistant',
|
||||
): Promise<void> {
|
||||
for (const part of content.parts ?? []) {
|
||||
// Text content
|
||||
if ('text' in part && part.text) {
|
||||
const isThought = (part as { thought?: boolean }).thought ?? false;
|
||||
await this.messageEmitter.emitMessage(part.text, role, isThought);
|
||||
}
|
||||
|
||||
// Function call (tool start)
|
||||
if ('functionCall' in part && part.functionCall) {
|
||||
const functionName = part.functionCall.name ?? '';
|
||||
const callId = part.functionCall.id ?? `${functionName}-${Date.now()}`;
|
||||
|
||||
await this.toolCallEmitter.emitStart({
|
||||
toolName: functionName,
|
||||
callId,
|
||||
args: part.functionCall.args as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a tool result record.
|
||||
*/
|
||||
private async replayToolResult(record: ChatRecord): Promise<void> {
|
||||
// message is required - skip if not present
|
||||
if (!record.message?.parts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = record.toolCallResult;
|
||||
const callId = result?.callId ?? record.uuid;
|
||||
|
||||
// Extract tool name from the function response in message if available
|
||||
const toolName = this.extractToolNameFromRecord(record);
|
||||
|
||||
await this.toolCallEmitter.emitResult({
|
||||
toolName,
|
||||
callId,
|
||||
success: !result?.error,
|
||||
message: record.message.parts,
|
||||
resultDisplay: result?.resultDisplay,
|
||||
// For TodoWriteTool fallback, try to extract args from the record
|
||||
// Note: args aren't stored in tool_result records by default
|
||||
args: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a chat record's function response.
|
||||
*/
|
||||
private extractToolNameFromRecord(record: ChatRecord): string {
|
||||
// Try to get from functionResponse in message
|
||||
if (record.message?.parts) {
|
||||
for (const part of record.message.parts) {
|
||||
if ('functionResponse' in part && part.functionResponse?.name) {
|
||||
return part.functionResponse.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
981
packages/cli/src/acp-integration/session/Session.ts
Normal file
981
packages/cli/src/acp-integration/session/Session.ts
Normal file
@@ -0,0 +1,981 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolResult,
|
||||
ChatRecord,
|
||||
SubAgentEventEmitter,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
convertToFunctionResponse,
|
||||
DiscoveredMCPTool,
|
||||
StreamEventType,
|
||||
ToolConfirmationOutcome,
|
||||
logToolCall,
|
||||
logUserPrompt,
|
||||
getErrorStatus,
|
||||
isWithinRoot,
|
||||
isNodeError,
|
||||
TaskTool,
|
||||
UserPromptEvent,
|
||||
TodoWriteTool,
|
||||
ExitPlanModeTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import * as acp from '../acp.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import {
|
||||
handleSlashCommand,
|
||||
getAvailableCommands,
|
||||
} from '../../nonInteractiveCliCommands.js';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
AvailableCommandsUpdate,
|
||||
SetModeRequest,
|
||||
SetModeResponse,
|
||||
ApprovalModeValue,
|
||||
CurrentModeUpdate,
|
||||
} from '../schema.js';
|
||||
import { isSlashCommand } from '../../ui/utils/commandUtils.js';
|
||||
|
||||
// Import modular session components
|
||||
import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
/**
|
||||
* Built-in commands that are allowed in ACP integration mode.
|
||||
* Only safe, read-only commands that don't require interactive UI.
|
||||
*/
|
||||
export const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
||||
|
||||
/**
|
||||
* Session represents an active conversation session with the AI model.
|
||||
* It uses modular components for consistent event emission:
|
||||
* - HistoryReplayer for replaying past conversations
|
||||
* - ToolCallEmitter for tool-related session updates
|
||||
* - PlanEmitter for todo/plan updates
|
||||
* - SubAgentTracker for tracking sub-agent tool calls
|
||||
*/
|
||||
export class Session implements SessionContext {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
private turn: number = 0;
|
||||
|
||||
// Modular components
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
|
||||
// Implement SessionContext interface
|
||||
readonly sessionId: string;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
private readonly chat: GeminiChat,
|
||||
readonly config: Config,
|
||||
private readonly client: acp.Client,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {
|
||||
this.sessionId = id;
|
||||
|
||||
// Initialize modular components with this session as context
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
this.planEmitter = new PlanEmitter(this);
|
||||
this.historyReplayer = new HistoryReplayer(this);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
getConfig(): Config {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays conversation history to the client using modular components.
|
||||
* Delegates to HistoryReplayer for consistent event emission.
|
||||
*/
|
||||
async replayHistory(records: ChatRecord[]): Promise<void> {
|
||||
await this.historyReplayer.replay(records);
|
||||
}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
if (!this.pendingPrompt) {
|
||||
throw new Error('Not currently generating');
|
||||
}
|
||||
|
||||
this.pendingPrompt.abort();
|
||||
this.pendingPrompt = null;
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
this.pendingPrompt?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingPrompt = pendingSend;
|
||||
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
const chat = this.chat;
|
||||
const promptId = this.config.getSessionId() + '########' + this.turn;
|
||||
|
||||
// Extract text from all text blocks to construct the full prompt text for logging
|
||||
const promptText = params.prompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
|
||||
// Log user prompt
|
||||
logUserPrompt(
|
||||
this.config,
|
||||
new UserPromptEvent(
|
||||
promptText.length,
|
||||
promptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
promptText,
|
||||
),
|
||||
);
|
||||
|
||||
// record user message for session management
|
||||
this.config.getChatRecordingService()?.recordUserMessage(promptText);
|
||||
|
||||
// Check if the input contains a slash command
|
||||
// Extract text from the first text block if present
|
||||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||
const inputText = firstTextBlock?.text || '';
|
||||
|
||||
let parts: Part[];
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - allow specific built-in commands for ACP integration
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
if (slashCommandResult) {
|
||||
// Use the result from the slash command
|
||||
parts = slashCommandResult as Part[];
|
||||
} else {
|
||||
// Slash command didn't return a prompt, continue with normal processing
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
} else {
|
||||
// Normal processing for non-slash commands
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
while (nextMessage !== null) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
chat.addHistory(nextMessage);
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
this.config.getModel(),
|
||||
{
|
||||
message: nextMessage?.parts ?? [],
|
||||
config: {
|
||||
abortSignal: pendingSend.signal,
|
||||
},
|
||||
},
|
||||
promptId,
|
||||
);
|
||||
nextMessage = null;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.candidates &&
|
||||
resp.value.candidates.length > 0
|
||||
) {
|
||||
const candidate = resp.value.candidates[0];
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (!part.text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
throw new acp.RequestError(
|
||||
429,
|
||||
'Rate limit exceeded. Try again later.',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const fc of functionCalls) {
|
||||
const response = await this.runTool(pendingSend.signal, promptId, fc);
|
||||
toolResponseParts.push(...response);
|
||||
}
|
||||
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
}
|
||||
}
|
||||
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
|
||||
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
const params: acp.SessionNotification = {
|
||||
sessionId: this.sessionId,
|
||||
update,
|
||||
};
|
||||
|
||||
await this.client.sessionUpdate(params);
|
||||
}
|
||||
|
||||
async sendAvailableCommandsUpdate(): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const slashCommands = await getAvailableCommands(
|
||||
this.config,
|
||||
this.settings,
|
||||
abortController.signal,
|
||||
ALLOWED_BUILTIN_COMMANDS_FOR_ACP,
|
||||
);
|
||||
|
||||
// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol
|
||||
const availableCommands: AvailableCommand[] = slashCommands.map(
|
||||
(cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
input: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const update: AvailableCommandsUpdate = {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
};
|
||||
|
||||
await this.sendUpdate(update);
|
||||
} catch (error) {
|
||||
// Log error but don't fail session creation
|
||||
console.error('Error sending available commands update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests permission from the client for a tool call.
|
||||
* Used by SubAgentTracker for sub-agent approval requests.
|
||||
*/
|
||||
async requestPermission(
|
||||
params: acp.RequestPermissionRequest,
|
||||
): Promise<acp.RequestPermissionResponse> {
|
||||
return this.client.requestPermission(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the approval mode for the current session.
|
||||
* Maps ACP approval mode values to core ApprovalMode enum.
|
||||
*/
|
||||
async setMode(params: SetModeRequest): Promise<SetModeResponse> {
|
||||
const modeMap: Record<ApprovalModeValue, ApprovalMode> = {
|
||||
plan: ApprovalMode.PLAN,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
'auto-edit': ApprovalMode.AUTO_EDIT,
|
||||
yolo: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
const approvalMode = modeMap[params.modeId];
|
||||
this.config.setApprovalMode(approvalMode);
|
||||
|
||||
return { modeId: params.modeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a current_mode_update notification to the client.
|
||||
* Called after the agent switches modes (e.g., from exit_plan_mode tool).
|
||||
*/
|
||||
private async sendCurrentModeUpdateNotification(
|
||||
outcome: ToolConfirmationOutcome,
|
||||
): Promise<void> {
|
||||
// Determine the new mode based on the approval outcome
|
||||
// This mirrors the logic in ExitPlanModeTool.onConfirm
|
||||
let newModeId: ApprovalModeValue;
|
||||
switch (outcome) {
|
||||
case ToolConfirmationOutcome.ProceedAlways:
|
||||
newModeId = 'auto-edit';
|
||||
break;
|
||||
case ToolConfirmationOutcome.ProceedOnce:
|
||||
default:
|
||||
newModeId = 'default';
|
||||
break;
|
||||
}
|
||||
|
||||
const update: CurrentModeUpdate = {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
modeId: newModeId,
|
||||
};
|
||||
|
||||
await this.sendUpdate(update);
|
||||
}
|
||||
|
||||
private async runTool(
|
||||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
fc: FunctionCall,
|
||||
): Promise<Part[]> {
|
||||
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
|
||||
const args = (fc.args ?? {}) as Record<string, unknown>;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const errorResponse = (error: Error) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
'event.name': 'tool_call',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
prompt_id: promptId,
|
||||
function_name: fc.name ?? '',
|
||||
function_args: args,
|
||||
duration_ms: durationMs,
|
||||
status: 'error',
|
||||
success: false,
|
||||
error: error.message,
|
||||
tool_type:
|
||||
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
|
||||
? 'mcp'
|
||||
: 'native',
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: fc.name ?? '',
|
||||
response: { error: error.message },
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
if (!fc.name) {
|
||||
return errorResponse(new Error('Missing function name'));
|
||||
}
|
||||
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(fc.name as string);
|
||||
|
||||
if (!tool) {
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" not found in registry.`),
|
||||
);
|
||||
}
|
||||
|
||||
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
|
||||
const isTodoWriteTool = tool.name === TodoWriteTool.Name;
|
||||
const isTaskTool = tool.name === TaskTool.Name;
|
||||
const isExitPlanModeTool = tool.name === ExitPlanModeTool.Name;
|
||||
|
||||
// Track cleanup functions for sub-agent event listeners
|
||||
let subAgentCleanupFunctions: Array<() => void> = [];
|
||||
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
|
||||
if (isTaskTool && 'eventEmitter' in invocation) {
|
||||
// Access eventEmitter from TaskTool invocation
|
||||
const taskEventEmitter = (
|
||||
invocation as {
|
||||
eventEmitter: SubAgentEventEmitter;
|
||||
}
|
||||
).eventEmitter;
|
||||
|
||||
// Create a SubAgentTracker for this tool execution
|
||||
const subAgentTracker = new SubAgentTracker(this, this.client);
|
||||
|
||||
// Set up sub-agent tool tracking
|
||||
subAgentCleanupFunctions = subAgentTracker.setup(
|
||||
taskEventEmitter,
|
||||
abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Add plan content for exit_plan_mode
|
||||
if (confirmationDetails.type === 'plan') {
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: confirmationDetails.plan,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Map tool kind, using switch_mode for exit_plan_mode per ACP spec
|
||||
const mappedKind = this.toolCallEmitter.mapToolKind(tool.kind, fc.name);
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
sessionId: this.sessionId,
|
||||
options: toPermissionOptions(confirmationDetails),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
status: 'pending',
|
||||
title: invocation.getDescription(),
|
||||
content,
|
||||
locations: invocation.toolLocations(),
|
||||
kind: mappedKind,
|
||||
},
|
||||
};
|
||||
|
||||
const output = await this.client.requestPermission(params);
|
||||
const outcome =
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
: z
|
||||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
await confirmationDetails.onConfirm(outcome);
|
||||
|
||||
// After exit_plan_mode confirmation, send current_mode_update notification
|
||||
if (isExitPlanModeTool && outcome !== ToolConfirmationOutcome.Cancel) {
|
||||
await this.sendCurrentModeUpdateNotification(outcome);
|
||||
}
|
||||
|
||||
switch (outcome) {
|
||||
case ToolConfirmationOutcome.Cancel:
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" was canceled by the user.`),
|
||||
);
|
||||
case ToolConfirmationOutcome.ProceedOnce:
|
||||
case ToolConfirmationOutcome.ProceedAlways:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysServer:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysTool:
|
||||
case ToolConfirmationOutcome.ModifyWithEditor:
|
||||
break;
|
||||
default: {
|
||||
const resultOutcome: never = outcome;
|
||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||
}
|
||||
}
|
||||
} else if (!isTodoWriteTool) {
|
||||
// Skip tool_call event for TodoWriteTool - use ToolCallEmitter
|
||||
const startParams: ToolCallStartParams = {
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
};
|
||||
await this.toolCallEmitter.emitStart(startParams);
|
||||
}
|
||||
|
||||
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
||||
|
||||
// Clean up event listeners
|
||||
subAgentCleanupFunctions.forEach((cleanup) => cleanup());
|
||||
|
||||
// Create response parts first (needed for emitResult and recordToolResult)
|
||||
const responseParts = convertToFunctionResponse(
|
||||
fc.name,
|
||||
callId,
|
||||
toolResult.llmContent,
|
||||
);
|
||||
|
||||
// Handle TodoWriteTool: extract todos and send plan update
|
||||
if (isTodoWriteTool) {
|
||||
const todos = this.planEmitter.extractTodos(
|
||||
toolResult.returnDisplay,
|
||||
args,
|
||||
);
|
||||
|
||||
// Match original logic: emit plan if todos.length > 0 OR if args had todos
|
||||
if ((todos && todos.length > 0) || Array.isArray(args['todos'])) {
|
||||
await this.planEmitter.emitPlan(todos ?? []);
|
||||
}
|
||||
|
||||
// Skip tool_call_update event for TodoWriteTool
|
||||
// Still log and return function response for LLM
|
||||
} else {
|
||||
// Normal tool handling: emit result using ToolCallEmitter
|
||||
// Convert toolResult.error to Error type if present
|
||||
const error = toolResult.error
|
||||
? new Error(toolResult.error.message)
|
||||
: undefined;
|
||||
|
||||
await this.toolCallEmitter.emitResult({
|
||||
callId,
|
||||
toolName: fc.name,
|
||||
args,
|
||||
message: responseParts,
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error,
|
||||
success: !toolResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
'event.name': 'tool_call',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
function_name: fc.name,
|
||||
function_args: args,
|
||||
duration_ms: durationMs,
|
||||
status: 'success',
|
||||
success: true,
|
||||
prompt_id: promptId,
|
||||
tool_type:
|
||||
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
|
||||
? 'mcp'
|
||||
: 'native',
|
||||
});
|
||||
|
||||
// Record tool result for session management
|
||||
this.config.getChatRecordingService()?.recordToolResult(responseParts, {
|
||||
callId,
|
||||
status: 'success',
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
});
|
||||
|
||||
return responseParts;
|
||||
} catch (e) {
|
||||
// Ensure cleanup on error
|
||||
subAgentCleanupFunctions.forEach((cleanup) => cleanup());
|
||||
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
|
||||
// Use ToolCallEmitter for error handling
|
||||
await this.toolCallEmitter.emitError(callId, error);
|
||||
|
||||
// Record tool error for session management
|
||||
const errorParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: fc.name ?? '',
|
||||
response: { error: error.message },
|
||||
},
|
||||
},
|
||||
];
|
||||
this.config.getChatRecordingService()?.recordToolResult(errorParts, {
|
||||
callId,
|
||||
status: 'error',
|
||||
resultDisplay: undefined,
|
||||
error,
|
||||
errorType: undefined,
|
||||
});
|
||||
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #resolvePrompt(
|
||||
message: acp.ContentBlock[],
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
const FILE_URI_SCHEME = 'file://';
|
||||
|
||||
const embeddedContext: acp.EmbeddedResourceResource[] = [];
|
||||
|
||||
const parts = message.map((part) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return { text: part.text };
|
||||
case 'image':
|
||||
case 'audio':
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: part.mimeType,
|
||||
data: part.data,
|
||||
},
|
||||
};
|
||||
case 'resource_link': {
|
||||
if (part.uri.startsWith(FILE_URI_SCHEME)) {
|
||||
return {
|
||||
fileData: {
|
||||
mimeData: part.mimeType,
|
||||
name: part.name,
|
||||
fileUri: part.uri.slice(FILE_URI_SCHEME.length),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return { text: `@${part.uri}` };
|
||||
}
|
||||
}
|
||||
case 'resource': {
|
||||
embeddedContext.push(part.resource);
|
||||
return { text: `@${part.resource.uri}` };
|
||||
}
|
||||
default: {
|
||||
const unreachable: never = part;
|
||||
throw new Error(`Unexpected chunk type: '${unreachable}'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const atPathCommandParts = parts.filter((part) => 'fileData' in part);
|
||||
|
||||
if (atPathCommandParts.length === 0 && embeddedContext.length === 0) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore();
|
||||
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const ignoredPaths: string[] = [];
|
||||
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||
const globTool = toolRegistry.getTool('glob');
|
||||
|
||||
if (!readManyFilesTool) {
|
||||
throw new Error('Error: read_many_files tool not found.');
|
||||
}
|
||||
|
||||
for (const atPathPart of atPathCommandParts) {
|
||||
const pathName = atPathPart.fileData!.fileUri;
|
||||
// Check if path should be ignored by git
|
||||
if (fileDiscovery.shouldGitIgnoreFile(pathName)) {
|
||||
ignoredPaths.push(pathName);
|
||||
const reason = respectGitIgnore
|
||||
? 'git-ignored and will be skipped'
|
||||
: 'ignored by custom patterns';
|
||||
console.warn(`Path ${pathName} is ${reason}.`);
|
||||
continue;
|
||||
}
|
||||
let currentPathSpec = pathName;
|
||||
let resolvedSuccessfully = false;
|
||||
try {
|
||||
const absolutePath = path.resolve(this.config.getTargetDir(), pathName);
|
||||
if (isWithinRoot(absolutePath, this.config.getTargetDir())) {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
currentPathSpec = pathName.endsWith('/')
|
||||
? `${pathName}**`
|
||||
: `${pathName}/**`;
|
||||
this.debug(
|
||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||
);
|
||||
} else {
|
||||
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
|
||||
}
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.debug(
|
||||
`Path ${pathName} is outside the project directory. Skipping.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (this.config.getEnableRecursiveFileSearch() && globTool) {
|
||||
this.debug(
|
||||
`Path ${pathName} not found directly, attempting glob search.`,
|
||||
);
|
||||
try {
|
||||
const globResult = await globTool.buildAndExecute(
|
||||
{
|
||||
pattern: `**/*${pathName}*`,
|
||||
path: this.config.getTargetDir(),
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
if (
|
||||
globResult.llmContent &&
|
||||
typeof globResult.llmContent === 'string' &&
|
||||
!globResult.llmContent.startsWith('No files found') &&
|
||||
!globResult.llmContent.startsWith('Error:')
|
||||
) {
|
||||
const lines = globResult.llmContent.split('\n');
|
||||
if (lines.length > 1 && lines[1]) {
|
||||
const firstMatchAbsolute = lines[1].trim();
|
||||
currentPathSpec = path.relative(
|
||||
this.config.getTargetDir(),
|
||||
firstMatchAbsolute,
|
||||
);
|
||||
this.debug(
|
||||
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
|
||||
);
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.debug(
|
||||
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.debug(
|
||||
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} catch (globError) {
|
||||
console.error(
|
||||
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.debug(
|
||||
`Glob tool not found. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (resolvedSuccessfully) {
|
||||
pathSpecsToRead.push(currentPathSpec);
|
||||
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
|
||||
contentLabelsForDisplay.push(pathName);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the initial part of the query for the LLM
|
||||
let initialQueryText = '';
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const chunk = parts[i];
|
||||
if ('text' in chunk) {
|
||||
initialQueryText += chunk.text;
|
||||
} else {
|
||||
// type === 'atPath'
|
||||
const resolvedSpec =
|
||||
chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri);
|
||||
if (
|
||||
i > 0 &&
|
||||
initialQueryText.length > 0 &&
|
||||
!initialQueryText.endsWith(' ') &&
|
||||
resolvedSpec
|
||||
) {
|
||||
// Add space if previous part was text and didn't end with space, or if previous was @path
|
||||
const prevPart = parts[i - 1];
|
||||
if (
|
||||
'text' in prevPart ||
|
||||
('fileData' in prevPart &&
|
||||
atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri))
|
||||
) {
|
||||
initialQueryText += ' ';
|
||||
}
|
||||
}
|
||||
// Append the resolved path spec for display purposes
|
||||
if (resolvedSpec) {
|
||||
initialQueryText += `@${resolvedSpec}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ignored paths message
|
||||
let ignoredPathsMessage = '';
|
||||
if (ignoredPaths.length > 0) {
|
||||
const pathList = ignoredPaths.map((p) => `- ${p}`).join('\n');
|
||||
ignoredPathsMessage = `Note: The following paths were skipped because they are ignored:\n${pathList}\n\n`;
|
||||
}
|
||||
|
||||
const processedQueryParts: Part[] = [];
|
||||
|
||||
// Read files using read_many_files tool
|
||||
if (pathSpecsToRead.length > 0) {
|
||||
const readResult = await readManyFilesTool.buildAndExecute(
|
||||
{
|
||||
paths_with_line_ranges: pathSpecsToRead,
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const contentForLlm =
|
||||
typeof readResult.llmContent === 'string'
|
||||
? readResult.llmContent
|
||||
: JSON.stringify(readResult.llmContent);
|
||||
|
||||
// Combine content label, ignored paths message, file content, and user query
|
||||
const combinedText = `${ignoredPathsMessage}${contentForLlm}`.trim();
|
||||
processedQueryParts.push({ text: combinedText });
|
||||
processedQueryParts.push({ text: initialQueryText });
|
||||
} else if (embeddedContext.length > 0) {
|
||||
// No @path files to read, but we have embedded context
|
||||
processedQueryParts.push({
|
||||
text: `${ignoredPathsMessage}${initialQueryText}`.trim(),
|
||||
});
|
||||
} else {
|
||||
// No @path files found or resolved
|
||||
processedQueryParts.push({
|
||||
text: `${ignoredPathsMessage}${initialQueryText}`.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Process embedded context from resource blocks
|
||||
for (const contextPart of embeddedContext) {
|
||||
// Type guard for text resources
|
||||
if ('text' in contextPart && contextPart.text) {
|
||||
processedQueryParts.push({
|
||||
text: `File: ${contextPart.uri}\n${contextPart.text}`,
|
||||
});
|
||||
}
|
||||
// Type guard for blob resources
|
||||
if ('blob' in contextPart && contextPart.blob) {
|
||||
processedQueryParts.push({
|
||||
inlineData: {
|
||||
mimeType: contextPart.mimeType ?? 'application/octet-stream',
|
||||
data: contextPart.blob,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return processedQueryParts;
|
||||
}
|
||||
|
||||
debug(msg: string): void {
|
||||
if (this.config.getDebugMode()) {
|
||||
console.warn(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions
|
||||
// ============================================================================
|
||||
|
||||
const basicPermissionOptions = [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Allow',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Reject',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): acp.PermissionOption[] {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'exec':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'mcp':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'info':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'plan':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Yes, and auto-accept edits`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: `Yes, and manually approve edits`,
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: `No, keep planning (esc)`,
|
||||
kind: 'reject_once',
|
||||
},
|
||||
];
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
525
packages/cli/src/acp-integration/session/SubAgentTracker.test.ts
Normal file
525
packages/cli/src/acp-integration/session/SubAgentTracker.test.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SubAgentTracker } from './SubAgentTracker.js';
|
||||
import type { SessionContext } from './types.js';
|
||||
import type {
|
||||
Config,
|
||||
ToolRegistry,
|
||||
SubAgentEventEmitter,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
ToolEditConfirmationDetails,
|
||||
ToolInfoConfirmationDetails,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SubAgentEventType,
|
||||
ToolConfirmationOutcome,
|
||||
TodoWriteTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Helper to create a mock SubAgentToolCallEvent with required fields
|
||||
function createToolCallEvent(
|
||||
overrides: Partial<SubAgentToolCallEvent> & { name: string; callId: string },
|
||||
): SubAgentToolCallEvent {
|
||||
return {
|
||||
subagentId: 'test-subagent',
|
||||
round: 1,
|
||||
timestamp: Date.now(),
|
||||
description: `Calling ${overrides.name}`,
|
||||
args: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a mock SubAgentToolResultEvent with required fields
|
||||
function createToolResultEvent(
|
||||
overrides: Partial<SubAgentToolResultEvent> & {
|
||||
name: string;
|
||||
callId: string;
|
||||
success: boolean;
|
||||
},
|
||||
): SubAgentToolResultEvent {
|
||||
return {
|
||||
subagentId: 'test-subagent',
|
||||
round: 1,
|
||||
timestamp: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a mock SubAgentApprovalRequestEvent with required fields
|
||||
function createApprovalEvent(
|
||||
overrides: Partial<SubAgentApprovalRequestEvent> & {
|
||||
name: string;
|
||||
callId: string;
|
||||
confirmationDetails: SubAgentApprovalRequestEvent['confirmationDetails'];
|
||||
respond: SubAgentApprovalRequestEvent['respond'];
|
||||
},
|
||||
): SubAgentApprovalRequestEvent {
|
||||
return {
|
||||
subagentId: 'test-subagent',
|
||||
round: 1,
|
||||
timestamp: Date.now(),
|
||||
description: `Awaiting approval for ${overrides.name}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create edit confirmation details
|
||||
function createEditConfirmation(
|
||||
overrides: Partial<Omit<ToolEditConfirmationDetails, 'onConfirm' | 'type'>>,
|
||||
): Omit<ToolEditConfirmationDetails, 'onConfirm'> {
|
||||
return {
|
||||
type: 'edit',
|
||||
title: 'Edit file',
|
||||
fileName: '/test.ts',
|
||||
filePath: '/test.ts',
|
||||
fileDiff: '',
|
||||
originalContent: '',
|
||||
newContent: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create info confirmation details
|
||||
function createInfoConfirmation(
|
||||
overrides?: Partial<Omit<ToolInfoConfirmationDetails, 'onConfirm' | 'type'>>,
|
||||
): Omit<ToolInfoConfirmationDetails, 'onConfirm'> {
|
||||
return {
|
||||
type: 'info',
|
||||
title: 'Tool requires approval',
|
||||
prompt: 'Allow this action?',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SubAgentTracker', () => {
|
||||
let mockContext: SessionContext;
|
||||
let mockClient: acp.Client;
|
||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
let requestPermissionSpy: ReturnType<typeof vi.fn>;
|
||||
let tracker: SubAgentTracker;
|
||||
let eventEmitter: SubAgentEventEmitter;
|
||||
let abortController: AbortController;
|
||||
|
||||
beforeEach(() => {
|
||||
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
|
||||
requestPermissionSpy = vi.fn().mockResolvedValue({
|
||||
outcome: { optionId: ToolConfirmationOutcome.ProceedOnce },
|
||||
});
|
||||
|
||||
const mockToolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue(null),
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
mockContext = {
|
||||
sessionId: 'test-session-id',
|
||||
config: {
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config,
|
||||
sendUpdate: sendUpdateSpy,
|
||||
};
|
||||
|
||||
mockClient = {
|
||||
requestPermission: requestPermissionSpy,
|
||||
} as unknown as acp.Client;
|
||||
|
||||
tracker = new SubAgentTracker(mockContext, mockClient);
|
||||
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
|
||||
abortController = new AbortController();
|
||||
});
|
||||
|
||||
describe('setup', () => {
|
||||
it('should return cleanup function', () => {
|
||||
const cleanups = tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
expect(cleanups).toHaveLength(1);
|
||||
expect(typeof cleanups[0]).toBe('function');
|
||||
});
|
||||
|
||||
it('should register event listeners', () => {
|
||||
const onSpy = vi.spyOn(eventEmitter, 'on');
|
||||
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
expect(onSpy).toHaveBeenCalledWith(
|
||||
SubAgentEventType.TOOL_CALL,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(onSpy).toHaveBeenCalledWith(
|
||||
SubAgentEventType.TOOL_RESULT,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(onSpy).toHaveBeenCalledWith(
|
||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove event listeners on cleanup', () => {
|
||||
const offSpy = vi.spyOn(eventEmitter, 'off');
|
||||
const cleanups = tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
cleanups[0]();
|
||||
|
||||
expect(offSpy).toHaveBeenCalledWith(
|
||||
SubAgentEventType.TOOL_CALL,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(offSpy).toHaveBeenCalledWith(
|
||||
SubAgentEventType.TOOL_RESULT,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(offSpy).toHaveBeenCalledWith(
|
||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool call handling', () => {
|
||||
it('should emit tool_call on TOOL_CALL event', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const event = createToolCallEvent({
|
||||
name: 'read_file',
|
||||
callId: 'call-123',
|
||||
args: { path: '/test.ts' },
|
||||
description: 'Reading file',
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
|
||||
|
||||
// Allow async operations to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ToolCallEmitter resolves metadata from registry - uses toolName when tool not found
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
kind: 'other',
|
||||
rawInput: { path: '/test.ts' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip tool_call for TodoWriteTool', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const event = createToolCallEvent({
|
||||
name: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
args: { todos: [] },
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
|
||||
|
||||
// Give time for any async operation
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not emit when aborted', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
abortController.abort();
|
||||
|
||||
const event = createToolCallEvent({
|
||||
name: 'read_file',
|
||||
callId: 'call-123',
|
||||
args: {},
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool result handling', () => {
|
||||
it('should emit tool_call_update on TOOL_RESULT event', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
// First emit tool call to store state
|
||||
eventEmitter.emit(
|
||||
SubAgentEventType.TOOL_CALL,
|
||||
createToolCallEvent({
|
||||
name: 'read_file',
|
||||
callId: 'call-123',
|
||||
args: { path: '/test.ts' },
|
||||
}),
|
||||
);
|
||||
|
||||
// Then emit result
|
||||
const resultEvent = createToolResultEvent({
|
||||
name: 'read_file',
|
||||
callId: 'call-123',
|
||||
success: true,
|
||||
resultDisplay: 'File contents',
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-123',
|
||||
status: 'completed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit failed status on unsuccessful result', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const resultEvent = createToolResultEvent({
|
||||
name: 'read_file',
|
||||
callId: 'call-fail',
|
||||
success: false,
|
||||
resultDisplay: undefined,
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status: 'failed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit plan update for TodoWriteTool results', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
// Store args via tool call
|
||||
eventEmitter.emit(
|
||||
SubAgentEventType.TOOL_CALL,
|
||||
createToolCallEvent({
|
||||
name: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
args: {
|
||||
todos: [{ id: '1', content: 'Task 1', status: 'pending' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Emit result with todo_list display
|
||||
const resultEvent = createToolResultEvent({
|
||||
name: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
success: true,
|
||||
resultDisplay: JSON.stringify({
|
||||
type: 'todo_list',
|
||||
todos: [{ id: '1', content: 'Task 1', status: 'completed' }],
|
||||
}),
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'Task 1', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should clean up state after result', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
eventEmitter.emit(
|
||||
SubAgentEventType.TOOL_CALL,
|
||||
createToolCallEvent({
|
||||
name: 'test_tool',
|
||||
callId: 'call-cleanup',
|
||||
args: { test: true },
|
||||
}),
|
||||
);
|
||||
|
||||
eventEmitter.emit(
|
||||
SubAgentEventType.TOOL_RESULT,
|
||||
createToolResultEvent({
|
||||
name: 'test_tool',
|
||||
callId: 'call-cleanup',
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Emit another result for same callId - should not have stored args
|
||||
sendUpdateSpy.mockClear();
|
||||
eventEmitter.emit(
|
||||
SubAgentEventType.TOOL_RESULT,
|
||||
createToolResultEvent({
|
||||
name: 'test_tool',
|
||||
callId: 'call-cleanup',
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Second call should not have args from first call
|
||||
// (state was cleaned up)
|
||||
});
|
||||
});
|
||||
|
||||
describe('approval handling', () => {
|
||||
it('should request permission from client', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const respondSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const event = createApprovalEvent({
|
||||
name: 'edit_file',
|
||||
callId: 'call-edit',
|
||||
description: 'Editing file',
|
||||
confirmationDetails: createEditConfirmation({
|
||||
fileName: '/test.ts',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
}),
|
||||
respond: respondSpy,
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requestPermissionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(requestPermissionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: 'test-session-id',
|
||||
toolCall: expect.objectContaining({
|
||||
toolCallId: 'call-edit',
|
||||
status: 'pending',
|
||||
content: [
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/test.ts',
|
||||
oldText: 'old',
|
||||
newText: 'new',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respond to subagent with permission outcome', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const respondSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const event = createApprovalEvent({
|
||||
name: 'test_tool',
|
||||
callId: 'call-123',
|
||||
confirmationDetails: createInfoConfirmation(),
|
||||
respond: respondSpy,
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(respondSpy).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel on permission request failure', async () => {
|
||||
requestPermissionSpy.mockRejectedValue(new Error('Network error'));
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const respondSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const event = createApprovalEvent({
|
||||
name: 'test_tool',
|
||||
callId: 'call-123',
|
||||
confirmationDetails: createInfoConfirmation(),
|
||||
respond: respondSpy,
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cancelled outcome from client', async () => {
|
||||
requestPermissionSpy.mockResolvedValue({
|
||||
outcome: { outcome: 'cancelled' },
|
||||
});
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const respondSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const event = createApprovalEvent({
|
||||
name: 'test_tool',
|
||||
callId: 'call-123',
|
||||
confirmationDetails: createInfoConfirmation(),
|
||||
respond: respondSpy,
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission options', () => {
|
||||
it('should include "Allow All Edits" for edit type', async () => {
|
||||
tracker.setup(eventEmitter, abortController.signal);
|
||||
|
||||
const event = createApprovalEvent({
|
||||
name: 'edit_file',
|
||||
callId: 'call-123',
|
||||
confirmationDetails: createEditConfirmation({
|
||||
fileName: '/test.ts',
|
||||
originalContent: '',
|
||||
newContent: 'new',
|
||||
}),
|
||||
respond: vi.fn(),
|
||||
});
|
||||
|
||||
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requestPermissionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const call = requestPermissionSpy.mock.calls[0][0];
|
||||
expect(call.options).toContainEqual(
|
||||
expect.objectContaining({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
318
packages/cli/src/acp-integration/session/SubAgentTracker.ts
Normal file
318
packages/cli/src/acp-integration/session/SubAgentTracker.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
SubAgentEventEmitter,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SubAgentEventType,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { z } from 'zod';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
* Permission option kind type matching ACP schema.
|
||||
*/
|
||||
type PermissionKind =
|
||||
| 'allow_once'
|
||||
| 'reject_once'
|
||||
| 'allow_always'
|
||||
| 'reject_always';
|
||||
|
||||
/**
|
||||
* Configuration for permission options displayed to users.
|
||||
*/
|
||||
interface PermissionOptionConfig {
|
||||
optionId: ToolConfirmationOutcome;
|
||||
name: string;
|
||||
kind: PermissionKind;
|
||||
}
|
||||
|
||||
const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Allow',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Reject',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Tracks and emits events for sub-agent tool calls within TaskTool execution.
|
||||
*
|
||||
* Uses the unified ToolCallEmitter for consistency with normal flow
|
||||
* and history replay. Also handles permission requests for tools that
|
||||
* require user approval.
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
tool?: AnyDeclarativeTool;
|
||||
invocation?: AnyToolInvocation;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly ctx: SessionContext,
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners for a sub-agent's tool events.
|
||||
*
|
||||
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
|
||||
* @param abortSignal - Signal to abort tracking if parent is cancelled
|
||||
* @returns Array of cleanup functions to remove listeners
|
||||
*/
|
||||
setup(
|
||||
eventEmitter: SubAgentEventEmitter,
|
||||
abortSignal: AbortSignal,
|
||||
): Array<() => void> {
|
||||
const onToolCall = this.createToolCallHandler(abortSignal);
|
||||
const onToolResult = this.createToolResultHandler(abortSignal);
|
||||
const onApproval = this.createApprovalHandler(abortSignal);
|
||||
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
|
||||
return [
|
||||
() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
|
||||
// Clean up any remaining states
|
||||
this.toolStates.clear();
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for tool call start events.
|
||||
*/
|
||||
private createToolCallHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentToolCallEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
// Look up tool and build invocation for metadata
|
||||
const toolRegistry = this.ctx.config.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(event.name);
|
||||
let invocation: AnyToolInvocation | undefined;
|
||||
|
||||
if (tool) {
|
||||
try {
|
||||
invocation = tool.build(event.args);
|
||||
} catch (e) {
|
||||
// If building fails, continue with defaults
|
||||
console.warn(`Failed to build subagent tool ${event.name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Store tool, invocation, and args for result handling
|
||||
this.toolStates.set(event.callId, {
|
||||
tool,
|
||||
invocation,
|
||||
args: event.args,
|
||||
});
|
||||
|
||||
// Use unified emitter - handles TodoWriteTool skipping internally
|
||||
void this.toolCallEmitter.emitStart({
|
||||
toolName: event.name,
|
||||
callId: event.callId,
|
||||
args: event.args,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for tool result events.
|
||||
*/
|
||||
private createToolResultHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentToolResultEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = this.toolStates.get(event.callId);
|
||||
|
||||
// Use unified emitter - handles TodoWriteTool plan updates internally
|
||||
void this.toolCallEmitter.emitResult({
|
||||
toolName: event.name,
|
||||
callId: event.callId,
|
||||
success: event.success,
|
||||
message: event.responseParts ?? [],
|
||||
resultDisplay: event.resultDisplay,
|
||||
args: state?.args,
|
||||
});
|
||||
|
||||
// Clean up state
|
||||
this.toolStates.delete(event.callId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for tool approval request events.
|
||||
*/
|
||||
private createApprovalHandler(
|
||||
abortSignal: AbortSignal,
|
||||
): (...args: unknown[]) => Promise<void> {
|
||||
return async (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentApprovalRequestEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = this.toolStates.get(event.callId);
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
|
||||
// Handle edit confirmation type - show diff
|
||||
if (event.confirmationDetails.type === 'edit') {
|
||||
const editDetails = event.confirmationDetails as unknown as {
|
||||
type: 'edit';
|
||||
fileName: string;
|
||||
originalContent: string | null;
|
||||
newContent: string;
|
||||
};
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: editDetails.fileName,
|
||||
oldText: editDetails.originalContent ?? '',
|
||||
newText: editDetails.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Build permission request
|
||||
const fullConfirmationDetails = {
|
||||
...event.confirmationDetails,
|
||||
onConfirm: async () => {
|
||||
// Placeholder - actual response handled via event.respond
|
||||
},
|
||||
} as unknown as ToolCallConfirmationDetails;
|
||||
|
||||
const { title, locations, kind } =
|
||||
this.toolCallEmitter.resolveToolMetadata(event.name, state?.args);
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
sessionId: this.ctx.sessionId,
|
||||
options: this.toPermissionOptions(fullConfirmationDetails),
|
||||
toolCall: {
|
||||
toolCallId: event.callId,
|
||||
status: 'pending',
|
||||
title,
|
||||
content,
|
||||
locations,
|
||||
kind,
|
||||
rawInput: state?.args,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Request permission from client
|
||||
const output = await this.client.requestPermission(params);
|
||||
const outcome =
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
: z
|
||||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
// Respond to subagent with the outcome
|
||||
await event.respond(outcome);
|
||||
} catch (error) {
|
||||
// If permission request fails, cancel the tool call
|
||||
console.error(
|
||||
`Permission request failed for subagent tool ${event.name}:`,
|
||||
error,
|
||||
);
|
||||
await event.respond(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts confirmation details to permission options for the client.
|
||||
*/
|
||||
private toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): acp.PermissionOption[] {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow All Edits',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'exec':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'mcp':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${(confirmation as { serverName?: string }).serverName ?? 'server'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'info':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Always Allow',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'plan':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Always Allow Plans',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
default: {
|
||||
// Fallback for unknown types
|
||||
return [...basicPermissionOptions];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SessionContext } from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for all session event emitters.
|
||||
* Provides common functionality and access to session context.
|
||||
*/
|
||||
export abstract class BaseEmitter {
|
||||
constructor(protected readonly ctx: SessionContext) {}
|
||||
|
||||
/**
|
||||
* Sends a session update to the ACP client.
|
||||
*/
|
||||
protected async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
return this.ctx.sendUpdate(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session configuration.
|
||||
*/
|
||||
protected get config() {
|
||||
return this.ctx.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the session ID.
|
||||
*/
|
||||
protected get sessionId() {
|
||||
return this.ctx.sessionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MessageEmitter } from './MessageEmitter.js';
|
||||
import type { SessionContext } from '../types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('MessageEmitter', () => {
|
||||
let mockContext: SessionContext;
|
||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
let emitter: MessageEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockContext = {
|
||||
sessionId: 'test-session-id',
|
||||
config: {} as Config,
|
||||
sendUpdate: sendUpdateSpy,
|
||||
};
|
||||
emitter = new MessageEmitter(mockContext);
|
||||
});
|
||||
|
||||
describe('emitUserMessage', () => {
|
||||
it('should send user_message_chunk update with text content', async () => {
|
||||
await emitter.emitUserMessage('Hello, world!');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'Hello, world!' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty text', async () => {
|
||||
await emitter.emitUserMessage('');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiline text', async () => {
|
||||
const multilineText = 'Line 1\nLine 2\nLine 3';
|
||||
await emitter.emitUserMessage(multilineText);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: multilineText },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAgentMessage', () => {
|
||||
it('should send agent_message_chunk update with text content', async () => {
|
||||
await emitter.emitAgentMessage('I can help you with that.');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'I can help you with that.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitAgentThought', () => {
|
||||
it('should send agent_thought_chunk update with text content', async () => {
|
||||
await emitter.emitAgentThought('Let me think about this...');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'Let me think about this...' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitMessage', () => {
|
||||
it('should emit user message when role is user', async () => {
|
||||
await emitter.emitMessage('User input', 'user');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'User input' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit agent message when role is assistant and isThought is false', async () => {
|
||||
await emitter.emitMessage('Agent response', 'assistant', false);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Agent response' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit agent message when role is assistant and isThought is not provided', async () => {
|
||||
await emitter.emitMessage('Agent response', 'assistant');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Agent response' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit agent thought when role is assistant and isThought is true', async () => {
|
||||
await emitter.emitAgentThought('Thinking...');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'Thinking...' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore isThought when role is user', async () => {
|
||||
// Even if isThought is true, user messages should still be user_message_chunk
|
||||
await emitter.emitMessage('User input', 'user', true);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'User input' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple emissions', () => {
|
||||
it('should handle multiple sequential emissions', async () => {
|
||||
await emitter.emitUserMessage('First');
|
||||
await emitter.emitAgentMessage('Second');
|
||||
await emitter.emitAgentThought('Third');
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'First' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Second' },
|
||||
});
|
||||
expect(sendUpdateSpy).toHaveBeenNthCalledWith(3, {
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: 'Third' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
* Handles emission of text message chunks (user, agent, thought).
|
||||
*
|
||||
* This emitter is responsible for sending message content to the ACP client
|
||||
* in a consistent format, regardless of whether the message comes from
|
||||
* normal flow, history replay, or other sources.
|
||||
*/
|
||||
export class MessageEmitter extends BaseEmitter {
|
||||
/**
|
||||
* Emits a user message chunk.
|
||||
*/
|
||||
async emitUserMessage(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent message chunk.
|
||||
*/
|
||||
async emitAgentMessage(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a message chunk based on role and thought flag.
|
||||
* This is the unified method that handles all message types.
|
||||
*
|
||||
* @param text - The message text content
|
||||
* @param role - Whether this is a user or assistant message
|
||||
* @param isThought - Whether this is an assistant thought (only applies to assistant role)
|
||||
*/
|
||||
async emitMessage(
|
||||
text: string,
|
||||
role: 'user' | 'assistant',
|
||||
isThought: boolean = false,
|
||||
): Promise<void> {
|
||||
if (role === 'user') {
|
||||
return this.emitUserMessage(text);
|
||||
}
|
||||
return isThought
|
||||
? this.emitAgentThought(text)
|
||||
: this.emitAgentMessage(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PlanEmitter } from './PlanEmitter.js';
|
||||
import type { SessionContext, TodoItem } from '../types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('PlanEmitter', () => {
|
||||
let mockContext: SessionContext;
|
||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
let emitter: PlanEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockContext = {
|
||||
sessionId: 'test-session-id',
|
||||
config: {} as Config,
|
||||
sendUpdate: sendUpdateSpy,
|
||||
};
|
||||
emitter = new PlanEmitter(mockContext);
|
||||
});
|
||||
|
||||
describe('emitPlan', () => {
|
||||
it('should send plan update with converted todo entries', async () => {
|
||||
const todos: TodoItem[] = [
|
||||
{ id: '1', content: 'First task', status: 'pending' },
|
||||
{ id: '2', content: 'Second task', status: 'in_progress' },
|
||||
{ id: '3', content: 'Third task', status: 'completed' },
|
||||
];
|
||||
|
||||
await emitter.emitPlan(todos);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'First task', priority: 'medium', status: 'pending' },
|
||||
{ content: 'Second task', priority: 'medium', status: 'in_progress' },
|
||||
{ content: 'Third task', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty todos array', async () => {
|
||||
await emitter.emitPlan([]);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should set default priority to medium for all entries', async () => {
|
||||
const todos: TodoItem[] = [
|
||||
{ id: '1', content: 'Task', status: 'pending' },
|
||||
];
|
||||
|
||||
await emitter.emitPlan(todos);
|
||||
|
||||
const call = sendUpdateSpy.mock.calls[0][0];
|
||||
expect(call.entries[0].priority).toBe('medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTodos', () => {
|
||||
describe('from resultDisplay object', () => {
|
||||
it('should extract todos from valid todo_list object', () => {
|
||||
const resultDisplay = {
|
||||
type: 'todo_list',
|
||||
todos: [
|
||||
{ id: '1', content: 'Task 1', status: 'pending' as const },
|
||||
{ id: '2', content: 'Task 2', status: 'completed' as const },
|
||||
],
|
||||
};
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: '1', content: 'Task 1', status: 'pending' },
|
||||
{ id: '2', content: 'Task 2', status: 'completed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return null for object without type todo_list', () => {
|
||||
const resultDisplay = {
|
||||
type: 'other',
|
||||
todos: [],
|
||||
};
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for object without todos array', () => {
|
||||
const resultDisplay = {
|
||||
type: 'todo_list',
|
||||
items: [], // wrong key
|
||||
};
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('from resultDisplay JSON string', () => {
|
||||
it('should extract todos from valid JSON string', () => {
|
||||
const resultDisplay = JSON.stringify({
|
||||
type: 'todo_list',
|
||||
todos: [{ id: '1', content: 'Task', status: 'pending' }],
|
||||
});
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: '1', content: 'Task', status: 'pending' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return null for invalid JSON string', () => {
|
||||
const resultDisplay = 'not valid json';
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for JSON without todo_list type', () => {
|
||||
const resultDisplay = JSON.stringify({
|
||||
type: 'other',
|
||||
data: {},
|
||||
});
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('from args fallback', () => {
|
||||
it('should extract todos from args when resultDisplay is null', () => {
|
||||
const args = {
|
||||
todos: [{ id: '1', content: 'From args', status: 'pending' }],
|
||||
};
|
||||
|
||||
const result = emitter.extractTodos(null, args);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: '1', content: 'From args', status: 'pending' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract todos from args when resultDisplay is undefined', () => {
|
||||
const args = {
|
||||
todos: [{ id: '1', content: 'From args', status: 'pending' }],
|
||||
};
|
||||
|
||||
const result = emitter.extractTodos(undefined, args);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: '1', content: 'From args', status: 'pending' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should prefer resultDisplay over args', () => {
|
||||
const resultDisplay = {
|
||||
type: 'todo_list',
|
||||
todos: [{ id: '1', content: 'From display', status: 'completed' }],
|
||||
};
|
||||
const args = {
|
||||
todos: [{ id: '2', content: 'From args', status: 'pending' }],
|
||||
};
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay, args);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: '1', content: 'From display', status: 'completed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return null when args has no todos array', () => {
|
||||
const args = { other: 'value' };
|
||||
|
||||
const result = emitter.extractTodos(null, args);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when args.todos is not an array', () => {
|
||||
const args = { todos: 'not an array' };
|
||||
|
||||
const result = emitter.extractTodos(null, args);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null when both resultDisplay and args are undefined', () => {
|
||||
const result = emitter.extractTodos(undefined, undefined);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when resultDisplay is empty object', () => {
|
||||
const result = emitter.extractTodos({});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle resultDisplay with todos but wrong type', () => {
|
||||
const resultDisplay = {
|
||||
type: 'not_todo_list',
|
||||
todos: [{ id: '1', content: 'Task', status: 'pending' }],
|
||||
};
|
||||
|
||||
const result = emitter.extractTodos(resultDisplay);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
import type { TodoItem } from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
|
||||
/**
|
||||
* Handles emission of plan/todo updates.
|
||||
*
|
||||
* This emitter is responsible for converting todo items to ACP plan entries
|
||||
* and sending plan updates to the client. It also provides utilities for
|
||||
* extracting todos from various sources (tool result displays, args, etc.).
|
||||
*/
|
||||
export class PlanEmitter extends BaseEmitter {
|
||||
/**
|
||||
* Emits a plan update with the given todo items.
|
||||
*
|
||||
* @param todos - Array of todo items to send as plan entries
|
||||
*/
|
||||
async emitPlan(todos: TodoItem[]): Promise<void> {
|
||||
const entries: acp.PlanEntry[] = todos.map((todo) => ({
|
||||
content: todo.content,
|
||||
priority: 'medium' as const, // Default priority since todos don't have priority
|
||||
status: todo.status,
|
||||
}));
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'plan',
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts todos from tool result display or args.
|
||||
* Tries multiple sources in priority order:
|
||||
* 1. Result display object with type 'todo_list'
|
||||
* 2. Result display as JSON string
|
||||
* 3. Args with 'todos' array
|
||||
*
|
||||
* @param resultDisplay - The tool result display (object, string, or undefined)
|
||||
* @param args - The tool call arguments (fallback source)
|
||||
* @returns Array of todos if found, null otherwise
|
||||
*/
|
||||
extractTodos(
|
||||
resultDisplay: unknown,
|
||||
args?: Record<string, unknown>,
|
||||
): TodoItem[] | null {
|
||||
// Try resultDisplay first (final state from tool execution)
|
||||
const fromDisplay = this.extractFromResultDisplay(resultDisplay);
|
||||
if (fromDisplay) return fromDisplay;
|
||||
|
||||
// Fallback to args (initial state)
|
||||
if (args && Array.isArray(args['todos'])) {
|
||||
return args['todos'] as TodoItem[];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts todos from a result display value.
|
||||
* Handles both object and JSON string formats.
|
||||
*/
|
||||
private extractFromResultDisplay(resultDisplay: unknown): TodoItem[] | null {
|
||||
if (!resultDisplay) return null;
|
||||
|
||||
// Handle direct object with type 'todo_list'
|
||||
if (typeof resultDisplay === 'object') {
|
||||
const obj = resultDisplay as Record<string, unknown>;
|
||||
if (obj['type'] === 'todo_list' && Array.isArray(obj['todos'])) {
|
||||
return obj['todos'] as TodoItem[];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON string (from subagent events)
|
||||
if (typeof resultDisplay === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(resultDisplay) as Record<string, unknown>;
|
||||
if (
|
||||
parsed?.['type'] === 'todo_list' &&
|
||||
Array.isArray(parsed['todos'])
|
||||
) {
|
||||
return parsed['todos'] as TodoItem[];
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ToolCallEmitter } from './ToolCallEmitter.js';
|
||||
import type { SessionContext } from '../types.js';
|
||||
import type {
|
||||
Config,
|
||||
ToolRegistry,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Kind, TodoWriteTool } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
// Helper to create mock message parts for tests
|
||||
const createMockMessage = (text?: string): Part[] =>
|
||||
text
|
||||
? [{ functionResponse: { name: 'test', response: { output: text } } }]
|
||||
: [];
|
||||
|
||||
describe('ToolCallEmitter', () => {
|
||||
let mockContext: SessionContext;
|
||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
let mockToolRegistry: ToolRegistry;
|
||||
let emitter: ToolCallEmitter;
|
||||
|
||||
// Helper to create mock tool
|
||||
const createMockTool = (
|
||||
overrides: Partial<AnyDeclarativeTool> = {},
|
||||
): AnyDeclarativeTool =>
|
||||
({
|
||||
name: 'test_tool',
|
||||
kind: Kind.Other,
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Test tool description',
|
||||
toolLocations: () => [{ path: '/test/file.ts', line: 10 }],
|
||||
} as unknown as AnyToolInvocation),
|
||||
...overrides,
|
||||
}) as unknown as AnyDeclarativeTool;
|
||||
|
||||
beforeEach(() => {
|
||||
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockToolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue(null),
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
mockContext = {
|
||||
sessionId: 'test-session-id',
|
||||
config: {
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
} as unknown as Config,
|
||||
sendUpdate: sendUpdateSpy,
|
||||
};
|
||||
|
||||
emitter = new ToolCallEmitter(mockContext);
|
||||
});
|
||||
|
||||
describe('emitStart', () => {
|
||||
it('should emit tool_call update with basic params when tool not in registry', async () => {
|
||||
const result = await emitter.emitStart({
|
||||
toolName: 'unknown_tool',
|
||||
callId: 'call-123',
|
||||
args: { arg1: 'value1' },
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
title: 'unknown_tool', // Falls back to tool name
|
||||
content: [],
|
||||
locations: [],
|
||||
kind: 'other',
|
||||
rawInput: { arg1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit tool_call with resolved metadata when tool is in registry', async () => {
|
||||
const mockTool = createMockTool({ kind: Kind.Edit });
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
|
||||
const result = await emitter.emitStart({
|
||||
toolName: 'edit_file',
|
||||
callId: 'call-456',
|
||||
args: { path: '/test.ts' },
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-456',
|
||||
status: 'in_progress',
|
||||
title: 'edit_file: Test tool description',
|
||||
content: [],
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
kind: 'edit',
|
||||
rawInput: { path: '/test.ts' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip emit for TodoWriteTool and return false', async () => {
|
||||
const result = await emitter.emitStart({
|
||||
toolName: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
args: { todos: [] },
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty args', async () => {
|
||||
await emitter.emitStart({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-empty',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawInput: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back gracefully when tool build fails', async () => {
|
||||
const mockTool = createMockTool();
|
||||
vi.mocked(mockTool.build).mockImplementation(() => {
|
||||
throw new Error('Build failed');
|
||||
});
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
|
||||
await emitter.emitStart({
|
||||
toolName: 'failing_tool',
|
||||
callId: 'call-fail',
|
||||
args: { invalid: true },
|
||||
});
|
||||
|
||||
// Should use fallback values
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-fail',
|
||||
status: 'in_progress',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
kind: 'other', // Fallback to other
|
||||
rawInput: { invalid: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
it('should emit tool_call_update with completed status on success', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-123',
|
||||
success: true,
|
||||
message: createMockMessage('Tool completed successfully'),
|
||||
resultDisplay: 'Tool completed successfully',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-123',
|
||||
status: 'completed',
|
||||
rawOutput: 'Tool completed successfully',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit tool_call_update with failed status on failure', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-123',
|
||||
success: false,
|
||||
message: [],
|
||||
error: new Error('Something went wrong'),
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-123',
|
||||
status: 'failed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Something went wrong' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle diff display format', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'edit_file',
|
||||
callId: 'call-edit',
|
||||
success: true,
|
||||
message: [],
|
||||
resultDisplay: {
|
||||
fileName: '/test/file.ts',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-edit',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/test/file.ts',
|
||||
oldText: 'old content',
|
||||
newText: 'new content',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform message parts to content', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-123',
|
||||
success: true,
|
||||
message: [{ text: 'Some text output' }],
|
||||
resultDisplay: 'raw output',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-123',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Some text output' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw output',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty message parts', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-empty',
|
||||
success: true,
|
||||
message: [],
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-empty',
|
||||
status: 'completed',
|
||||
content: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe('TodoWriteTool handling', () => {
|
||||
it('should emit plan update instead of tool_call_update for TodoWriteTool', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
success: true,
|
||||
message: [],
|
||||
resultDisplay: {
|
||||
type: 'todo_list',
|
||||
todos: [
|
||||
{ id: '1', content: 'Task 1', status: 'pending' },
|
||||
{ id: '2', content: 'Task 2', status: 'in_progress' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'Task 1', priority: 'medium', status: 'pending' },
|
||||
{ content: 'Task 2', priority: 'medium', status: 'in_progress' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use args as fallback for TodoWriteTool todos', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
success: true,
|
||||
message: [],
|
||||
resultDisplay: null,
|
||||
args: {
|
||||
todos: [{ id: '1', content: 'From args', status: 'completed' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'From args', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit anything for TodoWriteTool with empty todos', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
success: true,
|
||||
message: [],
|
||||
resultDisplay: { type: 'todo_list', todos: [] },
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not emit anything for TodoWriteTool with no extractable todos', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: TodoWriteTool.Name,
|
||||
callId: 'call-todo',
|
||||
success: true,
|
||||
message: [],
|
||||
resultDisplay: 'Some string result',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitError', () => {
|
||||
it('should emit tool_call_update with failed status and error message', async () => {
|
||||
const error = new Error('Connection timeout');
|
||||
|
||||
await emitter.emitError('call-123', error);
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-123',
|
||||
status: 'failed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Connection timeout' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTodoWriteTool', () => {
|
||||
it('should return true for TodoWriteTool.Name', () => {
|
||||
expect(emitter.isTodoWriteTool(TodoWriteTool.Name)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other tool names', () => {
|
||||
expect(emitter.isTodoWriteTool('read_file')).toBe(false);
|
||||
expect(emitter.isTodoWriteTool('edit_file')).toBe(false);
|
||||
expect(emitter.isTodoWriteTool('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapToolKind', () => {
|
||||
it('should map all Kind values correctly', () => {
|
||||
expect(emitter.mapToolKind(Kind.Read)).toBe('read');
|
||||
expect(emitter.mapToolKind(Kind.Edit)).toBe('edit');
|
||||
expect(emitter.mapToolKind(Kind.Delete)).toBe('delete');
|
||||
expect(emitter.mapToolKind(Kind.Move)).toBe('move');
|
||||
expect(emitter.mapToolKind(Kind.Search)).toBe('search');
|
||||
expect(emitter.mapToolKind(Kind.Execute)).toBe('execute');
|
||||
expect(emitter.mapToolKind(Kind.Think)).toBe('think');
|
||||
expect(emitter.mapToolKind(Kind.Fetch)).toBe('fetch');
|
||||
expect(emitter.mapToolKind(Kind.Other)).toBe('other');
|
||||
});
|
||||
|
||||
it('should map exit_plan_mode tool to switch_mode kind', () => {
|
||||
// exit_plan_mode uses Kind.Think internally, but should map to switch_mode per ACP spec
|
||||
expect(emitter.mapToolKind(Kind.Think, 'exit_plan_mode')).toBe(
|
||||
'switch_mode',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not affect other tools with Kind.Think', () => {
|
||||
// Other tools with Kind.Think should still map to think
|
||||
expect(emitter.mapToolKind(Kind.Think, 'todo_write')).toBe('think');
|
||||
expect(emitter.mapToolKind(Kind.Think, 'some_other_tool')).toBe('think');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExitPlanModeTool', () => {
|
||||
it('should return true for exit_plan_mode tool name', () => {
|
||||
expect(emitter.isExitPlanModeTool('exit_plan_mode')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other tool names', () => {
|
||||
expect(emitter.isExitPlanModeTool('read_file')).toBe(false);
|
||||
expect(emitter.isExitPlanModeTool('edit_file')).toBe(false);
|
||||
expect(emitter.isExitPlanModeTool('todo_write')).toBe(false);
|
||||
expect(emitter.isExitPlanModeTool('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToolMetadata', () => {
|
||||
it('should return defaults when tool not found', () => {
|
||||
const metadata = emitter.resolveToolMetadata('unknown_tool', {
|
||||
arg: 'value',
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
title: 'unknown_tool',
|
||||
locations: [],
|
||||
kind: 'other',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return tool metadata when tool found and built successfully', () => {
|
||||
const mockTool = createMockTool({ kind: Kind.Search });
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
|
||||
const metadata = emitter.resolveToolMetadata('search_tool', {
|
||||
query: 'test',
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
title: 'search_tool: Test tool description',
|
||||
locations: [{ path: '/test/file.ts', line: 10 }],
|
||||
kind: 'search',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: consistent behavior across flows', () => {
|
||||
it('should handle the same params consistently regardless of source', async () => {
|
||||
// This test verifies that the emitter produces consistent output
|
||||
// whether called from normal flow, replay, or subagent
|
||||
|
||||
const params = {
|
||||
toolName: 'read_file',
|
||||
callId: 'consistent-call',
|
||||
args: { path: '/test.ts' },
|
||||
};
|
||||
|
||||
// First call (e.g., from normal flow)
|
||||
await emitter.emitStart(params);
|
||||
const firstCall = sendUpdateSpy.mock.calls[0][0];
|
||||
|
||||
// Reset and call again (e.g., from replay)
|
||||
sendUpdateSpy.mockClear();
|
||||
await emitter.emitStart(params);
|
||||
const secondCall = sendUpdateSpy.mock.calls[0][0];
|
||||
|
||||
// Both should produce identical output
|
||||
expect(firstCall).toEqual(secondCall);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixes verification', () => {
|
||||
describe('Fix 2: functionResponse parts are stringified', () => {
|
||||
it('should stringify functionResponse parts in message', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-func',
|
||||
success: true,
|
||||
message: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test',
|
||||
response: { output: 'test output' },
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: { unknownField: 'value', nested: { data: 123 } },
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-func',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '{"output":"test output"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: { unknownField: 'value', nested: { data: 123 } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fix 3: rawOutput is included in emitResult', () => {
|
||||
it('should include rawOutput when resultDisplay is provided', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-extra',
|
||||
success: true,
|
||||
message: [{ text: 'Result text' }],
|
||||
resultDisplay: 'Result text',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-extra',
|
||||
status: 'completed',
|
||||
rawOutput: 'Result text',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include rawOutput when resultDisplay is undefined', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-null',
|
||||
success: true,
|
||||
message: [],
|
||||
});
|
||||
|
||||
const call = sendUpdateSpy.mock.calls[0][0];
|
||||
expect(call.rawOutput).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fix 5: Line null mapping in resolveToolMetadata', () => {
|
||||
it('should map undefined line to null in locations', () => {
|
||||
const mockTool = createMockTool();
|
||||
// Override toolLocations to return undefined line
|
||||
vi.mocked(mockTool.build).mockReturnValue({
|
||||
getDescription: () => 'Description',
|
||||
toolLocations: () => [
|
||||
{ path: '/file1.ts', line: 10 },
|
||||
{ path: '/file2.ts', line: undefined },
|
||||
{ path: '/file3.ts' }, // no line property
|
||||
],
|
||||
} as unknown as AnyToolInvocation);
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
|
||||
|
||||
const metadata = emitter.resolveToolMetadata('test_tool', {
|
||||
arg: 'value',
|
||||
});
|
||||
|
||||
expect(metadata.locations).toEqual([
|
||||
{ path: '/file1.ts', line: 10 },
|
||||
{ path: '/file2.ts', line: null },
|
||||
{ path: '/file3.ts', line: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fix 6: Empty plan emission when args has todos', () => {
|
||||
it('should emit empty plan when args had todos but result has none', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: TodoWriteTool.Name,
|
||||
callId: 'call-todo-empty',
|
||||
success: true,
|
||||
message: [],
|
||||
resultDisplay: null, // No result display
|
||||
args: {
|
||||
todos: [], // Empty array in args
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit empty plan when result todos is empty but args had todos', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: TodoWriteTool.Name,
|
||||
callId: 'call-todo-cleared',
|
||||
success: true,
|
||||
message: [],
|
||||
resultDisplay: {
|
||||
type: 'todo_list',
|
||||
todos: [], // Empty result
|
||||
},
|
||||
args: {
|
||||
todos: [{ id: '1', content: 'Was here', status: 'pending' }],
|
||||
},
|
||||
});
|
||||
|
||||
// Should still emit empty plan (result takes precedence but we emit empty)
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message transformation', () => {
|
||||
it('should transform text parts from message', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-text',
|
||||
success: true,
|
||||
message: [{ text: 'Text content from message' }],
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-text',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'Text content from message' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform functionResponse parts from message', async () => {
|
||||
await emitter.emitResult({
|
||||
toolName: 'test_tool',
|
||||
callId: 'call-func-resp',
|
||||
success: true,
|
||||
message: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
response: { output: 'Function output' },
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: 'raw result',
|
||||
});
|
||||
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'call-func-resp',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
import { PlanEmitter } from './PlanEmitter.js';
|
||||
import type {
|
||||
SessionContext,
|
||||
ToolCallStartParams,
|
||||
ToolCallResultParams,
|
||||
ResolvedToolMetadata,
|
||||
} from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import {
|
||||
TodoWriteTool,
|
||||
Kind,
|
||||
ExitPlanModeTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Unified tool call event emitter.
|
||||
*
|
||||
* Handles tool_call and tool_call_update for ALL flows:
|
||||
* - Normal tool execution in runTool()
|
||||
* - History replay in HistoryReplayer
|
||||
* - SubAgent tool tracking in SubAgentTracker
|
||||
*
|
||||
* This ensures consistent behavior across all tool event sources,
|
||||
* including special handling for tools like TodoWriteTool.
|
||||
*/
|
||||
export class ToolCallEmitter extends BaseEmitter {
|
||||
private readonly planEmitter: PlanEmitter;
|
||||
|
||||
constructor(ctx: SessionContext) {
|
||||
super(ctx);
|
||||
this.planEmitter = new PlanEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool call start event.
|
||||
*
|
||||
* @param params - Tool call start parameters
|
||||
* @returns true if event was emitted, false if skipped (e.g., TodoWriteTool)
|
||||
*/
|
||||
async emitStart(params: ToolCallStartParams): Promise<boolean> {
|
||||
// Skip tool_call for TodoWriteTool - plan updates sent on result
|
||||
if (this.isTodoWriteTool(params.toolName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { title, locations, kind } = this.resolveToolMetadata(
|
||||
params.toolName,
|
||||
params.args,
|
||||
);
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: 'in_progress',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
kind,
|
||||
rawInput: params.args ?? {},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool call result event.
|
||||
* Handles TodoWriteTool specially by routing to plan updates.
|
||||
*
|
||||
* @param params - Tool call result parameters
|
||||
*/
|
||||
async emitResult(params: ToolCallResultParams): Promise<void> {
|
||||
// Handle TodoWriteTool specially - send plan update instead
|
||||
if (this.isTodoWriteTool(params.toolName)) {
|
||||
const todos = this.planEmitter.extractTodos(
|
||||
params.resultDisplay,
|
||||
params.args,
|
||||
);
|
||||
// Match original behavior: send plan even if empty when args['todos'] exists
|
||||
// This ensures the UI is updated even when all todos are removed
|
||||
if (todos && todos.length > 0) {
|
||||
await this.planEmitter.emitPlan(todos);
|
||||
} else if (params.args && Array.isArray(params.args['todos'])) {
|
||||
// Send empty plan when args had todos but result has none
|
||||
await this.planEmitter.emitPlan([]);
|
||||
}
|
||||
return; // Skip tool_call_update for TodoWriteTool
|
||||
}
|
||||
|
||||
// Determine content for the update
|
||||
let contentArray: acp.ToolCallContent[] = [];
|
||||
|
||||
// Special case: diff result from edit tools (format from resultDisplay)
|
||||
const diffContent = this.extractDiffContent(params.resultDisplay);
|
||||
if (diffContent) {
|
||||
contentArray = [diffContent];
|
||||
} else if (params.error) {
|
||||
// Error case: show error message
|
||||
contentArray = [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: params.error.message },
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Normal case: transform message parts to ToolCallContent[]
|
||||
contentArray = this.transformPartsToToolCallContent(params.message);
|
||||
}
|
||||
|
||||
// Build the update
|
||||
const update: Parameters<typeof this.sendUpdate>[0] = {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: params.callId,
|
||||
status: params.success ? 'completed' : 'failed',
|
||||
content: contentArray,
|
||||
};
|
||||
|
||||
// Add rawOutput from resultDisplay
|
||||
if (params.resultDisplay !== undefined) {
|
||||
(update as Record<string, unknown>)['rawOutput'] = params.resultDisplay;
|
||||
}
|
||||
|
||||
await this.sendUpdate(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a tool call error event.
|
||||
* Use this for explicit error handling when not using emitResult.
|
||||
*
|
||||
* @param callId - The tool call ID
|
||||
* @param error - The error that occurred
|
||||
*/
|
||||
async emitError(callId: string, error: Error): Promise<void> {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'failed',
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: error.message } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Public Utilities ====================
|
||||
|
||||
/**
|
||||
* Checks if a tool name is the TodoWriteTool.
|
||||
* Exposed for external use in components that need to check this.
|
||||
*/
|
||||
isTodoWriteTool(toolName: string): boolean {
|
||||
return toolName === TodoWriteTool.Name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool name is the ExitPlanModeTool.
|
||||
*/
|
||||
isExitPlanModeTool(toolName: string): boolean {
|
||||
return toolName === ExitPlanModeTool.Name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves tool metadata from the registry.
|
||||
* Falls back to defaults if tool not found or build fails.
|
||||
*
|
||||
* @param toolName - Name of the tool
|
||||
* @param args - Tool call arguments (used to build invocation)
|
||||
*/
|
||||
resolveToolMetadata(
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
): ResolvedToolMetadata {
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(toolName);
|
||||
|
||||
let title = tool?.displayName ?? toolName;
|
||||
let locations: acp.ToolCallLocation[] = [];
|
||||
let kind: acp.ToolKind = 'other';
|
||||
|
||||
if (tool && args) {
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
title = `${title}: ${invocation.getDescription()}`;
|
||||
// Map locations to ensure line is null instead of undefined (for ACP consistency)
|
||||
locations = invocation.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
}));
|
||||
// Pass tool name to handle special cases like exit_plan_mode -> switch_mode
|
||||
kind = this.mapToolKind(tool.kind, toolName);
|
||||
} catch {
|
||||
// Use defaults on build failure
|
||||
}
|
||||
}
|
||||
|
||||
return { title, locations, kind };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps core Tool Kind enum to ACP ToolKind string literals.
|
||||
*
|
||||
* @param kind - The core Kind enum value
|
||||
* @param toolName - Optional tool name to handle special cases like exit_plan_mode
|
||||
*/
|
||||
mapToolKind(kind: Kind, toolName?: string): acp.ToolKind {
|
||||
// Special case: exit_plan_mode uses 'switch_mode' kind per ACP spec
|
||||
if (toolName && this.isExitPlanModeTool(toolName)) {
|
||||
return 'switch_mode';
|
||||
}
|
||||
|
||||
const kindMap: Record<Kind, acp.ToolKind> = {
|
||||
[Kind.Read]: 'read',
|
||||
[Kind.Edit]: 'edit',
|
||||
[Kind.Delete]: 'delete',
|
||||
[Kind.Move]: 'move',
|
||||
[Kind.Search]: 'search',
|
||||
[Kind.Execute]: 'execute',
|
||||
[Kind.Think]: 'think',
|
||||
[Kind.Fetch]: 'fetch',
|
||||
[Kind.Other]: 'other',
|
||||
};
|
||||
return kindMap[kind] ?? 'other';
|
||||
}
|
||||
|
||||
// ==================== Private Helpers ====================
|
||||
|
||||
/**
|
||||
* Extracts diff content from resultDisplay if it's a diff type (edit tool result).
|
||||
* Returns null if not a diff.
|
||||
*/
|
||||
private extractDiffContent(
|
||||
resultDisplay: unknown,
|
||||
): acp.ToolCallContent | null {
|
||||
if (!resultDisplay || typeof resultDisplay !== 'object') return null;
|
||||
|
||||
const obj = resultDisplay as Record<string, unknown>;
|
||||
|
||||
// Check if this is a diff display (edit tool result)
|
||||
if ('fileName' in obj && 'newContent' in obj) {
|
||||
return {
|
||||
type: 'diff',
|
||||
path: obj['fileName'] as string,
|
||||
oldText: (obj['originalContent'] as string) ?? '',
|
||||
newText: obj['newContent'] as string,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Part[] to ToolCallContent[].
|
||||
* Extracts text from functionResponse parts and text parts.
|
||||
*/
|
||||
private transformPartsToToolCallContent(
|
||||
parts: Part[],
|
||||
): acp.ToolCallContent[] {
|
||||
const result: acp.ToolCallContent[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
// Handle text parts
|
||||
if ('text' in part && part.text) {
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: part.text },
|
||||
});
|
||||
}
|
||||
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
});
|
||||
} catch {
|
||||
// Ignore serialization errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
10
packages/cli/src/acp-integration/session/emitters/index.ts
Normal file
10
packages/cli/src/acp-integration/session/emitters/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { BaseEmitter } from './BaseEmitter.js';
|
||||
export { MessageEmitter } from './MessageEmitter.js';
|
||||
export { PlanEmitter } from './PlanEmitter.js';
|
||||
export { ToolCallEmitter } from './ToolCallEmitter.js';
|
||||
40
packages/cli/src/acp-integration/session/index.ts
Normal file
40
packages/cli/src/acp-integration/session/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session module for ACP/Zed integration.
|
||||
*
|
||||
* This module provides a modular architecture for handling session events:
|
||||
* - **Emitters**: Unified event emission (MessageEmitter, ToolCallEmitter, PlanEmitter)
|
||||
* - **HistoryReplayer**: Replays session history using unified emitters
|
||||
* - **SubAgentTracker**: Tracks sub-agent tool events using unified emitters
|
||||
*
|
||||
* The key benefit is that all event emission goes through the same emitters,
|
||||
* ensuring consistency between normal flow, history replay, and sub-agent events.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
SessionContext,
|
||||
SessionUpdateSender,
|
||||
ToolCallStartParams,
|
||||
ToolCallResultParams,
|
||||
TodoItem,
|
||||
ResolvedToolMetadata,
|
||||
} from './types.js';
|
||||
|
||||
// Emitters
|
||||
export { BaseEmitter } from './emitters/BaseEmitter.js';
|
||||
export { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
export { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
export { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
|
||||
// Components
|
||||
export { HistoryReplayer } from './HistoryReplayer.js';
|
||||
export { SubAgentTracker } from './SubAgentTracker.js';
|
||||
|
||||
// Main Session class
|
||||
export { Session } from './Session.js';
|
||||
76
packages/cli/src/acp-integration/session/types.ts
Normal file
76
packages/cli/src/acp-integration/session/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type * as acp from '../acp.js';
|
||||
|
||||
/**
|
||||
* Interface for sending session updates to the ACP client.
|
||||
* Implemented by Session class and used by all emitters.
|
||||
*/
|
||||
export interface SessionUpdateSender {
|
||||
sendUpdate(update: acp.SessionUpdate): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session context shared across all emitters.
|
||||
* Provides access to session state and configuration.
|
||||
*/
|
||||
export interface SessionContext extends SessionUpdateSender {
|
||||
readonly sessionId: string;
|
||||
readonly config: Config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for emitting a tool call start event.
|
||||
*/
|
||||
export interface ToolCallStartParams {
|
||||
/** Name of the tool being called */
|
||||
toolName: string;
|
||||
/** Unique identifier for this tool call */
|
||||
callId: string;
|
||||
/** Arguments passed to the tool */
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for emitting a tool call result event.
|
||||
*/
|
||||
export interface ToolCallResultParams {
|
||||
/** Name of the tool that was called */
|
||||
toolName: string;
|
||||
/** Unique identifier for this tool call */
|
||||
callId: string;
|
||||
/** Whether the tool execution succeeded */
|
||||
success: boolean;
|
||||
/** The response parts from tool execution (maps to content in update event) */
|
||||
message: Part[];
|
||||
/** Display result from tool execution (maps to rawOutput in update event) */
|
||||
resultDisplay?: unknown;
|
||||
/** Error if tool execution failed */
|
||||
error?: Error;
|
||||
/** Original args (fallback for TodoWriteTool todos extraction) */
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo item structure for plan updates.
|
||||
*/
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved tool metadata from the registry.
|
||||
*/
|
||||
export interface ResolvedToolMetadata {
|
||||
title: string;
|
||||
locations: acp.ToolCallLocation[];
|
||||
kind: acp.ToolKind;
|
||||
}
|
||||
Reference in New Issue
Block a user