mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
1485 lines
45 KiB
TypeScript
1485 lines
45 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type { ReadableStream, WritableStream } from 'node:stream/web';
|
|
|
|
import type { Content, FunctionCall, Part } from '@google/genai';
|
|
import type {
|
|
Config,
|
|
GeminiChat,
|
|
ToolCallConfirmationDetails,
|
|
ToolResult,
|
|
SubAgentEventEmitter,
|
|
SubAgentToolCallEvent,
|
|
SubAgentToolResultEvent,
|
|
SubAgentApprovalRequestEvent,
|
|
AnyDeclarativeTool,
|
|
AnyToolInvocation,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import {
|
|
AuthType,
|
|
clearCachedCredentialFile,
|
|
convertToFunctionResponse,
|
|
DiscoveredMCPTool,
|
|
StreamEventType,
|
|
DEFAULT_GEMINI_MODEL,
|
|
DEFAULT_GEMINI_MODEL_AUTO,
|
|
DEFAULT_GEMINI_FLASH_MODEL,
|
|
MCPServerConfig,
|
|
ToolConfirmationOutcome,
|
|
logToolCall,
|
|
logUserPrompt,
|
|
getErrorStatus,
|
|
isWithinRoot,
|
|
isNodeError,
|
|
SubAgentEventType,
|
|
TaskTool,
|
|
Kind,
|
|
TodoWriteTool,
|
|
UserPromptEvent,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import * as acp from './acp.js';
|
|
import { AcpFileSystemService } from './fileSystemService.js';
|
|
import { Readable, Writable } from 'node:stream';
|
|
import type { LoadedSettings } from '../config/settings.js';
|
|
import { SettingScope } from '../config/settings.js';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import { z } from 'zod';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { getErrorMessage } from '../utils/errors.js';
|
|
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 {
|
|
handleSlashCommand,
|
|
getAvailableCommands,
|
|
} from '../nonInteractiveCliCommands.js';
|
|
import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js';
|
|
import { isSlashCommand } from '../ui/utils/commandUtils.js';
|
|
|
|
/**
|
|
* Built-in commands that are allowed in ACP integration mode.
|
|
* Only these commands will be available when using handleSlashCommand
|
|
* or getAvailableCommands in ACP integration.
|
|
*
|
|
* Currently, only "init" is supported because `handleSlashCommand` in
|
|
* nonInteractiveCliCommands.ts only supports handling results where
|
|
* result.type is "submit_prompt". Other result types are either coupled
|
|
* to the UI or cannot send notifications to the client via ACP.
|
|
*
|
|
* If you have a good idea to add support for more commands, PRs are welcome!
|
|
*/
|
|
const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init'];
|
|
|
|
/**
|
|
* Resolves the model to use based on the current configuration.
|
|
*
|
|
* If the model is set to "auto", it will use the flash model if in fallback
|
|
* mode, otherwise it will use the default model.
|
|
*/
|
|
export function resolveModel(model: string, isInFallbackMode: boolean): string {
|
|
if (model === DEFAULT_GEMINI_MODEL_AUTO) {
|
|
return isInFallbackMode ? DEFAULT_GEMINI_FLASH_MODEL : DEFAULT_GEMINI_MODEL;
|
|
}
|
|
return model;
|
|
}
|
|
|
|
export async function runZedIntegration(
|
|
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',
|
|
},
|
|
];
|
|
|
|
return {
|
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
authMethods,
|
|
agentCapabilities: {
|
|
loadSession: false,
|
|
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 sessionId = this.config.getSessionId() || randomUUID();
|
|
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
|
|
|
let isAuthenticated = false;
|
|
if (this.settings.merged.security?.auth?.selectedType) {
|
|
try {
|
|
await config.refreshAuth(
|
|
this.settings.merged.security.auth.selectedType,
|
|
);
|
|
isAuthenticated = true;
|
|
} catch (e) {
|
|
console.error(`Authentication failed: ${e}`);
|
|
}
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
throw acp.RequestError.authRequired();
|
|
}
|
|
|
|
if (this.clientCapabilities?.fs) {
|
|
const acpFileSystemService = new AcpFileSystemService(
|
|
this.client,
|
|
sessionId,
|
|
this.clientCapabilities.fs,
|
|
config.getFileSystemService(),
|
|
);
|
|
config.setFileSystemService(acpFileSystemService);
|
|
}
|
|
|
|
const geminiClient = config.getGeminiClient();
|
|
const chat = await geminiClient.startChat();
|
|
const session = new Session(
|
|
sessionId,
|
|
chat,
|
|
config,
|
|
this.client,
|
|
this.settings,
|
|
);
|
|
this.sessions.set(sessionId, session);
|
|
|
|
// Send available commands update as the first session update
|
|
setTimeout(async () => {
|
|
await session.sendAvailableCommandsUpdate();
|
|
}, 0);
|
|
|
|
return {
|
|
sessionId,
|
|
};
|
|
}
|
|
|
|
async newSessionConfig(
|
|
sessionId: string,
|
|
cwd: string,
|
|
mcpServers: acp.McpServer[],
|
|
): 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 config = await loadCliConfig(
|
|
settings,
|
|
this.extensions,
|
|
new ExtensionEnablementManager(
|
|
ExtensionStorage.getUserExtensionsDir(),
|
|
this.argv.extensions,
|
|
),
|
|
sessionId,
|
|
this.argv,
|
|
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);
|
|
}
|
|
}
|
|
|
|
class Session {
|
|
private pendingPrompt: AbortController | null = null;
|
|
private turn: number = 0;
|
|
|
|
constructor(
|
|
private readonly id: string,
|
|
private readonly chat: GeminiChat,
|
|
private readonly config: Config,
|
|
private readonly client: acp.Client,
|
|
private readonly settings: LoadedSettings,
|
|
) {}
|
|
|
|
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,
|
|
),
|
|
);
|
|
|
|
// 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(
|
|
resolveModel(this.config.getModel(), this.config.isInFallbackMode()),
|
|
{
|
|
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' };
|
|
}
|
|
|
|
private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
|
const params: acp.SessionNotification = {
|
|
sessionId: this.id,
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 =
|
|
fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name;
|
|
|
|
// Declare subAgentToolEventListeners outside try block for cleanup in catch
|
|
let subAgentToolEventListeners: Array<() => void> = [];
|
|
|
|
try {
|
|
const invocation = tool.build(args);
|
|
|
|
// Detect TaskTool and set up sub-agent tool tracking
|
|
const isTaskTool = tool.name === TaskTool.Name;
|
|
|
|
if (isTaskTool && 'eventEmitter' in invocation) {
|
|
// Access eventEmitter from TaskTool invocation
|
|
const taskEventEmitter = (
|
|
invocation as {
|
|
eventEmitter: SubAgentEventEmitter;
|
|
}
|
|
).eventEmitter;
|
|
|
|
// Set up sub-agent tool tracking
|
|
subAgentToolEventListeners = this.setupSubAgentToolTracking(
|
|
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,
|
|
});
|
|
}
|
|
|
|
const params: acp.RequestPermissionRequest = {
|
|
sessionId: this.id,
|
|
options: toPermissionOptions(confirmationDetails),
|
|
toolCall: {
|
|
toolCallId: callId,
|
|
status: 'pending',
|
|
title: invocation.getDescription(),
|
|
content,
|
|
locations: invocation.toolLocations(),
|
|
kind: tool.kind,
|
|
},
|
|
};
|
|
|
|
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);
|
|
|
|
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
|
|
await this.sendUpdate({
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: callId,
|
|
status: 'in_progress',
|
|
title: invocation.getDescription(),
|
|
content: [],
|
|
locations: invocation.toolLocations(),
|
|
kind: tool.kind,
|
|
});
|
|
}
|
|
|
|
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
|
|
|
// Clean up event listeners
|
|
subAgentToolEventListeners.forEach((cleanup) => cleanup());
|
|
|
|
// Handle TodoWriteTool: extract todos and send plan update
|
|
if (isTodoWriteTool) {
|
|
// Extract todos from args (initial state)
|
|
let todos: Array<{
|
|
id: string;
|
|
content: string;
|
|
status: 'pending' | 'in_progress' | 'completed';
|
|
}> = [];
|
|
|
|
if (Array.isArray(args['todos'])) {
|
|
todos = args['todos'] as Array<{
|
|
id: string;
|
|
content: string;
|
|
status: 'pending' | 'in_progress' | 'completed';
|
|
}>;
|
|
}
|
|
|
|
// If returnDisplay has todos (e.g., modified by user), use those instead
|
|
if (
|
|
toolResult.returnDisplay &&
|
|
typeof toolResult.returnDisplay === 'object' &&
|
|
'type' in toolResult.returnDisplay &&
|
|
toolResult.returnDisplay.type === 'todo_list' &&
|
|
'todos' in toolResult.returnDisplay &&
|
|
Array.isArray(toolResult.returnDisplay.todos)
|
|
) {
|
|
todos = toolResult.returnDisplay.todos;
|
|
}
|
|
|
|
// Convert todos to plan entries and send plan update
|
|
if (todos.length > 0 || Array.isArray(args['todos'])) {
|
|
const planEntries = convertTodosToPlanEntries(todos);
|
|
await this.sendUpdate({
|
|
sessionUpdate: 'plan',
|
|
entries: planEntries,
|
|
});
|
|
}
|
|
|
|
// Skip tool_call_update event for TodoWriteTool
|
|
// Still log and return function response for LLM
|
|
} else {
|
|
// Normal tool handling: send tool_call_update
|
|
const content = toToolCallContent(toolResult);
|
|
|
|
await this.sendUpdate({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: callId,
|
|
status: 'completed',
|
|
content: content ? [content] : [],
|
|
});
|
|
}
|
|
|
|
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',
|
|
});
|
|
|
|
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
|
} catch (e) {
|
|
// Ensure cleanup on error
|
|
subAgentToolEventListeners.forEach((cleanup) => cleanup());
|
|
|
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
|
|
await this.sendUpdate({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: callId,
|
|
status: 'failed',
|
|
content: [
|
|
{ type: 'content', content: { type: 'text', text: error.message } },
|
|
],
|
|
});
|
|
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up event listeners to track sub-agent tool calls within a TaskTool execution.
|
|
* Converts subagent tool call events into zedIntegration session updates.
|
|
*
|
|
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
|
|
* @param abortSignal - Signal to abort tracking if parent is cancelled
|
|
* @returns Array of cleanup functions to remove event listeners
|
|
*/
|
|
private setupSubAgentToolTracking(
|
|
eventEmitter: SubAgentEventEmitter,
|
|
abortSignal: AbortSignal,
|
|
): Array<() => void> {
|
|
const cleanupFunctions: Array<() => void> = [];
|
|
const toolRegistry = this.config.getToolRegistry();
|
|
|
|
// Track subagent tool call states
|
|
const subAgentToolStates = new Map<
|
|
string,
|
|
{
|
|
tool?: AnyDeclarativeTool;
|
|
invocation?: AnyToolInvocation;
|
|
args?: Record<string, unknown>;
|
|
}
|
|
>();
|
|
|
|
// Listen for tool call start
|
|
const onToolCall = (...args: unknown[]) => {
|
|
const event = args[0] as SubAgentToolCallEvent;
|
|
if (abortSignal.aborted) return;
|
|
|
|
const subAgentTool = toolRegistry.getTool(event.name);
|
|
let subAgentInvocation: AnyToolInvocation | undefined;
|
|
let toolKind: acp.ToolKind = 'other';
|
|
let locations: acp.ToolCallLocation[] = [];
|
|
|
|
if (subAgentTool) {
|
|
try {
|
|
subAgentInvocation = subAgentTool.build(event.args);
|
|
toolKind = this.mapToolKind(subAgentTool.kind);
|
|
locations = subAgentInvocation.toolLocations().map((loc) => ({
|
|
path: loc.path,
|
|
line: loc.line ?? null,
|
|
}));
|
|
} catch (e) {
|
|
// If building fails, continue with defaults
|
|
console.warn(`Failed to build subagent tool ${event.name}:`, e);
|
|
}
|
|
}
|
|
|
|
// Save state for subsequent updates
|
|
subAgentToolStates.set(event.callId, {
|
|
tool: subAgentTool,
|
|
invocation: subAgentInvocation,
|
|
args: event.args,
|
|
});
|
|
|
|
// Check if this is TodoWriteTool - if so, skip sending tool_call event
|
|
// Plan update will be sent in onToolResult when we have the final state
|
|
if (event.name === TodoWriteTool.Name) {
|
|
return;
|
|
}
|
|
|
|
// Send tool call start update with rawInput
|
|
void this.sendUpdate({
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: event.callId,
|
|
status: 'in_progress',
|
|
title: event.description || event.name,
|
|
content: [],
|
|
locations,
|
|
kind: toolKind,
|
|
rawInput: event.args,
|
|
});
|
|
};
|
|
|
|
// Listen for tool call result
|
|
const onToolResult = (...args: unknown[]) => {
|
|
const event = args[0] as SubAgentToolResultEvent;
|
|
if (abortSignal.aborted) return;
|
|
|
|
const state = subAgentToolStates.get(event.callId);
|
|
|
|
// Check if this is TodoWriteTool - if so, route to plan updates
|
|
if (event.name === TodoWriteTool.Name) {
|
|
let todos:
|
|
| Array<{
|
|
id: string;
|
|
content: string;
|
|
status: 'pending' | 'in_progress' | 'completed';
|
|
}>
|
|
| undefined;
|
|
|
|
// Try to extract todos from resultDisplay first (final state)
|
|
if (event.resultDisplay) {
|
|
try {
|
|
// resultDisplay might be a JSON stringified object
|
|
const parsed =
|
|
typeof event.resultDisplay === 'string'
|
|
? JSON.parse(event.resultDisplay)
|
|
: event.resultDisplay;
|
|
|
|
if (
|
|
typeof parsed === 'object' &&
|
|
parsed !== null &&
|
|
'type' in parsed &&
|
|
parsed.type === 'todo_list' &&
|
|
'todos' in parsed &&
|
|
Array.isArray(parsed.todos)
|
|
) {
|
|
todos = parsed.todos;
|
|
}
|
|
} catch {
|
|
// If parsing fails, ignore - resultDisplay might not be JSON
|
|
}
|
|
}
|
|
|
|
// Fallback to args if resultDisplay doesn't have todos
|
|
if (!todos && state?.args && Array.isArray(state.args['todos'])) {
|
|
todos = state.args['todos'] as Array<{
|
|
id: string;
|
|
content: string;
|
|
status: 'pending' | 'in_progress' | 'completed';
|
|
}>;
|
|
}
|
|
|
|
// Send plan update if we have todos
|
|
if (todos) {
|
|
const planEntries = convertTodosToPlanEntries(todos);
|
|
void this.sendUpdate({
|
|
sessionUpdate: 'plan',
|
|
entries: planEntries,
|
|
});
|
|
}
|
|
|
|
// Skip sending tool_call_update event for TodoWriteTool
|
|
// Clean up state
|
|
subAgentToolStates.delete(event.callId);
|
|
return;
|
|
}
|
|
|
|
let content: acp.ToolCallContent[] = [];
|
|
|
|
// If there's a result display, try to convert to ToolCallContent
|
|
if (event.resultDisplay && state?.invocation) {
|
|
// resultDisplay is typically a string
|
|
if (typeof event.resultDisplay === 'string') {
|
|
content = [
|
|
{
|
|
type: 'content',
|
|
content: {
|
|
type: 'text',
|
|
text: event.resultDisplay,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
// Send tool call completion update
|
|
void this.sendUpdate({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: event.callId,
|
|
status: event.success ? 'completed' : 'failed',
|
|
content: content.length > 0 ? content : [],
|
|
title: state?.invocation?.getDescription() ?? event.name,
|
|
kind: state?.tool ? this.mapToolKind(state.tool.kind) : null,
|
|
locations:
|
|
state?.invocation?.toolLocations().map((loc) => ({
|
|
path: loc.path,
|
|
line: loc.line ?? null,
|
|
})) ?? null,
|
|
rawInput: state?.args,
|
|
});
|
|
|
|
// Clean up state
|
|
subAgentToolStates.delete(event.callId);
|
|
};
|
|
|
|
// Listen for permission requests
|
|
const onToolWaitingApproval = async (...args: unknown[]) => {
|
|
const event = args[0] as SubAgentApprovalRequestEvent;
|
|
if (abortSignal.aborted) return;
|
|
|
|
const state = subAgentToolStates.get(event.callId);
|
|
const content: acp.ToolCallContent[] = [];
|
|
|
|
// Handle different confirmation types
|
|
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 options from confirmation details
|
|
// event.confirmationDetails already contains all fields except onConfirm,
|
|
// which we add here to satisfy the type requirement for toPermissionOptions
|
|
const fullConfirmationDetails = {
|
|
...event.confirmationDetails,
|
|
onConfirm: async () => {
|
|
// This is a placeholder - the actual response is handled via event.respond
|
|
},
|
|
} as unknown as ToolCallConfirmationDetails;
|
|
|
|
const params: acp.RequestPermissionRequest = {
|
|
sessionId: this.id,
|
|
options: toPermissionOptions(fullConfirmationDetails),
|
|
toolCall: {
|
|
toolCallId: event.callId,
|
|
status: 'pending',
|
|
title: event.description || event.name,
|
|
content,
|
|
locations:
|
|
state?.invocation?.toolLocations().map((loc) => ({
|
|
path: loc.path,
|
|
line: loc.line ?? null,
|
|
})) ?? [],
|
|
kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other',
|
|
rawInput: state?.args,
|
|
},
|
|
};
|
|
|
|
try {
|
|
// Request permission from zed 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);
|
|
}
|
|
};
|
|
|
|
// Register event listeners
|
|
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
|
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
|
eventEmitter.on(
|
|
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
|
onToolWaitingApproval,
|
|
);
|
|
|
|
// Return cleanup functions
|
|
cleanupFunctions.push(() => {
|
|
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
|
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
|
eventEmitter.off(
|
|
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
|
onToolWaitingApproval,
|
|
);
|
|
});
|
|
|
|
return cleanupFunctions;
|
|
}
|
|
|
|
/**
|
|
* Maps core Tool Kind enum to ACP ToolKind string literals.
|
|
*
|
|
* @param kind - The core Kind enum value
|
|
* @returns The corresponding ACP ToolKind string literal
|
|
*/
|
|
private mapToolKind(kind: Kind): acp.ToolKind {
|
|
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';
|
|
}
|
|
|
|
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 += ' ';
|
|
}
|
|
}
|
|
if (resolvedSpec) {
|
|
initialQueryText += `@${resolvedSpec}`;
|
|
} else {
|
|
// If not resolved for reading (e.g. lone @ or invalid path that was skipped),
|
|
// add the original @-string back, ensuring spacing if it's not the first element.
|
|
if (
|
|
i > 0 &&
|
|
initialQueryText.length > 0 &&
|
|
!initialQueryText.endsWith(' ') &&
|
|
!chunk.fileData?.fileUri.startsWith(' ')
|
|
) {
|
|
initialQueryText += ' ';
|
|
}
|
|
if (chunk.fileData?.fileUri) {
|
|
initialQueryText += `@${chunk.fileData.fileUri}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
initialQueryText = initialQueryText.trim();
|
|
// Inform user about ignored paths
|
|
if (ignoredPaths.length > 0) {
|
|
const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
|
this.debug(
|
|
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const processedQueryParts: Part[] = [{ text: initialQueryText }];
|
|
|
|
if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) {
|
|
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
|
console.warn('No valid file paths found in @ commands to read.');
|
|
return [{ text: initialQueryText }];
|
|
}
|
|
|
|
if (pathSpecsToRead.length > 0) {
|
|
const toolArgs = {
|
|
paths: pathSpecsToRead,
|
|
respectGitIgnore, // Use configuration setting
|
|
};
|
|
|
|
const callId = `${readManyFilesTool.name}-${Date.now()}`;
|
|
|
|
try {
|
|
const invocation = readManyFilesTool.build(toolArgs);
|
|
|
|
await this.sendUpdate({
|
|
sessionUpdate: 'tool_call',
|
|
toolCallId: callId,
|
|
status: 'in_progress',
|
|
title: invocation.getDescription(),
|
|
content: [],
|
|
locations: invocation.toolLocations(),
|
|
kind: readManyFilesTool.kind,
|
|
});
|
|
|
|
const result = await invocation.execute(abortSignal);
|
|
const content = toToolCallContent(result) || {
|
|
type: 'content',
|
|
content: {
|
|
type: 'text',
|
|
text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
|
},
|
|
};
|
|
await this.sendUpdate({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: callId,
|
|
status: 'completed',
|
|
content: content ? [content] : [],
|
|
});
|
|
if (Array.isArray(result.llmContent)) {
|
|
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
|
processedQueryParts.push({
|
|
text: '\n--- Content from referenced files ---',
|
|
});
|
|
for (const part of result.llmContent) {
|
|
if (typeof part === 'string') {
|
|
const match = fileContentRegex.exec(part);
|
|
if (match) {
|
|
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
|
|
const fileActualContent = match[2].trim();
|
|
processedQueryParts.push({
|
|
text: `\nContent from @${filePathSpecInContent}:\n`,
|
|
});
|
|
processedQueryParts.push({ text: fileActualContent });
|
|
} else {
|
|
processedQueryParts.push({ text: part });
|
|
}
|
|
} else {
|
|
// part is a Part object.
|
|
processedQueryParts.push(part);
|
|
}
|
|
}
|
|
} else {
|
|
console.warn(
|
|
'read_many_files tool returned no content or empty content.',
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
await this.sendUpdate({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: callId,
|
|
status: 'failed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
content: {
|
|
type: 'text',
|
|
text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (embeddedContext.length > 0) {
|
|
processedQueryParts.push({
|
|
text: '\n--- Content from referenced context ---',
|
|
});
|
|
|
|
for (const contextPart of embeddedContext) {
|
|
processedQueryParts.push({
|
|
text: `\nContent from @${contextPart.uri}:\n`,
|
|
});
|
|
if ('text' in contextPart) {
|
|
processedQueryParts.push({
|
|
text: contextPart.text,
|
|
});
|
|
} else {
|
|
processedQueryParts.push({
|
|
inlineData: {
|
|
mimeType: contextPart.mimeType ?? 'application/octet-stream',
|
|
data: contextPart.blob,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return processedQueryParts;
|
|
}
|
|
|
|
debug(msg: string) {
|
|
if (this.config.getDebugMode()) {
|
|
console.warn(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts todo items to plan entries format for zed integration.
|
|
* Maps todo status to plan status and assigns a default priority.
|
|
*
|
|
* @param todos - Array of todo items with id, content, and status
|
|
* @returns Array of plan entries with content, priority, and status
|
|
*/
|
|
function convertTodosToPlanEntries(
|
|
todos: Array<{
|
|
id: string;
|
|
content: string;
|
|
status: 'pending' | 'in_progress' | 'completed';
|
|
}>,
|
|
): acp.PlanEntry[] {
|
|
return todos.map((todo) => ({
|
|
content: todo.content,
|
|
priority: 'medium' as const, // Default priority since todos don't have priority
|
|
status: todo.status,
|
|
}));
|
|
}
|
|
|
|
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
|
if (toolResult.error?.message) {
|
|
throw new Error(toolResult.error.message);
|
|
}
|
|
|
|
if (toolResult.returnDisplay) {
|
|
if (typeof toolResult.returnDisplay === 'string') {
|
|
return {
|
|
type: 'content',
|
|
content: { type: 'text', text: toolResult.returnDisplay },
|
|
};
|
|
} else if (
|
|
'type' in toolResult.returnDisplay &&
|
|
toolResult.returnDisplay.type === 'plan_summary'
|
|
) {
|
|
const planDisplay = toolResult.returnDisplay;
|
|
const planText = `${planDisplay.message}\n\n${planDisplay.plan}`;
|
|
return {
|
|
type: 'content',
|
|
content: { type: 'text', text: planText },
|
|
};
|
|
} else {
|
|
if ('fileName' in toolResult.returnDisplay) {
|
|
return {
|
|
type: 'diff',
|
|
path: toolResult.returnDisplay.fileName,
|
|
oldText: toolResult.returnDisplay.originalContent,
|
|
newText: toolResult.returnDisplay.newContent,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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: `Always Allow Plans`,
|
|
kind: 'allow_always',
|
|
},
|
|
...basicPermissionOptions,
|
|
];
|
|
default: {
|
|
const unreachable: never = confirmation;
|
|
throw new Error(`Unexpected: ${unreachable}`);
|
|
}
|
|
}
|
|
}
|