Session-Level Conversation History Management (#1113)

This commit is contained in:
tanzhenxin
2025-12-03 18:04:48 +08:00
committed by GitHub
parent a7abd8d09f
commit 0a75d85ac9
114 changed files with 9257 additions and 4039 deletions

View 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>;
}

View 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;
}
}

View 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;

View 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);
}
}

View 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',
]);
});
});
});

View 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 '';
}
}

View 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}`);
}
}
}

View 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',
}),
);
});
});
});

View 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];
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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' },
});
});
});
});

View File

@@ -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);
}
}

View File

@@ -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();
});
});
});
});

View File

@@ -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;
}
}

View File

@@ -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',
}),
);
});
});
});
});

View File

@@ -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;
}
}

View 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';

View 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';

View 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;
}