From 5de784d9a3655a2719809025f543d1e48c266a89 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 16:03:28 +0800 Subject: [PATCH 1/9] feat(app): connect local AI tools through MCP --- .../electro-bridge/ipc/local-ai-context.ts | 83 +++++ packages/app/src/electron/ai/agent-tools.ts | 290 ++++++++++++++++++ .../app/src/electron/ai/provider-adapter.ts | 7 + .../src/electron/ai/providers/claude-code.ts | 51 ++- .../src/electron/ai/providers/codex-cli.ts | 74 ++++- packages/app/src/electron/ai/runtime.ts | 191 +++++++++++- packages/app/src/electron/main.ts | 19 +- packages/app/src/electron/mcp/hub.ts | 4 +- packages/app/src/electron/mcp/index.ts | 2 +- packages/app/src/electron/tools/index.ts | 18 ++ .../chat/input/ask-user-input-overlay.tsx | 137 +++++---- .../components/chat/message/chat-content.tsx | 5 +- .../renderer/libs/hooks/use-local-ai-chat.ts | 36 ++- .../renderer/libs/stores/user-input-store.ts | 151 ++++----- packages/app/src/shared/types/local-ai.ts | 27 ++ 15 files changed, 921 insertions(+), 174 deletions(-) create mode 100644 packages/app/src/electron/ai/agent-tools.ts diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts index f71541b5..698b2703 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -1,6 +1,7 @@ import type { ILocalAIAPI, LocalAIChatRequest, + LocalAIInteractionResponse, LocalAIProviderStatus, LocalAIResult, LocalAIRuntimeService, @@ -23,6 +24,7 @@ export const LOCAL_AI_CHANNELS = { GET_PROVIDER_STATUS: "local-ai:get-provider-status", START_CHAT: "local-ai:start-chat", ABORT: "local-ai:abort", + RESPOND_INTERACTION: "local-ai:respond-interaction", EVENT: "local-ai:event", } as const; @@ -45,6 +47,7 @@ const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; const ALLOWED_PROVIDER_IDS = new Set(["claude-code", "codex-cli"]); const MAX_MESSAGE_CHARS = 200_000; const MAX_REQUEST_CHARS = 1_000_000; +const MAX_INTERACTION_RESPONSE_CHARS = 20_000; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -130,6 +133,28 @@ function validateRequest(request: unknown): request is LocalAIChatRequest { }); } +function validateInteractionResponse( + response: unknown, +): response is LocalAIInteractionResponse { + if (!isRecord(response)) return false; + + const keys = Object.keys(response); + if ( + keys.length === 0 || + keys.some((key) => key !== "approved" && key !== "value") + ) { + return false; + } + + return ( + (response.approved === undefined || + typeof response.approved === "boolean") && + (response.value === undefined || + (typeof response.value === "string" && + response.value.length <= MAX_INTERACTION_RESPONSE_CHARS)) + ); +} + function failure(error: unknown): LocalAIResult { return { success: false, error: serializeLocalAIError(error) }; } @@ -354,6 +379,57 @@ export function setupLocalAIIPC( }, ); + mainIPC.handle( + LOCAL_AI_CHANNELS.RESPOND_INTERACTION, + async ( + event, + requestId: unknown, + interactionId: unknown, + response: unknown, + ): Promise> => { + if (!ensureSender(event)) { + return failure( + createError("IPC sender is not allowed", "LOCAL_AI_FORBIDDEN"), + ); + } + if (!options.runtime) return failure(runtimeUnavailable()); + if ( + typeof requestId !== "string" || + !REQUEST_ID_PATTERN.test(requestId) || + typeof interactionId !== "string" || + !REQUEST_ID_PATTERN.test(interactionId) || + !validateInteractionResponse(response) + ) { + return failure( + createError( + "Invalid local AI interaction response", + "LOCAL_AI_INVALID_REQUEST", + ), + ); + } + + const active = activeRequests.get(requestId); + if (!active || active.sender !== event.sender) { + return { success: true, data: { accepted: false } }; + } + + try { + return { + success: true, + data: { + accepted: await options.runtime.respondToInteraction( + requestId, + interactionId, + response, + ), + }, + }; + } catch (error) { + return failure(error); + } + }, + ); + mainIPC.handle( LOCAL_AI_CHANNELS.ABORT, async ( @@ -433,6 +509,13 @@ export function createLocalAIAPI( rendererIPC.invoke(LOCAL_AI_CHANNELS.START_CHAT, request), abort: (requestId) => rendererIPC.invoke(LOCAL_AI_CHANNELS.ABORT, requestId), + respondToInteraction: (requestId, interactionId, response) => + rendererIPC.invoke( + LOCAL_AI_CHANNELS.RESPOND_INTERACTION, + requestId, + interactionId, + response, + ), onEvent: (requestId, callback) => { const handler = (_event: unknown, event: LocalAIStreamEvent) => { if (event.requestId === requestId) callback(event); diff --git a/packages/app/src/electron/ai/agent-tools.ts b/packages/app/src/electron/ai/agent-tools.ts new file mode 100644 index 00000000..388b84df --- /dev/null +++ b/packages/app/src/electron/ai/agent-tools.ts @@ -0,0 +1,290 @@ +import type { ToolDefinition } from "@/shared/types/mcp"; +import { z, type ZodRawShape, type ZodTypeAny } from "zod"; + +export interface AgentToolGroup { + serverName: string; + tools: ToolDefinition[]; +} + +export interface AgentToolInteraction { + kind: "approval" | "input"; + name: string; + prompt: string; + input?: unknown; + options?: string[]; +} + +export interface AgentTool { + name: string; + qualifiedName: string; + description: string; + inputSchema: Record; + inputShape: ZodRawShape; + inputValidator: ZodTypeAny; + execute(input: Record): Promise; +} + +export interface AgentToolCatalogOptions { + groups: AgentToolGroup[]; + executeTool( + serverName: string, + toolName: string, + input: Record, + ): Promise; + requestInteraction(interaction: AgentToolInteraction): Promise<{ + approved?: boolean; + value?: string; + }>; +} + +const BUILTIN_SERVER = "builtin"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toolSchema(tool: ToolDefinition): Record { + const schema = tool.inputSchema ?? tool.parameters; + return isRecord(schema) + ? schema + : { type: "object", properties: {}, additionalProperties: true }; +} + +function zodForSchema(schema: unknown): ZodTypeAny { + if (!isRecord(schema)) return z.unknown(); + + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + const values = schema.enum.filter( + (value): value is string | number | boolean | null => + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + ); + if (values.length === 0) return z.unknown(); + const literals = values.map((value) => z.literal(value)); + return literals.length === 1 + ? literals[0] + : z.union( + literals as [ + (typeof literals)[number], + (typeof literals)[number], + ...(typeof literals)[number][], + ], + ); + } + + if ( + "const" in schema && + (schema.const === null || + typeof schema.const === "string" || + typeof schema.const === "number" || + typeof schema.const === "boolean") + ) { + return z.literal(schema.const); + } + + const alternatives = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : undefined; + if (alternatives?.length) { + const variants = alternatives.map(zodForSchema); + return variants.length === 1 + ? variants[0] + : z.union( + variants as [ + (typeof variants)[number], + (typeof variants)[number], + ...(typeof variants)[number][], + ], + ); + } + + switch (schema.type) { + case "string": { + let value = z.string(); + if (typeof schema.minLength === "number") + value = value.min(schema.minLength); + if (typeof schema.maxLength === "number") + value = value.max(schema.maxLength); + if (typeof schema.pattern === "string") { + try { + value = value.regex(new RegExp(schema.pattern)); + } catch { + return value; + } + } + if (schema.format === "uri" || schema.format === "url") + return value.url(); + return value; + } + case "integer": { + let value = z.number().int(); + if (typeof schema.minimum === "number") value = value.min(schema.minimum); + if (typeof schema.maximum === "number") value = value.max(schema.maximum); + return value; + } + case "number": { + let value = z.number(); + if (typeof schema.minimum === "number") value = value.min(schema.minimum); + if (typeof schema.maximum === "number") value = value.max(schema.maximum); + return value; + } + case "boolean": + return z.boolean(); + case "array": { + let value = z.array(zodForSchema(schema.items)); + if (typeof schema.minItems === "number") + value = value.min(schema.minItems); + if (typeof schema.maxItems === "number") + value = value.max(schema.maxItems); + return value; + } + case "object": + return z.object(shapeForSchema(schema)).passthrough(); + default: + return z.unknown(); + } +} + +export function shapeForSchema(schema: unknown): ZodRawShape { + if (!isRecord(schema) || !isRecord(schema.properties)) return {}; + + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter( + (property): property is string => typeof property === "string", + ) + : [], + ); + + return Object.fromEntries( + Object.entries(schema.properties).map(([name, propertySchema]) => { + const validator = zodForSchema(propertySchema); + return [name, required.has(name) ? validator : validator.optional()]; + }), + ); +} + +function slug(value: string): string { + return ( + value + .normalize("NFKD") + .replace(/[^A-Za-z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase() + .slice(0, 48) || "tool" + ); +} + +function stableSuffix(value: string): string { + let hash = 2166136261; + for (const character of value) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function requiresApproval(serverName: string, tool: ToolDefinition): boolean { + if (serverName.toLowerCase() !== BUILTIN_SERVER) return true; + return ( + tool.annotations?.readOnlyHint !== true || + tool.annotations?.openWorldHint === true + ); +} + +function interactionPrompt( + qualifiedName: string, + input: Record, +): string { + return `Allow ${qualifiedName} to run with these arguments?\n${JSON.stringify( + input, + null, + 2, + )}`; +} + +export function createAgentToolCatalog( + options: AgentToolCatalogOptions, +): AgentTool[] { + const aliases = new Set(); + + return options.groups.flatMap((group) => + group.tools.map((definition) => { + const qualifiedName = `${group.serverName}:${definition.name}`; + const baseAlias = `${slug(group.serverName)}__${slug(definition.name)}`; + const name = aliases.has(baseAlias) + ? `${baseAlias}__${stableSuffix(qualifiedName)}` + : baseAlias; + aliases.add(name); + + const inputSchema = toolSchema(definition); + const inputShape = shapeForSchema(inputSchema); + const inputValidator = z.object(inputShape).passthrough(); + const description = [ + `Fully qualified tool: ${qualifiedName}.`, + definition.description?.trim() || "No description provided.", + "Returns the underlying tool result or an actionable execution error.", + ].join(" "); + + return { + name, + qualifiedName, + description, + inputSchema, + inputShape, + inputValidator, + execute: async (input: Record) => { + const parsed = inputValidator.parse(input); + + if ( + group.serverName.toLowerCase() === BUILTIN_SERVER && + definition.name === "ask_user_input" + ) { + const question = + typeof parsed.question === "string" + ? parsed.question + : "What should I do next?"; + const interaction = await options.requestInteraction({ + kind: "input", + name: qualifiedName, + prompt: question, + input: parsed, + options: Array.isArray(parsed.options) + ? parsed.options.filter( + (option): option is string => typeof option === "string", + ) + : [], + }); + if (typeof interaction.value !== "string") { + throw new Error(`User cancelled ${qualifiedName}.`); + } + return { + success: true, + userSelection: interaction.value, + message: `User selected: ${interaction.value}`, + }; + } + + if (requiresApproval(group.serverName, definition)) { + const interaction = await options.requestInteraction({ + kind: "approval", + name: qualifiedName, + prompt: interactionPrompt(qualifiedName, parsed), + input: parsed, + options: ["Allow once", "Deny"], + }); + if (interaction.approved !== true) { + throw new Error(`User denied ${qualifiedName}.`); + } + } + + return options.executeTool(group.serverName, definition.name, parsed); + }, + } satisfies AgentTool; + }), + ); +} diff --git a/packages/app/src/electron/ai/provider-adapter.ts b/packages/app/src/electron/ai/provider-adapter.ts index 97e2a1d9..2bd5a646 100644 --- a/packages/app/src/electron/ai/provider-adapter.ts +++ b/packages/app/src/electron/ai/provider-adapter.ts @@ -1,5 +1,6 @@ import type { LocalAIChatRequest } from "@/shared/types/local-ai"; import type { LanguageModel } from "ai"; +import type { AgentTool, AgentToolInteraction } from "./agent-tools"; import type { LocalAiProviderId, LocalAiProviderStatus } from "./types"; export function resolveLocalModelId( @@ -16,6 +17,12 @@ export interface LocalAiProviderAdapter { createModel( request: LocalAIChatRequest, status: LocalAiProviderStatus, + context: { + tools: AgentTool[]; + requestInteraction( + interaction: AgentToolInteraction, + ): Promise<{ approved?: boolean; value?: string }>; + }, ): Promise; dispose(): Promise; } diff --git a/packages/app/src/electron/ai/providers/claude-code.ts b/packages/app/src/electron/ai/providers/claude-code.ts index 7f640dcb..b4a566bf 100644 --- a/packages/app/src/electron/ai/providers/claude-code.ts +++ b/packages/app/src/electron/ai/providers/claude-code.ts @@ -1,6 +1,10 @@ import type { LocalAIChatRequest } from "@/shared/types/local-ai"; import type { LanguageModel } from "ai"; -import { createClaudeCode } from "ai-sdk-provider-claude-code"; +import { + createClaudeCode, + createSdkMcpServer, + tool as createClaudeTool, +} from "ai-sdk-provider-claude-code"; import { loadClaudeEnvironment } from "../claude-environment"; import { probeCliProvider } from "../cli-probe"; import { @@ -20,7 +24,7 @@ export class ClaudeCodeAdapter implements LocalAiProviderAdapter { // Tool wiring is a separate, approval-aware integration. Text chat starts // without implicitly granting local access. tools: [], - maxTurns: 1, + maxTurns: 12, logger: false, // Some local Claude subscriptions store their auth/base URL in the // user settings env block. Load only those environment entries without @@ -38,12 +42,55 @@ export class ClaudeCodeAdapter implements LocalAiProviderAdapter { async createModel( request: LocalAIChatRequest, status: LocalAiProviderStatus, + context: Parameters[2], ): Promise { + const tools = context.tools.map((definition) => + createClaudeTool( + definition.name, + definition.description, + definition.inputShape, + async (input) => { + try { + const output = await definition.execute(input); + return { + content: [ + { + type: "text" as const, + text: + typeof output === "string" + ? output + : JSON.stringify(output), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + ), + ); + const mcpServer = + tools.length > 0 + ? createSdkMcpServer({ name: "convera", tools }) + : undefined; + return this.provider( resolveLocalModelId(request.modelId, status.defaultModel), { pathToClaudeCodeExecutable: status.executablePath, cwd: request.options?.cwd, + mcpServers: mcpServer ? { convera: mcpServer } : undefined, + allowedTools: context.tools.map( + (definition) => `mcp__convera__${definition.name}`, + ), }, ); } diff --git a/packages/app/src/electron/ai/providers/codex-cli.ts b/packages/app/src/electron/ai/providers/codex-cli.ts index d10bfdf8..d276c521 100644 --- a/packages/app/src/electron/ai/providers/codex-cli.ts +++ b/packages/app/src/electron/ai/providers/codex-cli.ts @@ -1,6 +1,9 @@ import type { LocalAIChatRequest } from "@/shared/types/local-ai"; import type { LanguageModel } from "ai"; -import type { CodexAppServerProvider } from "ai-sdk-provider-codex-cli"; +import type { + CodexAppServerProvider, + CodexAppServerRequestHandlers, +} from "ai-sdk-provider-codex-cli"; import type { ZodEffects, ZodTypeAny } from "zod"; import { probeCliProvider } from "../cli-probe"; import { @@ -50,13 +53,80 @@ export class CodexCliAdapter implements LocalAiProviderAdapter { async createModel( request: LocalAIChatRequest, status: LocalAiProviderStatus, + context: Parameters[2], ): Promise { await this.ensureProvider(status.executablePath); + const { createSdkMcpServer, tool } = + await importCodexProviderWithZod3Compatibility(); + const tools = context.tools.map((definition) => + tool({ + name: definition.name, + description: definition.description, + parameters: definition.inputValidator, + execute: async (input) => + definition.execute(input as Record), + }), + ); + const mcpServer = + tools.length > 0 + ? createSdkMcpServer({ name: "convera", tools }) + : undefined; + const requestApproval = async ( + name: string, + prompt: string, + input: unknown, + ) => + ( + await context.requestInteraction({ + kind: "approval", + name, + prompt, + input, + options: ["Allow once", "Deny"], + }) + ).approved === true; + const serverRequests: CodexAppServerRequestHandlers = { + onCommandExecutionApproval: async ({ params }) => ({ + decision: (await requestApproval( + "codex:command_execution", + `Allow Codex to execute this command?\n${params.command ?? ""}`, + params, + )) + ? "accept" + : "decline", + }), + onFileChangeApproval: async ({ params }) => ({ + decision: (await requestApproval( + "codex:file_change", + "Allow Codex to modify files in the current workspace?", + params, + )) + ? "accept" + : "decline", + }), + onSkillApproval: async () => ({ decision: "decline" }), + onMcpElicitation: async ({ params }) => ({ + action: + params._meta?.codex_approval_kind === "mcp_tool_call" + ? "accept" + : "decline", + content: null, + }), + }; + const cwd = request.options?.cwd; return this.provider!( resolveLocalModelId(request.modelId, status.defaultModel), { - cwd: request.options?.cwd, + cwd, + mcpServers: mcpServer ? { convera: mcpServer } : undefined, + serverRequests, + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: cwd ? [cwd] : [], + networkAccess: false, + }, }, ); } diff --git a/packages/app/src/electron/ai/runtime.ts b/packages/app/src/electron/ai/runtime.ts index c3eea048..cb1eb9bc 100644 --- a/packages/app/src/electron/ai/runtime.ts +++ b/packages/app/src/electron/ai/runtime.ts @@ -1,6 +1,7 @@ import type { LocalAIChatRequest, LocalAIFinishReason, + LocalAIInteractionResponse, LocalAIProviderAvailability, LocalAIProviderStatus, LocalAIRuntimeService, @@ -9,6 +10,13 @@ import type { LocalAIUsage, } from "@/shared/types/local-ai"; import { streamText, type LanguageModel, type ModelMessage } from "ai"; +import { randomUUID } from "node:crypto"; +import { + createAgentToolCatalog, + type AgentTool, + type AgentToolGroup, + type AgentToolInteraction, +} from "./agent-tools"; import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "./provider-descriptors"; import type { LocalAiProviderAdapter } from "./provider-adapter"; import { ClaudeCodeAdapter } from "./providers/claude-code"; @@ -36,6 +44,16 @@ export type RuntimeStreamInvoker = ( options: RuntimeStreamOptions, ) => RuntimeStreamResult; +export type AgentToolGroupProvider = () => + | AgentToolGroup[] + | Promise; + +export type AgentToolExecutor = ( + serverName: string, + toolName: string, + input: Record, +) => Promise; + const defaultStreamInvoker: RuntimeStreamInvoker = (options) => streamText(options) as unknown as RuntimeStreamResult; @@ -168,6 +186,15 @@ function stringField( return undefined; } +interface PendingInteraction { + requestId: string; + resolve(response: LocalAIInteractionResponse): void; + reject(error: Error): void; + timeout: ReturnType; + abortSignal: AbortSignal; + onAbort(): void; +} + export class LocalAiRuntime implements LocalAIRuntimeService { private readonly adapters = new Map< LocalAiProviderId, @@ -176,12 +203,17 @@ export class LocalAiRuntime implements LocalAIRuntimeService { private readonly activeRequests = new Map(); private readonly streamInvoker: RuntimeStreamInvoker; private readonly workingDirectory: string; + private readonly getToolGroups: AgentToolGroupProvider; + private readonly executeTool: AgentToolExecutor; + private readonly pendingInteractions = new Map(); constructor( options: { adapters?: LocalAiProviderAdapter[]; streamInvoker?: RuntimeStreamInvoker; workingDirectory?: string; + getToolGroups?: AgentToolGroupProvider; + executeTool?: AgentToolExecutor; } = {}, ) { const adapters = options.adapters ?? [ @@ -190,6 +222,14 @@ export class LocalAiRuntime implements LocalAIRuntimeService { ]; this.streamInvoker = options.streamInvoker ?? defaultStreamInvoker; this.workingDirectory = options.workingDirectory ?? process.cwd(); + this.getToolGroups = options.getToolGroups ?? (() => []); + this.executeTool = + options.executeTool ?? + (async (serverName, toolName) => { + throw new Error( + `Tool executor is unavailable for ${serverName}:${toolName}.`, + ); + }); for (const adapter of adapters) { this.adapters.set(adapter.id, adapter); @@ -312,14 +352,35 @@ export class LocalAiRuntime implements LocalAIRuntimeService { cwd: this.workingDirectory, }, }; - const model = await adapter.createModel(trustedRequest, probeStatus); + const requestInteraction = (interaction: AgentToolInteraction) => + this.requestInteraction( + request.requestId, + interaction, + controller.signal, + emit, + ); + const tools = createAgentToolCatalog({ + groups: await this.getToolGroups(), + executeTool: this.executeTool, + requestInteraction, + }); + const model = await adapter.createModel(trustedRequest, probeStatus, { + tools, + requestInteraction, + }); const result = this.streamInvoker({ model, messages: toMessages(request), abortSignal: controller.signal, maxOutputTokens: request.options?.maxOutputTokens, }); - await this.forwardStream(request.requestId, result, controller, emit); + await this.forwardStream( + request.requestId, + result, + controller, + emit, + tools, + ); } catch (error) { if (controller.signal.aborted) { emit({ @@ -331,6 +392,12 @@ export class LocalAiRuntime implements LocalAIRuntimeService { this.emitFailure(request.requestId, emit, error); } } finally { + this.rejectRequestInteractions( + request.requestId, + new Error( + "Local AI request finished before the interaction completed.", + ), + ); this.activeRequests.delete(request.requestId); } } @@ -345,11 +412,28 @@ export class LocalAiRuntime implements LocalAIRuntimeService { return true; } + respondToInteraction( + requestId: string, + interactionId: string, + response: LocalAIInteractionResponse, + ): boolean { + const pending = this.pendingInteractions.get(interactionId); + if (!pending || pending.requestId !== requestId) return false; + + this.releaseInteraction(interactionId, pending); + pending.resolve(response); + return true; + } + async dispose(): Promise { for (const controller of this.activeRequests.values()) { controller.abort(); } this.activeRequests.clear(); + for (const [interactionId, pending] of this.pendingInteractions) { + this.releaseInteraction(interactionId, pending); + pending.reject(new Error("Local AI runtime disposed.")); + } await Promise.all( [...this.adapters.values()].map((adapter) => adapter.dispose()), @@ -361,7 +445,11 @@ export class LocalAiRuntime implements LocalAIRuntimeService { result: RuntimeStreamResult, controller: AbortController, emit: (event: LocalAIStreamEvent) => void, + tools: AgentTool[], ): Promise { + const eventNames = new Map( + tools.map((tool) => [tool.name, tool.qualifiedName]), + ); const toolNames = new Map(); const toolInputs = new Map(); let terminalEventEmitted = false; @@ -377,7 +465,10 @@ export class LocalAiRuntime implements LocalAIRuntimeService { } case "tool-input-start": { const toolCallId = stringField(part, "id") ?? "unknown"; - const name = stringField(part, "toolName") ?? "unknown"; + const name = this.toolEventName( + stringField(part, "toolName") ?? "unknown", + eventNames, + ); toolNames.set(toolCallId, name); toolInputs.set(toolCallId, ""); emit({ @@ -407,8 +498,11 @@ export class LocalAiRuntime implements LocalAIRuntimeService { case "tool-call": { const toolCallId = stringField(part, "toolCallId", "id") ?? "unknown"; const name = - stringField(part, "toolName") ?? - toolNames.get(toolCallId) ?? + this.toolEventName( + stringField(part, "toolName") ?? "", + eventNames, + ) || + toolNames.get(toolCallId) || "unknown"; emit({ type: "tool", @@ -427,8 +521,11 @@ export class LocalAiRuntime implements LocalAIRuntimeService { requestId, toolCallId, name: - stringField(part, "toolName") ?? - toolNames.get(toolCallId) ?? + this.toolEventName( + stringField(part, "toolName") ?? "", + eventNames, + ) || + toolNames.get(toolCallId) || "unknown", state: "output-available", output: part.output, @@ -442,8 +539,11 @@ export class LocalAiRuntime implements LocalAIRuntimeService { requestId, toolCallId, name: - stringField(part, "toolName") ?? - toolNames.get(toolCallId) ?? + this.toolEventName( + stringField(part, "toolName") ?? "", + eventNames, + ) || + toolNames.get(toolCallId) || "unknown", state: "output-error", error: serializeLocalAiError(part.error), @@ -503,4 +603,77 @@ export class LocalAiRuntime implements LocalAIRuntimeService { }); emit({ type: "finish", requestId, finishReason: "error" }); } + + private requestInteraction( + requestId: string, + interaction: AgentToolInteraction, + abortSignal: AbortSignal, + emit: (event: LocalAIStreamEvent) => void, + ): Promise { + const interactionId = randomUUID(); + + return new Promise((resolve, reject) => { + const onAbort = () => { + const pending = this.pendingInteractions.get(interactionId); + if (!pending) return; + this.releaseInteraction(interactionId, pending); + reject(new Error(`Interaction cancelled for ${interaction.name}.`)); + }; + const timeout = setTimeout(() => { + const pending = this.pendingInteractions.get(interactionId); + if (!pending) return; + this.releaseInteraction(interactionId, pending); + reject(new Error(`Interaction timed out for ${interaction.name}.`)); + }, 5 * 60_000); + + this.pendingInteractions.set(interactionId, { + requestId, + resolve, + reject, + timeout, + abortSignal, + onAbort, + }); + abortSignal.addEventListener("abort", onAbort, { once: true }); + emit({ + type: "interaction", + requestId, + interactionId, + ...interaction, + }); + }); + } + + private rejectRequestInteractions(requestId: string, error: Error): void { + for (const [interactionId, pending] of this.pendingInteractions) { + if (pending.requestId !== requestId) continue; + this.releaseInteraction(interactionId, pending); + pending.reject(error); + } + } + + private releaseInteraction( + interactionId: string, + pending: PendingInteraction, + ): void { + clearTimeout(pending.timeout); + pending.abortSignal.removeEventListener("abort", pending.onAbort); + this.pendingInteractions.delete(interactionId); + } + + private toolEventName( + providerName: string, + eventNames: Map, + ): string { + for (const [alias, qualifiedName] of eventNames) { + if ( + providerName === alias || + providerName.endsWith(`__${alias}`) || + providerName.endsWith(`.${alias}`) + ) { + return qualifiedName; + } + } + return providerName; + } } diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts index 61754117..e623b600 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -1,7 +1,13 @@ import { app, BrowserWindow, globalShortcut } from "electron"; import { getLogger, initializeLogger } from "@/electron/logger"; -import { getMCPHub, initializeMCPHub } from "@/electron/mcp"; +import { + callTool, + getAllTools, + getMCPHub, + initializeMCPHub, + mcpToolCall, +} from "@/electron/mcp"; import { LocalAiRuntime } from "@/electron/ai"; import { getCurrentShortcut } from "@/electro-bridge/ipc/ipc-handlers"; @@ -20,7 +26,16 @@ import { // Initialize logger for main process const logger = getLogger("main-process"); -const localAIRuntime = new LocalAiRuntime(); +const localAIRuntime = new LocalAiRuntime({ + getToolGroups: async () => { + await initializeMCPHub(); + return getAllTools(); + }, + executeTool: (serverName, toolName, input) => + serverName.toLowerCase() === "builtin" + ? mcpToolCall(toolName, input) + : callTool(serverName, toolName, input), +}); function registerGlobalShortcuts() { globalShortcut.unregisterAll(); diff --git a/packages/app/src/electron/mcp/hub.ts b/packages/app/src/electron/mcp/hub.ts index 98f4b22c..4e2b8ad7 100644 --- a/packages/app/src/electron/mcp/hub.ts +++ b/packages/app/src/electron/mcp/hub.ts @@ -9,7 +9,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { zodToJsonSchema } from "zod-to-json-schema"; -import { BUILTIN_TOOLS_REGISTRY } from "../tools"; +import { BUILTIN_TOOL_ANNOTATIONS, BUILTIN_TOOLS_REGISTRY } from "../tools"; import { MCPConnection } from "./connection"; /** @@ -544,6 +544,8 @@ export class MCPHub extends EventEmitter { description: tool.description || "", inputSchema: this.zodSchemaToJsonSchema(tool.inputSchema), parameters: this.zodSchemaToJsonSchema(tool.inputSchema), + annotations: + BUILTIN_TOOL_ANNOTATIONS[name as keyof typeof BUILTIN_TOOL_ANNOTATIONS], })); } diff --git a/packages/app/src/electron/mcp/index.ts b/packages/app/src/electron/mcp/index.ts index 1eb2dd11..820cdfab 100644 --- a/packages/app/src/electron/mcp/index.ts +++ b/packages/app/src/electron/mcp/index.ts @@ -113,7 +113,7 @@ export function getAllTools(): Array<{ } const builtinTools = { - serverName: "Builtin", + serverName: "builtin", tools: globalHub.getBuiltinToolsDefinition(), }; diff --git a/packages/app/src/electron/tools/index.ts b/packages/app/src/electron/tools/index.ts index 369d71e8..40e1051c 100644 --- a/packages/app/src/electron/tools/index.ts +++ b/packages/app/src/electron/tools/index.ts @@ -23,3 +23,21 @@ export const BUILTIN_TOOLS_REGISTRY = { execute_command: executeCommand, web_fetch: webFetch, } as const; + +export const BUILTIN_TOOL_ANNOTATIONS = { + ask_user_input: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + execute_command: { + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }, + web_fetch: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, +} as const; diff --git a/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx b/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx index 64d1dfa8..d8086cc0 100644 --- a/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx +++ b/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx @@ -2,97 +2,104 @@ import { useUserInputStore } from "@/renderer/libs/stores/user-input-store"; import { Send } from "lucide-react"; import React, { useState } from "react"; -/** - * Overlay component that replaces ChatInput when waiting for user input. - * Only rendered when there's a pending input request (controlled by ChatInputContainer). - */ export function AskUserInputOverlay() { const [customInput, setCustomInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - + const [responseError, setResponseError] = useState(); const { pendingInputs, resolvePendingInput } = useUserInputStore(); + const pending = pendingInputs.values().next().value; - // Get the first pending input (there should typically only be one) - const pendingEntries = Array.from(pendingInputs.entries()); - const pending = pendingEntries.length > 0 ? pendingEntries[0][1] : null; - - // Safety check - shouldn't happen since parent controls rendering if (!pending) return null; - const { toolCallId, question, options } = pending; - - // Handle option selection - const handleOptionSelect = (option: string) => { + const submit = async (value: string) => { if (isSubmitting) return; setIsSubmitting(true); - resolvePendingInput(toolCallId, option); - setTimeout(() => { - setIsSubmitting(false); + setResponseError(undefined); + try { + await resolvePendingInput(pending.interactionId, value); setCustomInput(""); - }, 100); - }; - - // Handle custom input submission - const handleCustomSubmit = () => { - if (isSubmitting || !customInput.trim()) return; - setIsSubmitting(true); - resolvePendingInput(toolCallId, customInput.trim()); - setTimeout(() => { + } catch (error) { + setResponseError( + error instanceof Error ? error.message : "Could not send response.", + ); + } finally { setIsSubmitting(false); - setCustomInput(""); - }, 100); + } }; + const customInputEnabled = pending.kind === "input"; + const details = + pending.kind === "approval" && pending.input !== undefined + ? JSON.stringify(pending.input, null, 2) + : undefined; + return ( -
- {/* Question */} -
- {question} +
+
+ {pending.kind === "approval" + ? `Approval required ยท ${pending.name}` + : pending.name} +
+
+ {pending.question}
- {/* Options */} -
- {options.map((option, index) => ( + {details && ( +
+          {details}
+        
+ )} + +
+ {pending.options.map((option) => ( ))}
- {/* Custom input */} -
-
- setCustomInput(e.target.value)} - disabled={isSubmitting} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleCustomSubmit(); - } - }} - className="flex-1 h-6 text-sm bg-transparent border-0 outline-none text-foreground placeholder:text-muted-foreground disabled:opacity-50" - autoFocus - /> - {customInput.trim() && ( -
+ )} + + {customInputEnabled && ( +
+
+ setCustomInput(event.target.value)} disabled={isSubmitting} - className="p-1 text-primary hover:bg-primary/10 rounded disabled:opacity-50" - > - - - )} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (customInput.trim()) void submit(customInput.trim()); + } + }} + className="h-6 flex-1 border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50" + autoFocus + /> + {customInput.trim() && ( + + )} +
-
+ )}
); } diff --git a/packages/app/src/renderer/components/chat/message/chat-content.tsx b/packages/app/src/renderer/components/chat/message/chat-content.tsx index 5dbe754e..ec783441 100644 --- a/packages/app/src/renderer/components/chat/message/chat-content.tsx +++ b/packages/app/src/renderer/components/chat/message/chat-content.tsx @@ -213,10 +213,13 @@ export default function ChatContent({ const toolInvocation = part.toolInvocation; const toolName = toolInvocation.toolName || "Tool"; + const rendererName = toolName.includes(":") + ? toolName.slice(toolName.lastIndexOf(":") + 1) + : toolName; // Check if there's a custom renderer for this tool const CustomRenderer = - TOOL_COMPONENTS[toolName as keyof typeof TOOL_COMPONENTS]; + TOOL_COMPONENTS[rendererName as keyof typeof TOOL_COMPONENTS]; if (CustomRenderer) { return ( { + const result = await localAI.respondToInteraction( + event.requestId, + event.interactionId, + response, + ); + if (!result.success || !result.data?.accepted) { + throw new Error( + result.error?.message || + "Local AI interaction is no longer active.", + ); + } + }); + return; + } + setStatus(event.finishReason === "error" ? "error" : "ready"); + useUserInputStore.getState().dismissRequest(event.requestId); activeRequestIdRef.current = undefined; releaseSubscription(); }, @@ -156,13 +185,15 @@ export function useLocalAIChat(): UseLocalAIChatResult { } if (activeRequestIdRef.current) { - const abortResult = await localAI.abort(activeRequestIdRef.current); + const previousRequestId = activeRequestIdRef.current; + const abortResult = await localAI.abort(previousRequestId); if (!abortResult.success) { throw new Error( abortResult.error?.message || "Could not stop the previous local AI request.", ); } + useUserInputStore.getState().dismissRequest(previousRequestId); releaseSubscription(); activeRequestIdRef.current = undefined; } @@ -206,6 +237,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { : new Error("Failed to start local AI chat."); setError(nextError); setStatus("error"); + useUserInputStore.getState().dismissRequest(requestId); activeRequestIdRef.current = undefined; releaseSubscription(); } @@ -249,6 +281,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { // main process no longer owns the request, there will be no event to // wait for, so release the local listener here. if (!result.data?.aborted) { + useUserInputStore.getState().dismissRequest(requestId); activeRequestIdRef.current = undefined; releaseSubscription(); setStatus("ready"); @@ -269,6 +302,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { const localAI = getLocalAI(); releaseSubscription(); if (requestId && localAI) { + useUserInputStore.getState().dismissRequest(requestId); void localAI.abort(requestId); } }, diff --git a/packages/app/src/renderer/libs/stores/user-input-store.ts b/packages/app/src/renderer/libs/stores/user-input-store.ts index 56a9b2fd..abf947f5 100644 --- a/packages/app/src/renderer/libs/stores/user-input-store.ts +++ b/packages/app/src/renderer/libs/stores/user-input-store.ts @@ -1,121 +1,92 @@ -/** - * User Input Store - * - * Manages pending user input requests from the ask_user_input tool. - * Uses deferred Promise pattern to block AI generation until user responds. - */ - +import type { + LocalAIInteractionResponse, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; import { create } from "zustand"; +type InteractionEvent = Extract; + interface PendingInput { - toolCallId: string; + requestId: string; + interactionId: string; + kind: InteractionEvent["kind"]; + name: string; question: string; options: string[]; - resolve: (value: string) => void; - reject: (error: Error) => void; + input?: unknown; + respond(response: LocalAIInteractionResponse): Promise; createdAt: number; } interface UserInputState { pendingInputs: Map; - - /** - * Register a new pending input request. - * Returns a Promise that resolves when user responds. - */ - registerPendingInput: ( - toolCallId: string, - question: string, - options: string[], - ) => Promise; - - /** - * Resolve a pending input with user's selection. - * Called when user clicks an option or submits custom input. - */ - resolvePendingInput: (toolCallId: string, value: string) => void; - - /** - * Reject/cancel a pending input. - * Called on timeout or chat reset. - */ - rejectPendingInput: (toolCallId: string, error?: string) => void; - - /** - * Check if a tool call is pending user input. - */ - isPending: (toolCallId: string) => boolean; - - /** - * Get pending input details by toolCallId. - */ - getPendingInput: (toolCallId: string) => PendingInput | undefined; - - /** - * Clear all pending inputs. - * Called on chat reset. - */ - clearAllPending: () => void; + registerInteraction( + event: InteractionEvent, + respond: PendingInput["respond"], + ): void; + resolvePendingInput(interactionId: string, value: string): Promise; + dismissRequest(requestId: string): void; + clearAllPending(): void; } export const useUserInputStore = create((set, get) => ({ pendingInputs: new Map(), - registerPendingInput: (toolCallId, question, options) => { - return new Promise((resolve, reject) => { - set((state) => { - const newMap = new Map(state.pendingInputs); - newMap.set(toolCallId, { - toolCallId, - question, - options, - resolve, - reject, - createdAt: Date.now(), - }); - return { pendingInputs: newMap }; + registerInteraction: (event, respond) => { + set((state) => { + const pendingInputs = new Map(state.pendingInputs); + pendingInputs.set(event.interactionId, { + requestId: event.requestId, + interactionId: event.interactionId, + kind: event.kind, + name: event.name, + question: event.prompt, + options: event.options ?? [], + input: event.input, + respond, + createdAt: Date.now(), }); + return { pendingInputs }; }); }, - resolvePendingInput: (toolCallId, value) => { - const pending = get().pendingInputs.get(toolCallId); - if (pending) { - pending.resolve(value); - set((state) => { - const newMap = new Map(state.pendingInputs); - newMap.delete(toolCallId); - return { pendingInputs: newMap }; - }); - } + resolvePendingInput: async (interactionId, value) => { + const pending = get().pendingInputs.get(interactionId); + if (!pending) return; + + await pending.respond( + pending.kind === "approval" + ? { approved: value === "Allow once" } + : { value }, + ); + set((state) => { + const pendingInputs = new Map(state.pendingInputs); + pendingInputs.delete(interactionId); + return { pendingInputs }; + }); }, - rejectPendingInput: (toolCallId, error = "User cancelled") => { - const pending = get().pendingInputs.get(toolCallId); - if (pending) { - pending.reject(new Error(error)); - set((state) => { - const newMap = new Map(state.pendingInputs); - newMap.delete(toolCallId); - return { pendingInputs: newMap }; - }); - } + dismissRequest: (requestId) => { + set((state) => ({ + pendingInputs: new Map( + [...state.pendingInputs].filter( + ([, pending]) => pending.requestId !== requestId, + ), + ), + })); }, - isPending: (toolCallId) => get().pendingInputs.has(toolCallId), - - getPendingInput: (toolCallId) => get().pendingInputs.get(toolCallId), - clearAllPending: () => { - const pending = get().pendingInputs; - pending.forEach((p) => p.reject(new Error("Chat reset"))); + const pending = [...get().pendingInputs.values()]; set({ pendingInputs: new Map() }); + pending.forEach((interaction) => { + void interaction.respond( + interaction.kind === "approval" ? { approved: false } : {}, + ); + }); }, })); -/** - * Hook to check if there are any pending inputs. - */ export const useHasPendingInput = () => { const pendingInputs = useUserInputStore((state) => state.pendingInputs); return pendingInputs.size > 0; diff --git a/packages/app/src/shared/types/local-ai.ts b/packages/app/src/shared/types/local-ai.ts index c598793f..61eba8e2 100644 --- a/packages/app/src/shared/types/local-ai.ts +++ b/packages/app/src/shared/types/local-ai.ts @@ -65,6 +65,13 @@ export interface LocalAIUsage { totalTokens?: number; } +export type LocalAIInteractionKind = "approval" | "input"; + +export interface LocalAIInteractionResponse { + approved?: boolean; + value?: string; +} + export type LocalAIToolState = | "input-streaming" | "input-available" @@ -101,6 +108,16 @@ export type LocalAIStreamEvent = requestId: string; error: LocalAISerializableError; } + | { + type: "interaction"; + requestId: string; + interactionId: string; + kind: LocalAIInteractionKind; + name: string; + prompt: string; + input?: unknown; + options?: string[]; + } | { type: "finish"; requestId: string; @@ -128,6 +145,11 @@ export interface LocalAIRuntimeService { emit: (event: LocalAIStreamEvent) => void, ): Promise | void; abort(requestId: string): Promise | boolean; + respondToInteraction( + requestId: string, + interactionId: string, + response: LocalAIInteractionResponse, + ): Promise | boolean; } export interface ILocalAIAPI { @@ -137,6 +159,11 @@ export interface ILocalAIAPI { ): Promise>; startChat(request: LocalAIChatRequest): Promise; abort(requestId: string): Promise>; + respondToInteraction( + requestId: string, + interactionId: string, + response: LocalAIInteractionResponse, + ): Promise>; onEvent( requestId: string, callback: (event: LocalAIStreamEvent) => void, From f5cdef7c9a27d04faad8a935190adc7f4dceab86 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 16:03:33 +0800 Subject: [PATCH 2/9] test(app): cover local tool interaction lifecycle --- .../ipc/local-ai-context.test.ts | 73 +++++++++++ .../electron/ai/__tests__/agent-tools.test.ts | 123 ++++++++++++++++++ .../electron/ai/__tests__/codex-cli.test.ts | 5 +- .../src/electron/ai/__tests__/runtime.test.ts | 90 +++++++++++++ .../stores/__tests__/user-input-store.test.ts | 65 +++++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/electron/ai/__tests__/agent-tools.test.ts create mode 100644 packages/app/src/renderer/libs/stores/__tests__/user-input-store.test.ts diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts index b94728f7..4ba891ee 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -86,6 +86,7 @@ function createRuntime( })), startChat: vi.fn(), abort: vi.fn(() => true), + respondToInteraction: vi.fn(() => false), ...overrides, }; } @@ -251,6 +252,78 @@ describe("local AI IPC", () => { expect(runtime.startChat).not.toHaveBeenCalled(); }); + it("accepts interaction responses only from the active request owner", async () => { + const allowedSender = new FakeWebContents(1); + const otherSender = new FakeWebContents(2); + const runtime = createRuntime({ + startChat: vi.fn(() => new Promise(() => undefined)), + respondToInteraction: vi.fn(() => true), + }); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => allowedSender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + const respond = handlers.get(LOCAL_AI_CHANNELS.RESPOND_INTERACTION); + + start?.(createEvent(allowedSender), { + requestId: "request-1", + providerId: "claude-code", + messages: [{ role: "user", content: "hello" }], + }); + + await expect( + respond?.(createEvent(allowedSender), "request-1", "interaction-1", { + approved: true, + }), + ).resolves.toEqual({ + success: true, + data: { accepted: true }, + }); + expect(runtime.respondToInteraction).toHaveBeenCalledWith( + "request-1", + "interaction-1", + { approved: true }, + ); + + await expect( + respond?.(createEvent(otherSender), "request-1", "interaction-1", { + approved: true, + }), + ).resolves.toMatchObject({ + success: false, + error: { code: "LOCAL_AI_FORBIDDEN" }, + }); + }); + + it("rejects malformed interaction responses", async () => { + const sender = new FakeWebContents(1); + const runtime = createRuntime(); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + const respond = handlers.get(LOCAL_AI_CHANNELS.RESPOND_INTERACTION); + + await expect( + respond?.(createEvent(sender), "request-1", "interaction-1", { + approved: "yes", + }), + ).resolves.toMatchObject({ + success: false, + error: { code: "LOCAL_AI_INVALID_REQUEST" }, + }); + expect(runtime.respondToInteraction).not.toHaveBeenCalled(); + }); + it("aborts active work when its webContents is destroyed", async () => { const sender = new FakeWebContents(1); let resolveChat: (() => void) | undefined; diff --git a/packages/app/src/electron/ai/__tests__/agent-tools.test.ts b/packages/app/src/electron/ai/__tests__/agent-tools.test.ts new file mode 100644 index 00000000..00b54cc6 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/agent-tools.test.ts @@ -0,0 +1,123 @@ +import type { ToolDefinition } from "@/shared/types/mcp"; +import { describe, expect, it, vi } from "vitest"; +import { createAgentToolCatalog } from "../agent-tools"; + +function definition( + name: string, + overrides: Partial = {}, +): ToolDefinition { + return { + name, + description: `Run ${name}`, + inputSchema: { + type: "object", + properties: { + path: { type: "string", minLength: 1 }, + count: { type: "integer", minimum: 1 }, + }, + required: ["path"], + }, + ...overrides, + }; +} + +describe("createAgentToolCatalog", () => { + it("creates stable namespaced tools and validates their JSON schema", async () => { + const executeTool = vi.fn(async () => ({ ok: true })); + const tools = createAgentToolCatalog({ + groups: [{ serverName: "Repo Tools", tools: [definition("read/file")] }], + executeTool, + requestInteraction: vi.fn(async () => ({ approved: true })), + }); + + expect(tools[0]).toMatchObject({ + name: "repo_tools__read_file", + qualifiedName: "Repo Tools:read/file", + }); + await expect(tools[0].execute({ path: "" })).rejects.toThrow(); + await tools[0].execute({ path: "README.md", count: 2 }); + expect(executeTool).toHaveBeenCalledWith("Repo Tools", "read/file", { + path: "README.md", + count: 2, + }); + }); + + it("requires approval for untrusted MCP tools and open-world builtins", async () => { + const requestInteraction = vi + .fn() + .mockResolvedValueOnce({ approved: false }) + .mockResolvedValueOnce({ approved: true }); + const executeTool = vi.fn(async () => "done"); + const tools = createAgentToolCatalog({ + groups: [ + { + serverName: "external", + tools: [ + definition("read", { + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }), + ], + }, + { + serverName: "builtin", + tools: [ + definition("web_fetch", { + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, + }), + ], + }, + ], + executeTool, + requestInteraction, + }); + + await expect(tools[0].execute({ path: "x" })).rejects.toThrow( + "User denied external:read", + ); + await expect(tools[1].execute({ path: "x" })).resolves.toBe("done"); + expect(requestInteraction).toHaveBeenCalledTimes(2); + }); + + it("routes ask_user_input through the renderer interaction channel", async () => { + const executeTool = vi.fn(); + const tools = createAgentToolCatalog({ + groups: [ + { + serverName: "builtin", + tools: [ + { + name: "ask_user_input", + description: "Ask the user", + inputSchema: { + type: "object", + properties: { + question: { type: "string" }, + options: { type: "array", items: { type: "string" } }, + }, + required: ["question", "options"], + }, + }, + ], + }, + ], + executeTool, + requestInteraction: vi.fn(async () => ({ value: "Proceed" })), + }); + + await expect( + tools[0].execute({ + question: "Continue?", + options: ["Proceed", "Stop"], + }), + ).resolves.toMatchObject({ userSelection: "Proceed" }); + expect(executeTool).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/electron/ai/__tests__/codex-cli.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts index 05631cf5..a4c67618 100644 --- a/packages/app/src/electron/ai/__tests__/codex-cli.test.ts +++ b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts @@ -27,7 +27,10 @@ describe("CodexCliAdapter", () => { checkedAt: new Date(0).toISOString(), }; - const model = await adapter.createModel(request, status); + const model = await adapter.createModel(request, status, { + tools: [], + requestInteraction: async () => ({ approved: false }), + }); expect(model).toBeDefined(); expect(effectsPrototype.passthrough).toBeUndefined(); diff --git a/packages/app/src/electron/ai/__tests__/runtime.test.ts b/packages/app/src/electron/ai/__tests__/runtime.test.ts index 33008961..13fadf68 100644 --- a/packages/app/src/electron/ai/__tests__/runtime.test.ts +++ b/packages/app/src/electron/ai/__tests__/runtime.test.ts @@ -136,6 +136,10 @@ describe("LocalAiRuntime", () => { options: { cwd: "/trusted/workspace" }, }), expect.any(Object), + expect.objectContaining({ + tools: [], + requestInteraction: expect.any(Function), + }), ); expect(streamOptions?.messages).toEqual([ { role: "system", content: "Be concise." }, @@ -209,6 +213,92 @@ describe("LocalAiRuntime", () => { expect(adapter.dispose).toHaveBeenCalledOnce(); }); + it("pauses an approval-gated tool until the renderer responds", async () => { + const events: LocalAIStreamEvent[] = []; + let toolContext: + | Parameters[2] + | undefined; + const adapter = fakeAdapter("claude-code"); + vi.mocked(adapter.createModel).mockImplementation( + async (_request, _status, context) => { + toolContext = context; + return {} as LanguageModel; + }, + ); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + getToolGroups: () => [ + { + serverName: "external", + tools: [ + { + name: "write_value", + description: "Writes a value", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + }, + ], + executeTool: vi.fn(async () => ({ written: true })), + streamInvoker: () => ({ + fullStream: (async function* () { + const tool = toolContext?.tools[0]; + if (!tool) throw new Error("Expected tool context"); + const output = await tool.execute({ value: "ready" }); + yield { + type: "tool-result", + toolCallId: "tool-1", + toolName: tool.name, + output, + }; + yield { type: "finish", finishReason: "stop" }; + })(), + }), + }); + + const chat = runtime.startChat(request(), (event) => events.push(event)); + await vi.waitFor(() => { + expect(events[0]).toMatchObject({ + type: "interaction", + requestId: "request-1", + kind: "approval", + name: "external:write_value", + }); + }); + const interaction = events[0]; + if (interaction.type !== "interaction") { + throw new Error("Expected interaction event"); + } + + expect( + runtime.respondToInteraction( + interaction.requestId, + interaction.interactionId, + { approved: true }, + ), + ).toBe(true); + await chat; + + expect(events).toContainEqual({ + type: "tool", + requestId: "request-1", + toolCallId: "tool-1", + name: "external:write_value", + state: "output-available", + output: { written: true }, + }); + expect(events.at(-1)).toEqual({ + type: "finish", + requestId: "request-1", + finishReason: "stop", + usage: undefined, + }); + }); + it("emits a structured error and terminal event for unavailable auth", async () => { const events: LocalAIStreamEvent[] = []; const runtime = new LocalAiRuntime({ diff --git a/packages/app/src/renderer/libs/stores/__tests__/user-input-store.test.ts b/packages/app/src/renderer/libs/stores/__tests__/user-input-store.test.ts new file mode 100644 index 00000000..59b13245 --- /dev/null +++ b/packages/app/src/renderer/libs/stores/__tests__/user-input-store.test.ts @@ -0,0 +1,65 @@ +import type { LocalAIStreamEvent } from "@/shared/types/local-ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useUserInputStore } from "../user-input-store"; + +function interaction( + overrides: Partial> = {}, +): Extract { + return { + type: "interaction", + requestId: "request-1", + interactionId: "interaction-1", + kind: "approval", + name: "builtin:execute_command", + prompt: "Allow this command?", + options: ["Allow once", "Deny"], + ...overrides, + }; +} + +describe("user input store", () => { + beforeEach(() => { + useUserInputStore.setState({ pendingInputs: new Map() }); + }); + + it("maps approval choices to a structured runtime response", async () => { + const respond = vi.fn(async () => undefined); + useUserInputStore.getState().registerInteraction(interaction(), respond); + + await useUserInputStore + .getState() + .resolvePendingInput("interaction-1", "Allow once"); + + expect(respond).toHaveBeenCalledWith({ approved: true }); + expect(useUserInputStore.getState().pendingInputs.size).toBe(0); + }); + + it("returns text input and keeps a failed response available to retry", async () => { + const respond = vi + .fn() + .mockRejectedValueOnce(new Error("IPC failed")) + .mockResolvedValueOnce(undefined); + useUserInputStore.getState().registerInteraction( + interaction({ + kind: "input", + name: "builtin:ask_user_input", + prompt: "Choose", + options: ["Alpha"], + }), + respond, + ); + + await expect( + useUserInputStore + .getState() + .resolvePendingInput("interaction-1", "Alpha"), + ).rejects.toThrow("IPC failed"); + expect(useUserInputStore.getState().pendingInputs.size).toBe(1); + + await useUserInputStore + .getState() + .resolvePendingInput("interaction-1", "Alpha"); + expect(respond).toHaveBeenLastCalledWith({ value: "Alpha" }); + expect(useUserInputStore.getState().pendingInputs.size).toBe(0); + }); +}); From 59628875c0a14b573eac0b5e4105726296546e25 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 17:25:23 +0800 Subject: [PATCH 3/9] fix(app): connect Codex to local MCP tools --- .../electron/ai/__tests__/agent-tools.test.ts | 50 +++++++++++ .../ai/__tests__/codex-cli-mcp.test.ts | 86 +++++++++++++++++++ packages/app/src/electron/ai/agent-tools.ts | 22 +++-- .../src/electron/ai/providers/codex-cli.ts | 1 + .../app/src/electron/tools/ask-user-input.ts | 18 +++- 5 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts diff --git a/packages/app/src/electron/ai/__tests__/agent-tools.test.ts b/packages/app/src/electron/ai/__tests__/agent-tools.test.ts index 00b54cc6..28a35319 100644 --- a/packages/app/src/electron/ai/__tests__/agent-tools.test.ts +++ b/packages/app/src/electron/ai/__tests__/agent-tools.test.ts @@ -120,4 +120,54 @@ describe("createAgentToolCatalog", () => { ).resolves.toMatchObject({ userSelection: "Proceed" }); expect(executeTool).not.toHaveBeenCalled(); }); + + it("normalizes structured ask_user_input options for Codex clients", async () => { + const requestInteraction = vi.fn(async () => ({ value: "Alpha" })); + const tools = createAgentToolCatalog({ + groups: [ + { + serverName: "builtin", + tools: [ + { + name: "ask_user_input", + inputSchema: { + type: "object", + properties: { + question: { type: "string" }, + options: { + type: "array", + items: { + anyOf: [ + { type: "string" }, + { + type: "object", + properties: { + label: { type: "string" }, + description: { type: "string" }, + }, + required: ["label"], + }, + ], + }, + }, + }, + required: ["question", "options"], + }, + }, + ], + }, + ], + executeTool: vi.fn(), + requestInteraction, + }); + + await tools[0].execute({ + question: "Choose", + options: [{ label: "Alpha", description: "First" }, { label: "Beta" }], + }); + + expect(requestInteraction).toHaveBeenCalledWith( + expect.objectContaining({ options: ["Alpha", "Beta"] }), + ); + }); }); diff --git a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts new file mode 100644 index 00000000..d5d44854 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts @@ -0,0 +1,86 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors"; +import { CodexCliAdapter } from "../providers/codex-cli"; +import type { LocalAiProviderStatus } from "../types"; + +const mocks = vi.hoisted(() => { + const model = {}; + const provider = Object.assign( + vi.fn(() => model), + { + close: vi.fn(async () => undefined), + listModels: vi.fn(async () => ({ + models: [{ id: "gpt-test" }], + defaultModel: { id: "gpt-test" }, + })), + }, + ); + + return { + model, + provider, + createCodexAppServer: vi.fn(() => provider), + createSdkMcpServer: vi.fn(() => ({ url: "http://127.0.0.1/mcp" })), + tool: vi.fn((definition) => definition), + }; +}); + +vi.mock("ai-sdk-provider-codex-cli", () => ({ + createCodexAppServer: mocks.createCodexAppServer, + createSdkMcpServer: mocks.createSdkMcpServer, + tool: mocks.tool, +})); + +describe("CodexCliAdapter MCP transport", () => { + it("enables the RMCP client when Convera tools are attached", async () => { + const adapter = new CodexCliAdapter(); + const request: LocalAIChatRequest = { + requestId: "test", + providerId: "codex-cli", + modelId: "gpt-test", + messages: [{ role: "user", content: "use a tool" }], + options: { cwd: "/tmp/convera-test" }, + }; + const status: LocalAiProviderStatus = { + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + executablePath: "/test/codex", + defaultModel: "gpt-test", + models: ["gpt-test"], + checkedAt: new Date(0).toISOString(), + }; + + await adapter.createModel(request, status, { + tools: [ + { + name: "builtin__probe", + qualifiedName: "builtin:probe", + description: "Probe the local MCP bridge", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + }, + inputShape: { value: z.string() }, + inputValidator: z.object({ value: z.string() }), + execute: vi.fn(async () => "PROBE_OK"), + }, + ], + requestInteraction: vi.fn(async () => ({ approved: false })), + }); + + const mcpServer = mocks.createSdkMcpServer.mock.results[0].value; + expect(mocks.provider).toHaveBeenCalledWith( + "gpt-test", + expect.objectContaining({ + cwd: "/tmp/convera-test", + mcpServers: { convera: mcpServer }, + rmcpClient: true, + }), + ); + + await adapter.dispose(); + }); +}); diff --git a/packages/app/src/electron/ai/agent-tools.ts b/packages/app/src/electron/ai/agent-tools.ts index 388b84df..2c0bbcab 100644 --- a/packages/app/src/electron/ai/agent-tools.ts +++ b/packages/app/src/electron/ai/agent-tools.ts @@ -207,6 +207,22 @@ function interactionPrompt( )}`; } +function interactionOptions(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + return value.flatMap((option) => { + if (typeof option === "string") return [option]; + if ( + isRecord(option) && + typeof option.label === "string" && + option.label.trim() + ) { + return [option.label]; + } + return []; + }); +} + export function createAgentToolCatalog( options: AgentToolCatalogOptions, ): AgentTool[] { @@ -253,11 +269,7 @@ export function createAgentToolCatalog( name: qualifiedName, prompt: question, input: parsed, - options: Array.isArray(parsed.options) - ? parsed.options.filter( - (option): option is string => typeof option === "string", - ) - : [], + options: interactionOptions(parsed.options), }); if (typeof interaction.value !== "string") { throw new Error(`User cancelled ${qualifiedName}.`); diff --git a/packages/app/src/electron/ai/providers/codex-cli.ts b/packages/app/src/electron/ai/providers/codex-cli.ts index d276c521..3dff7414 100644 --- a/packages/app/src/electron/ai/providers/codex-cli.ts +++ b/packages/app/src/electron/ai/providers/codex-cli.ts @@ -120,6 +120,7 @@ export class CodexCliAdapter implements LocalAiProviderAdapter { { cwd, mcpServers: mcpServer ? { convera: mcpServer } : undefined, + rmcpClient: mcpServer ? true : undefined, serverRequests, approvalPolicy: "on-request", sandboxPolicy: { diff --git a/packages/app/src/electron/tools/ask-user-input.ts b/packages/app/src/electron/tools/ask-user-input.ts index f4179400..08e8b73d 100644 --- a/packages/app/src/electron/tools/ask-user-input.ts +++ b/packages/app/src/electron/tools/ask-user-input.ts @@ -8,6 +8,14 @@ import { tool } from "ai"; import { z } from "zod"; +const inputOption = z.union([ + z.string(), + z.object({ + label: z.string(), + description: z.string().optional(), + }), +]); + /** * Ask user input tool * @@ -22,9 +30,11 @@ export const askUserInput = tool({ inputSchema: z.object({ question: z.string().describe("The question to ask the user"), options: z - .array(z.string()) + .array(inputOption) .max(3) - .describe("Up to 3 predefined options for the user to choose from"), + .describe( + "Up to 3 predefined options. Each option may be a plain string or an object with a label and optional description.", + ), }), execute: async ({ question, options }) => { // This is a client-side tool - execution happens in renderer process @@ -33,7 +43,9 @@ export const askUserInput = tool({ return { _clientSideTool: true, question, - options, + options: options.map((option) => + typeof option === "string" ? option : option.label, + ), message: "Waiting for user input...", }; }, From 29ec4ddfcb0ba0c9762fed8181bfb39bd29bc995 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 17:41:50 +0800 Subject: [PATCH 4/9] fix(app): preserve click results after UI transitions --- packages/app/automation/driver.test.ts | 55 ++++++++++++++++++++++++++ packages/app/automation/driver.ts | 7 +++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 packages/app/automation/driver.test.ts diff --git a/packages/app/automation/driver.test.ts b/packages/app/automation/driver.test.ts new file mode 100644 index 00000000..462f7b77 --- /dev/null +++ b/packages/app/automation/driver.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { ConveraDriver } from "./driver.js"; + +describe("ConveraDriver click", () => { + it("returns the pre-click snapshot when the clicked element disappears", async () => { + let exists = true; + const click = vi.fn(async () => { + exists = false; + }); + const element = { + attributes: [{ name: "role", value: "option" }], + click, + doubleClick: vi.fn(), + getTagName: vi.fn(async () => "button"), + getText: vi.fn(async () => "Alpha"), + getValue: vi.fn(async () => null), + isClickable: vi.fn(async () => exists), + isDisplayed: vi.fn(async () => exists), + isEnabled: vi.fn(async () => true), + isExisting: vi.fn(async () => exists), + waitForDisplayed: vi.fn(async () => undefined), + waitForExist: vi.fn(async () => undefined), + }; + const browser = { + $: vi.fn(async () => element), + execute: vi.fn( + async ( + operation: (node: typeof element) => unknown, + node: typeof element, + ) => operation(node), + ), + waitUntil: vi.fn(async (condition: () => Promise) => { + if (!(await condition())) throw new Error("condition not met"); + return true; + }), + }; + const driver = new ConveraDriver(); + Reflect.set(driver, "browser", browser); + + await expect(driver.click('[role="option"]')).resolves.toMatchObject({ + selector: '[role="option"]', + tag: "button", + text: "Alpha", + displayed: true, + enabled: true, + clickable: true, + attributes: { role: "option" }, + action: "click", + completed: true, + }); + expect(click).toHaveBeenCalledOnce(); + expect(browser.$).toHaveBeenCalledTimes(2); + expect(element.isExisting).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/automation/driver.ts b/packages/app/automation/driver.ts index 37470c8c..e2c72961 100644 --- a/packages/app/automation/driver.ts +++ b/packages/app/automation/driver.ts @@ -547,9 +547,14 @@ export class ConveraDriver { async click(selector: string, double = false) { const element = await this.readyElement(selector, true); + const before = await this.inspectElement(selector); if (double) await element.doubleClick(); else await element.click(); - return this.inspectElement(selector); + return { + ...before, + action: double ? "double_click" : "click", + completed: true, + }; } async hover(selector: string) { From 57488fbe0d305609f452c44bffb91419a8e00358 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 17:43:10 +0800 Subject: [PATCH 5/9] test(app): verify builtin tools reach local AI runtime --- .../src/electron/mcp/runtime-catalog.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/app/src/electron/mcp/runtime-catalog.test.ts diff --git a/packages/app/src/electron/mcp/runtime-catalog.test.ts b/packages/app/src/electron/mcp/runtime-catalog.test.ts new file mode 100644 index 00000000..4fb4d336 --- /dev/null +++ b/packages/app/src/electron/mcp/runtime-catalog.test.ts @@ -0,0 +1,65 @@ +import type { LanguageModel } from "ai"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LocalAiProviderAdapter } from "../ai/provider-adapter"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../ai/provider-descriptors"; +import { LocalAiRuntime } from "../ai/runtime"; +import { cleanupMCPHub, getAllTools, initializeMCPHub } from "./index"; + +describe("main-process agent tool catalog", () => { + afterEach(async () => { + await cleanupMCPHub(); + }); + + it("provides every builtin tool to startChat after MCP initialization", async () => { + const createModel = vi.fn( + async () => ({}) as LanguageModel, + ); + const adapter: LocalAiProviderAdapter = { + id: "codex-cli", + getStatus: vi.fn(async () => ({ + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + checkedAt: new Date(0).toISOString(), + })), + createModel, + dispose: vi.fn(async () => undefined), + }; + const configPath = join( + tmpdir(), + `convera-mcp-runtime-catalog-${process.pid}.json`, + ); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + getToolGroups: async () => { + await initializeMCPHub(configPath); + return getAllTools(); + }, + streamInvoker: () => ({ + fullStream: (async function* () { + yield { type: "finish", finishReason: "stop" }; + })(), + }), + }); + + await runtime.startChat( + { + requestId: "runtime-catalog", + providerId: "codex-cli", + messages: [{ role: "user", content: "List available tools." }], + }, + vi.fn(), + ); + + const context = createModel.mock.calls[0]?.[2]; + expect(context?.tools.map((tool) => tool.qualifiedName)).toEqual([ + "builtin:ask_user_input", + "builtin:execute_command", + "builtin:web_fetch", + ]); + + await runtime.dispose(); + }); +}); From c15c3561b82a342b8d395eecb756af8f478879c7 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 17:57:06 +0800 Subject: [PATCH 6/9] fix(app): use native Codex HTTP MCP config --- .../ai/__tests__/codex-cli-mcp.test.ts | 55 ++++++++++++++++++- .../src/electron/ai/providers/codex-cli.ts | 12 ++-- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts index d5d44854..7065824d 100644 --- a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts +++ b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts @@ -34,7 +34,7 @@ vi.mock("ai-sdk-provider-codex-cli", () => ({ })); describe("CodexCliAdapter MCP transport", () => { - it("enables the RMCP client when Convera tools are attached", async () => { + it("attaches Convera tools without the obsolete RMCP feature flag", async () => { const adapter = new CodexCliAdapter(); const request: LocalAIChatRequest = { requestId: "test", @@ -77,9 +77,60 @@ describe("CodexCliAdapter MCP transport", () => { expect.objectContaining({ cwd: "/tmp/convera-test", mcpServers: { convera: mcpServer }, - rmcpClient: true, }), ); + expect(mocks.provider.mock.calls[0]?.[1]).not.toHaveProperty("rmcpClient"); + + await adapter.dispose(); + }); + + it("accepts MCP tool calls with structured empty content", async () => { + const adapter = new CodexCliAdapter(); + const request: LocalAIChatRequest = { + requestId: "test", + providerId: "codex-cli", + modelId: "gpt-test", + messages: [{ role: "user", content: "use a tool" }], + }; + const status: LocalAiProviderStatus = { + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + executablePath: "/test/codex", + defaultModel: "gpt-test", + models: ["gpt-test"], + checkedAt: new Date(0).toISOString(), + }; + + await adapter.createModel(request, status, { + tools: [ + { + name: "builtin__probe", + qualifiedName: "builtin:probe", + description: "Probe the local MCP bridge", + inputSchema: { type: "object", properties: {} }, + inputShape: {}, + inputValidator: z.object({}), + execute: vi.fn(async () => "PROBE_OK"), + }, + ], + requestInteraction: vi.fn(async () => ({ approved: false })), + }); + + const settings = mocks.provider.mock.calls.at(-1)?.[1]; + const handler = settings?.serverRequests?.onMcpElicitation; + expect(handler).toBeTypeOf("function"); + await expect( + handler?.({ + id: 1, + method: "mcpServer/elicitation/request", + params: { + threadId: "thread", + serverName: "convera", + _meta: { codex_approval_kind: "mcp_tool_call" }, + }, + }), + ).resolves.toEqual({ action: "accept", content: {} }); await adapter.dispose(); }); diff --git a/packages/app/src/electron/ai/providers/codex-cli.ts b/packages/app/src/electron/ai/providers/codex-cli.ts index 3dff7414..3849c855 100644 --- a/packages/app/src/electron/ai/providers/codex-cli.ts +++ b/packages/app/src/electron/ai/providers/codex-cli.ts @@ -105,13 +105,10 @@ export class CodexCliAdapter implements LocalAiProviderAdapter { : "decline", }), onSkillApproval: async () => ({ decision: "decline" }), - onMcpElicitation: async ({ params }) => ({ - action: - params._meta?.codex_approval_kind === "mcp_tool_call" - ? "accept" - : "decline", - content: null, - }), + onMcpElicitation: async ({ params }) => + params._meta?.codex_approval_kind === "mcp_tool_call" + ? { action: "accept", content: {} } + : { action: "decline", content: null }, }; const cwd = request.options?.cwd; @@ -120,7 +117,6 @@ export class CodexCliAdapter implements LocalAiProviderAdapter { { cwd, mcpServers: mcpServer ? { convera: mcpServer } : undefined, - rmcpClient: mcpServer ? true : undefined, serverRequests, approvalPolicy: "on-request", sandboxPolicy: { From e597a5c1b12754689ff390a61bbe060c5d234bd0 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 19:39:25 +0800 Subject: [PATCH 7/9] feat(app): render Codex tool calls with AI SDK --- .../ipc/local-ai-context.test.ts | 20 +- .../electro-bridge/ipc/local-ai-context.ts | 8 +- .../src/electron/ai/__tests__/runtime.test.ts | 130 +++++++---- packages/app/src/electron/ai/runtime.ts | 205 +++++------------- .../src/electron/mcp/runtime-catalog.test.ts | 6 +- .../components/chat/message/chat-content.tsx | 99 ++++++--- packages/app/src/renderer/libs/db/database.ts | 1 + packages/app/src/renderer/libs/db/hooks.ts | 1 + .../renderer/libs/hooks/use-local-ai-chat.ts | 121 +++++------ .../renderer/libs/local-ai-ui-stream.test.ts | 69 ++++++ .../src/renderer/libs/local-ai-ui-stream.ts | 81 +++++++ .../libs/stores/chat-history-store.ts | 3 + .../src/renderer/libs/stores/chat-store.tsx | 1 + packages/app/src/renderer/types/chat.ts | 6 +- packages/app/src/shared/types/local-ai.ts | 22 +- 15 files changed, 449 insertions(+), 324 deletions(-) create mode 100644 packages/app/src/renderer/libs/local-ai-ui-stream.test.ts create mode 100644 packages/app/src/renderer/libs/local-ai-ui-stream.ts diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts index 4ba891ee..e4ef7998 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -117,25 +117,25 @@ describe("local AI IPC", () => { listener?.( {}, { - type: "delta", + type: "ui-message", requestId: "request-2", - text: "ignore", + chunk: { type: "text-delta", id: "text-1", delta: "ignore" }, }, ); listener?.( {}, { - type: "delta", + type: "ui-message", requestId: "request-1", - text: "hello", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, }, ); expect(callback).toHaveBeenCalledOnce(); expect(callback).toHaveBeenCalledWith({ - type: "delta", + type: "ui-message", requestId: "request-1", - text: "hello", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, }); unsubscribe(); @@ -152,9 +152,9 @@ describe("local AI IPC", () => { const runtime = createRuntime({ startChat: vi.fn((_request, emit) => { emit({ - type: "delta", + type: "ui-message", requestId: "runtime-cannot-change-owner", - text: "hello", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, }); emit({ type: "finish", @@ -194,9 +194,9 @@ describe("local AI IPC", () => { { channel: LOCAL_AI_CHANNELS.EVENT, event: { - type: "delta", + type: "ui-message", requestId: "request-1", - text: "hello", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, }, }, { diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts index 698b2703..96e9cf4b 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -330,13 +330,7 @@ export function setupLocalAIIPC( requestId: request.requestId, error: serializeLocalAIError(runtimeEvent.error), } - : runtimeEvent.type === "tool" && runtimeEvent.error - ? { - ...runtimeEvent, - requestId: request.requestId, - error: serializeLocalAIError(runtimeEvent.error), - } - : { ...runtimeEvent, requestId: request.requestId }; + : { ...runtimeEvent, requestId: request.requestId }; try { sender.send(LOCAL_AI_CHANNELS.EVENT, streamEvent); diff --git a/packages/app/src/electron/ai/__tests__/runtime.test.ts b/packages/app/src/electron/ai/__tests__/runtime.test.ts index 13fadf68..d3ac491a 100644 --- a/packages/app/src/electron/ai/__tests__/runtime.test.ts +++ b/packages/app/src/electron/ai/__tests__/runtime.test.ts @@ -84,36 +84,38 @@ describe("LocalAiRuntime", () => { ]); }); - it("forwards text, tool, finish, usage, and explicit agent context", async () => { + it("forwards the AI SDK UI stream, usage, and explicit agent context", async () => { const events: LocalAIStreamEvent[] = []; let streamOptions: Parameters[0] | undefined; const streamInvoker: RuntimeStreamInvoker = (options) => { streamOptions = options; return { - fullStream: (async function* () { - yield { type: "text-delta", text: "Hi" }; + toUIMessageStream: async function* () { + yield { type: "start" as const, messageId: "assistant-1" }; + yield { type: "text-start" as const, id: "text-1" }; + yield { type: "text-delta" as const, id: "text-1", delta: "Hi" }; + yield { type: "text-end" as const, id: "text-1" }; yield { - type: "tool-call", + type: "tool-input-available" as const, toolCallId: "tool-1", toolName: "read_file", input: { path: "README.md" }, + dynamic: true, }; yield { - type: "tool-result", + type: "tool-output-available" as const, toolCallId: "tool-1", - toolName: "read_file", output: "contents", + dynamic: true, }; - yield { - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 3, - outputTokens: 2, - totalTokens: 5, - }, - }; - })(), + yield { type: "finish" as const, finishReason: "stop" as const }; + }, + finishReason: Promise.resolve("stop"), + usage: Promise.resolve({ + inputTokens: 3, + outputTokens: 2, + totalTokens: 5, + }), }; }; const adapter = fakeAdapter("claude-code"); @@ -146,22 +148,51 @@ describe("LocalAiRuntime", () => { { role: "user", content: "hello" }, ]); expect(events).toEqual([ - { type: "delta", requestId: "request-1", text: "Hi" }, { - type: "tool", + type: "ui-message", requestId: "request-1", - toolCallId: "tool-1", - name: "read_file", - state: "input-available", - input: { path: "README.md" }, + chunk: { type: "start", messageId: "assistant-1" }, }, { - type: "tool", + type: "ui-message", requestId: "request-1", - toolCallId: "tool-1", - name: "read_file", - state: "output-available", - output: "contents", + chunk: { type: "text-start", id: "text-1" }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-delta", id: "text-1", delta: "Hi" }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-end", id: "text-1" }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { + type: "tool-input-available", + toolCallId: "tool-1", + toolName: "read_file", + input: { path: "README.md" }, + dynamic: true, + }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { + type: "tool-output-available", + toolCallId: "tool-1", + output: "contents", + dynamic: true, + }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "finish", finishReason: "stop" }, }, { type: "finish", @@ -175,15 +206,20 @@ describe("LocalAiRuntime", () => { it("aborts an active stream and reports an aborted terminal event", async () => { const events: LocalAIStreamEvent[] = []; const streamInvoker: RuntimeStreamInvoker = (options) => ({ - fullStream: (async function* () { - yield { type: "text-delta", text: "partial" }; + toUIMessageStream: async function* () { + yield { type: "start" as const, messageId: "assistant-1" }; + yield { type: "text-start" as const, id: "text-1" }; + yield { + type: "text-delta" as const, + id: "text-1", + delta: "partial", + }; await new Promise((resolve) => { options.abortSignal.addEventListener("abort", () => resolve(), { once: true, }); }); - yield { type: "abort" }; - })(), + }, }); const adapter = fakeAdapter("claude-code"); const runtime = new LocalAiRuntime({ @@ -194,9 +230,9 @@ describe("LocalAiRuntime", () => { const chat = runtime.startChat(request(), (event) => events.push(event)); await vi.waitFor(() => { expect(events).toContainEqual({ - type: "delta", + type: "ui-message", requestId: "request-1", - text: "partial", + chunk: { type: "text-delta", id: "text-1", delta: "partial" }, }); }); @@ -245,18 +281,25 @@ describe("LocalAiRuntime", () => { ], executeTool: vi.fn(async () => ({ written: true })), streamInvoker: () => ({ - fullStream: (async function* () { + toUIMessageStream: async function* () { const tool = toolContext?.tools[0]; if (!tool) throw new Error("Expected tool context"); const output = await tool.execute({ value: "ready" }); yield { - type: "tool-result", + type: "tool-input-available" as const, toolCallId: "tool-1", toolName: tool.name, + input: { value: "ready" }, + dynamic: true, + }; + yield { + type: "tool-output-available" as const, + toolCallId: "tool-1", output, + dynamic: true, }; - yield { type: "finish", finishReason: "stop" }; - })(), + yield { type: "finish" as const, finishReason: "stop" as const }; + }, }), }); @@ -284,12 +327,15 @@ describe("LocalAiRuntime", () => { await chat; expect(events).toContainEqual({ - type: "tool", + type: "ui-message", requestId: "request-1", - toolCallId: "tool-1", - name: "external:write_value", - state: "output-available", - output: { written: true }, + chunk: { + type: "tool-input-available", + toolCallId: "tool-1", + toolName: "external:write_value", + input: { value: "ready" }, + dynamic: true, + }, }); expect(events.at(-1)).toEqual({ type: "finish", diff --git a/packages/app/src/electron/ai/runtime.ts b/packages/app/src/electron/ai/runtime.ts index cb1eb9bc..5ddf1296 100644 --- a/packages/app/src/electron/ai/runtime.ts +++ b/packages/app/src/electron/ai/runtime.ts @@ -9,7 +9,12 @@ import type { LocalAIStreamEvent, LocalAIUsage, } from "@/shared/types/local-ai"; -import { streamText, type LanguageModel, type ModelMessage } from "ai"; +import { + streamText, + type LanguageModel, + type ModelMessage, + type UIMessageChunk, +} from "ai"; import { randomUUID } from "node:crypto"; import { createAgentToolCatalog, @@ -27,10 +32,14 @@ import { type LocalAiProviderStatus as ProbeStatus, } from "./types"; -type RuntimeStreamPart = Record & { type: string }; - interface RuntimeStreamResult { - fullStream: AsyncIterable; + toUIMessageStream(options?: { + onError?: (error: unknown) => string; + sendReasoning?: boolean; + sendSources?: boolean; + }): AsyncIterable; + finishReason?: PromiseLike; + usage?: PromiseLike; } interface RuntimeStreamOptions { @@ -173,19 +182,6 @@ function usageFrom(value: unknown): LocalAIUsage | undefined { return { inputTokens, outputTokens, totalTokens }; } -function stringField( - part: RuntimeStreamPart, - ...fields: string[] -): string | undefined { - for (const field of fields) { - const value = part[field]; - if (typeof value === "string") { - return value; - } - } - return undefined; -} - interface PendingInteraction { requestId: string; resolve(response: LocalAIInteractionResponse): void; @@ -450,144 +446,32 @@ export class LocalAiRuntime implements LocalAIRuntimeService { const eventNames = new Map( tools.map((tool) => [tool.name, tool.qualifiedName]), ); - const toolNames = new Map(); - const toolInputs = new Map(); - let terminalEventEmitted = false; - - for await (const part of result.fullStream) { - switch (part.type) { - case "text-delta": { - const text = stringField(part, "text"); - if (text) { - emit({ type: "delta", requestId, text }); - } - break; - } - case "tool-input-start": { - const toolCallId = stringField(part, "id") ?? "unknown"; - const name = this.toolEventName( - stringField(part, "toolName") ?? "unknown", - eventNames, - ); - toolNames.set(toolCallId, name); - toolInputs.set(toolCallId, ""); - emit({ - type: "tool", - requestId, - toolCallId, - name, - state: "input-streaming", - }); - break; - } - case "tool-input-delta": { - const toolCallId = stringField(part, "id") ?? "unknown"; - const delta = stringField(part, "delta") ?? ""; - const input = `${toolInputs.get(toolCallId) ?? ""}${delta}`; - toolInputs.set(toolCallId, input); - emit({ - type: "tool", - requestId, - toolCallId, - name: toolNames.get(toolCallId) ?? "unknown", - state: "input-streaming", - input, - }); - break; - } - case "tool-call": { - const toolCallId = stringField(part, "toolCallId", "id") ?? "unknown"; - const name = - this.toolEventName( - stringField(part, "toolName") ?? "", - eventNames, - ) || - toolNames.get(toolCallId) || - "unknown"; - emit({ - type: "tool", - requestId, - toolCallId, - name, - state: "input-available", - input: part.input, - }); - break; - } - case "tool-result": { - const toolCallId = stringField(part, "toolCallId", "id") ?? "unknown"; - emit({ - type: "tool", - requestId, - toolCallId, - name: - this.toolEventName( - stringField(part, "toolName") ?? "", - eventNames, - ) || - toolNames.get(toolCallId) || - "unknown", - state: "output-available", - output: part.output, - }); - break; - } - case "tool-error": { - const toolCallId = stringField(part, "toolCallId", "id") ?? "unknown"; - emit({ - type: "tool", - requestId, - toolCallId, - name: - this.toolEventName( - stringField(part, "toolName") ?? "", - eventNames, - ) || - toolNames.get(toolCallId) || - "unknown", - state: "output-error", - error: serializeLocalAiError(part.error), - }); - break; - } - case "abort": - emit({ type: "finish", requestId, finishReason: "aborted" }); - terminalEventEmitted = true; - break; - case "error": - emit({ - type: "error", - requestId, - error: serializeLocalAiError(part.error), - }); - emit({ type: "finish", requestId, finishReason: "error" }); - terminalEventEmitted = true; - break; - case "finish": - emit({ - type: "finish", - requestId, - finishReason: controller.signal.aborted - ? "aborted" - : finishReason(part.finishReason), - usage: usageFrom(part.totalUsage), - }); - terminalEventEmitted = true; - break; - } - - if (terminalEventEmitted) { - break; + let streamedFinishReason: LocalAIFinishReason = "unknown"; + + for await (const chunk of result.toUIMessageStream({ + onError: (error) => serializeLocalAiError(error).message, + })) { + const qualifiedChunk = this.qualifyToolChunk(chunk, eventNames); + if (qualifiedChunk.type === "finish") { + streamedFinishReason = finishReason(qualifiedChunk.finishReason); + } else if (qualifiedChunk.type === "error") { + streamedFinishReason = "error"; } + emit({ type: "ui-message", requestId, chunk: qualifiedChunk }); } - if (!terminalEventEmitted) { - emit({ - type: "finish", - requestId, - finishReason: controller.signal.aborted ? "aborted" : "unknown", - }); - } + const resolvedFinishReason = result.finishReason + ? finishReason(await result.finishReason) + : streamedFinishReason; + const usage = result.usage ? usageFrom(await result.usage) : undefined; + emit({ + type: "finish", + requestId, + finishReason: controller.signal.aborted + ? "aborted" + : resolvedFinishReason, + usage, + }); } private emitFailure( @@ -676,4 +560,21 @@ export class LocalAiRuntime implements LocalAIRuntimeService { } return providerName; } + + private qualifyToolChunk( + chunk: UIMessageChunk, + eventNames: Map, + ): UIMessageChunk { + switch (chunk.type) { + case "tool-input-start": + case "tool-input-available": + case "tool-input-error": + return { + ...chunk, + toolName: this.toolEventName(chunk.toolName, eventNames), + }; + default: + return chunk; + } + } } diff --git a/packages/app/src/electron/mcp/runtime-catalog.test.ts b/packages/app/src/electron/mcp/runtime-catalog.test.ts index 4fb4d336..c5e77b07 100644 --- a/packages/app/src/electron/mcp/runtime-catalog.test.ts +++ b/packages/app/src/electron/mcp/runtime-catalog.test.ts @@ -38,9 +38,9 @@ describe("main-process agent tool catalog", () => { return getAllTools(); }, streamInvoker: () => ({ - fullStream: (async function* () { - yield { type: "finish", finishReason: "stop" }; - })(), + toUIMessageStream: async function* () { + yield { type: "finish" as const, finishReason: "stop" as const }; + }, }), }); diff --git a/packages/app/src/renderer/components/chat/message/chat-content.tsx b/packages/app/src/renderer/components/chat/message/chat-content.tsx index ec783441..c987ff84 100644 --- a/packages/app/src/renderer/components/chat/message/chat-content.tsx +++ b/packages/app/src/renderer/components/chat/message/chat-content.tsx @@ -1,7 +1,16 @@ import { BaseLogo } from "@/renderer/components/common/base-logo"; import { Markdown } from "@/renderer/components/common/markdown"; import { SelectedContent } from "@/renderer/libs/stores/chat-store"; -import type { UIMessage } from "@/renderer/types/chat"; +import type { + MessagePart, + ToolInvocation, + UIMessage, +} from "@/renderer/types/chat"; +import { + getToolName, + isToolUIPart, + type UIMessage as AISDKUIMessage, +} from "ai"; import { AnimatePresence, motion } from "framer-motion"; import { Loader2 } from "lucide-react"; import React, { useCallback, useEffect, useState } from "react"; @@ -10,20 +19,56 @@ import { TOOL_COMPONENTS } from "../tools"; import ChatMessage from "./chat-message"; import ToolCall from "./tool-call"; -/** - * Type definitions for tool invocations - */ -interface ToolInvocation { - toolCallId: string; - toolName: string; - state: "partial-call" | "call" | "result"; - args?: Record; - result?: string | { message?: string; [key: string]: unknown }; +function normalizeToolArgs(input: unknown): Record { + if (input && typeof input === "object" && !Array.isArray(input)) { + return input as Record; + } + if (typeof input === "string") { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return { input }; + } + } + return input === undefined ? {} : { input }; } -interface ToolPart { - type: "tool-invocation"; - toolInvocation: ToolInvocation; +function toToolInvocation(part: MessagePart): ToolInvocation | undefined { + if ("toolInvocation" in part) { + return part.toolInvocation; + } + + const sdkPart = part as AISDKUIMessage["parts"][number]; + if (!isToolUIPart(sdkPart)) return undefined; + + const completed = + sdkPart.state === "output-available" || + sdkPart.state === "output-error" || + sdkPart.state === "output-denied"; + const result = + sdkPart.state === "output-available" + ? sdkPart.output + : sdkPart.state === "output-error" + ? { error: sdkPart.errorText } + : sdkPart.state === "output-denied" + ? { error: "Tool execution was denied." } + : undefined; + + return { + toolCallId: sdkPart.toolCallId, + toolName: getToolName(sdkPart), + args: normalizeToolArgs(sdkPart.input), + state: + sdkPart.state === "input-streaming" + ? "partial-call" + : completed + ? "result" + : "call", + ...(completed ? { result } : {}), + }; } interface ChatContentProps { @@ -208,10 +253,9 @@ export default function ChatContent({ ); // Render tool calls with detailed information - const renderToolCall = useCallback((part: ToolPart, index: number) => { - if (!part.toolInvocation) return null; - - const toolInvocation = part.toolInvocation; + const renderToolCall = useCallback((part: MessagePart, index: number) => { + const toolInvocation = toToolInvocation(part); + if (!toolInvocation) return null; const toolName = toolInvocation.toolName || "Tool"; const rendererName = toolName.includes(":") ? toolName.slice(toolName.lastIndexOf(":") + 1) @@ -233,16 +277,22 @@ export default function ChatContent({ const args = toolInvocation.args || {}; let result = "Pending result..."; - if (toolInvocation.result) { + if (toolInvocation.result !== undefined) { if (typeof toolInvocation.result === "string") { result = toolInvocation.result; - } else if (typeof toolInvocation.result === "object") { + } else if ( + toolInvocation.result && + typeof toolInvocation.result === "object" + ) { // Try to extract message from result object if it exists - if (toolInvocation.result.message) { - result = toolInvocation.result.message as string; + const resultObject = toolInvocation.result as Record; + if (resultObject.message) { + result = String(resultObject.message); } else { result = JSON.stringify(toolInvocation.result, null, 2); } + } else { + result = String(toolInvocation.result); } } @@ -277,11 +327,8 @@ export default function ChatContent({ {renderMessageContent(part.text, message.id, isStreaming)}
, ); - } else if ( - part.type === "tool-invocation" && - "toolInvocation" in part - ) { - contentElements.push(renderToolCall(part as ToolPart, index)); + } else if (toToolInvocation(part)) { + contentElements.push(renderToolCall(part, index)); } }); diff --git a/packages/app/src/renderer/libs/db/database.ts b/packages/app/src/renderer/libs/db/database.ts index f0cd3ee9..964525a8 100644 --- a/packages/app/src/renderer/libs/db/database.ts +++ b/packages/app/src/renderer/libs/db/database.ts @@ -40,6 +40,7 @@ export interface Message { conversationId: string; role: "user" | "assistant" | "system" | "tool"; content: string; + parts?: unknown[]; toolInvocations?: unknown[]; experimental_attachments?: Array<{ url: string; diff --git a/packages/app/src/renderer/libs/db/hooks.ts b/packages/app/src/renderer/libs/db/hooks.ts index 44ad2c9a..39922fa4 100644 --- a/packages/app/src/renderer/libs/db/hooks.ts +++ b/packages/app/src/renderer/libs/db/hooks.ts @@ -457,6 +457,7 @@ export async function branchFromMessage( conversationId: newConvId, role: msg.role, content: msg.content, + parts: msg.parts, toolInvocations: msg.toolInvocations, experimental_attachments: msg.experimental_attachments, createdAt: new Date(baseTime + index), diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts index 648a6195..870a1ebd 100644 --- a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts @@ -4,6 +4,10 @@ import type { LocalAIChatRequest, LocalAIStreamEvent, } from "@/shared/types/local-ai"; +import { + createLocalAIUIMessageStream, + type LocalAIUIMessageStream, +} from "../local-ai-ui-stream"; import { getLocalAI, type LocalAIProviderId } from "../local-ai"; import { useUserInputStore } from "../stores/user-input-store"; @@ -56,40 +60,6 @@ function toRequestMessages(messages: Message[]) { })); } -function applyToolEvent( - message: Message, - event: Extract, -): Message { - const existing = message.toolInvocations || []; - const withoutCurrent = existing.filter( - (tool) => tool.toolCallId !== event.toolCallId, - ); - const base = { - toolCallId: event.toolCallId, - toolName: event.name, - args: - event.input && typeof event.input === "object" - ? (event.input as Record) - : {}, - }; - - const next = - event.state === "output-available" - ? { ...base, state: "result" as const, result: event.output } - : event.state === "output-error" - ? { - ...base, - state: "result" as const, - result: { error: event.error?.message }, - } - : { ...base, state: "call" as const }; - - return { - ...message, - toolInvocations: [...withoutCurrent, next], - }; -} - export function useLocalAIChat(): UseLocalAIChatResult { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -97,40 +67,32 @@ export function useLocalAIChat(): UseLocalAIChatResult { const [error, setError] = useState(); const activeRequestIdRef = useRef(undefined); const unsubscribeRef = useRef<(() => void) | undefined>(undefined); + const activeUIMessageStreamRef = useRef( + undefined, + ); const releaseSubscription = useCallback(() => { unsubscribeRef.current?.(); unsubscribeRef.current = undefined; }, []); + const closeUIMessageStream = useCallback(async () => { + const stream = activeUIMessageStreamRef.current; + if (!stream) return; + stream.close(); + await stream.done; + if (activeUIMessageStreamRef.current === stream) { + activeUIMessageStreamRef.current = undefined; + } + }, []); + const handleEvent = useCallback( - (assistantMessageId: string, event: LocalAIStreamEvent) => { + (event: LocalAIStreamEvent) => { if (event.requestId !== activeRequestIdRef.current) return; - if (event.type === "delta") { - setStatus("streaming"); - setMessages((current) => - current.map((message) => - message.id === assistantMessageId - ? { - ...message, - content: `${message.content}${event.text}`, - } - : message, - ), - ); - return; - } - - if (event.type === "tool") { + if (event.type === "ui-message") { setStatus("streaming"); - setMessages((current) => - current.map((message) => - message.id === assistantMessageId - ? applyToolEvent(message, event) - : message, - ), - ); + activeUIMessageStreamRef.current?.push(event.chunk); return; } @@ -167,10 +129,18 @@ export function useLocalAIChat(): UseLocalAIChatResult { return; } - setStatus(event.finishReason === "error" ? "error" : "ready"); - useUserInputStore.getState().dismissRequest(event.requestId); - activeRequestIdRef.current = undefined; - releaseSubscription(); + const stream = activeUIMessageStreamRef.current; + stream?.close(); + void (stream?.done ?? Promise.resolve()).finally(() => { + if (activeRequestIdRef.current !== event.requestId) return; + if (activeUIMessageStreamRef.current === stream) { + activeUIMessageStreamRef.current = undefined; + } + setStatus(event.finishReason === "error" ? "error" : "ready"); + useUserInputStore.getState().dismissRequest(event.requestId); + activeRequestIdRef.current = undefined; + releaseSubscription(); + }); }, [releaseSubscription], ); @@ -196,6 +166,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { useUserInputStore.getState().dismissRequest(previousRequestId); releaseSubscription(); activeRequestIdRef.current = undefined; + await closeUIMessageStream(); } const requestId = crypto.randomUUID(); @@ -206,13 +177,29 @@ export function useLocalAIChat(): UseLocalAIChatResult { content: "", createdAt: new Date(), }; + const uiMessageStream = createLocalAIUIMessageStream({ + messageId: assistantMessageId, + createdAt: assistantMessage.createdAt!, + onMessage: (message) => { + setMessages((current) => + current.map((candidate) => + candidate.id === assistantMessageId ? message : candidate, + ), + ); + }, + onError: (streamError) => { + setError(streamError); + setStatus("error"); + }, + }); setError(undefined); setStatus("submitted"); setMessages([...nextMessages, assistantMessage]); activeRequestIdRef.current = requestId; + activeUIMessageStreamRef.current = uiMessageStream; unsubscribeRef.current = localAI.onEvent(requestId, (event) => { - handleEvent(assistantMessageId, event); + handleEvent(event); }); try { @@ -240,9 +227,10 @@ export function useLocalAIChat(): UseLocalAIChatResult { useUserInputStore.getState().dismissRequest(requestId); activeRequestIdRef.current = undefined; releaseSubscription(); + await closeUIMessageStream(); } }, - [handleEvent, releaseSubscription], + [closeUIMessageStream, handleEvent, releaseSubscription], ); const send = useCallback( @@ -284,6 +272,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { useUserInputStore.getState().dismissRequest(requestId); activeRequestIdRef.current = undefined; releaseSubscription(); + await closeUIMessageStream(); setStatus("ready"); } } catch (abortError) { @@ -294,13 +283,15 @@ export function useLocalAIChat(): UseLocalAIChatResult { ); setStatus("error"); } - }, [releaseSubscription]); + }, [closeUIMessageStream, releaseSubscription]); useEffect( () => () => { const requestId = activeRequestIdRef.current; const localAI = getLocalAI(); releaseSubscription(); + activeUIMessageStreamRef.current?.close(); + activeUIMessageStreamRef.current = undefined; if (requestId && localAI) { useUserInputStore.getState().dismissRequest(requestId); void localAI.abort(requestId); diff --git a/packages/app/src/renderer/libs/local-ai-ui-stream.test.ts b/packages/app/src/renderer/libs/local-ai-ui-stream.test.ts new file mode 100644 index 00000000..baff8244 --- /dev/null +++ b/packages/app/src/renderer/libs/local-ai-ui-stream.test.ts @@ -0,0 +1,69 @@ +import type { Message } from "@/renderer/types/chat"; +import { describe, expect, it, vi } from "vitest"; +import { createLocalAIUIMessageStream } from "./local-ai-ui-stream"; + +describe("createLocalAIUIMessageStream", () => { + it("lets AI SDK assemble text and tool lifecycle parts", async () => { + const messages: Message[] = []; + const onError = vi.fn(); + const stream = createLocalAIUIMessageStream({ + messageId: "assistant-1", + createdAt: new Date(0), + onMessage: (message) => messages.push(message), + onError, + }); + + stream.push({ type: "start", messageId: "assistant-1" }); + stream.push({ type: "start-step" }); + stream.push({ type: "text-start", id: "text-1" }); + stream.push({ type: "text-delta", id: "text-1", delta: "Working" }); + stream.push({ type: "text-end", id: "text-1" }); + stream.push({ + type: "tool-input-start", + toolCallId: "tool-1", + toolName: "builtin:execute_command", + dynamic: true, + }); + stream.push({ + type: "tool-input-delta", + toolCallId: "tool-1", + inputTextDelta: '{"command":"pwd"}', + }); + stream.push({ + type: "tool-input-available", + toolCallId: "tool-1", + toolName: "builtin:execute_command", + input: { command: "pwd" }, + dynamic: true, + }); + stream.push({ + type: "tool-output-available", + toolCallId: "tool-1", + output: { stdout: "/workspace" }, + dynamic: true, + }); + stream.push({ type: "finish-step" }); + stream.push({ type: "finish", finishReason: "stop" }); + stream.close(); + await stream.done; + + expect(onError).not.toHaveBeenCalled(); + expect(messages.at(-1)).toMatchObject({ + id: "assistant-1", + role: "assistant", + content: "Working", + parts: [ + { type: "step-start" }, + { type: "text", text: "Working", state: "done" }, + { + type: "dynamic-tool", + toolCallId: "tool-1", + toolName: "builtin:execute_command", + state: "output-available", + input: { command: "pwd" }, + output: { stdout: "/workspace" }, + }, + ], + }); + }); +}); diff --git a/packages/app/src/renderer/libs/local-ai-ui-stream.ts b/packages/app/src/renderer/libs/local-ai-ui-stream.ts new file mode 100644 index 00000000..d43f36c0 --- /dev/null +++ b/packages/app/src/renderer/libs/local-ai-ui-stream.ts @@ -0,0 +1,81 @@ +import type { Message } from "@/renderer/types/chat"; +import { + readUIMessageStream, + type UIMessage as AISDKUIMessage, + type UIMessageChunk, +} from "ai"; + +export interface LocalAIUIMessageStream { + push(chunk: UIMessageChunk): void; + close(): void; + done: Promise; +} + +function toRendererMessage(message: AISDKUIMessage, createdAt: Date): Message { + return { + id: message.id, + role: message.role, + content: message.parts + .filter( + ( + part, + ): part is Extract<(typeof message.parts)[number], { type: "text" }> => + part.type === "text", + ) + .map((part) => part.text) + .join(""), + parts: message.parts, + createdAt, + }; +} + +export function createLocalAIUIMessageStream(options: { + messageId: string; + createdAt: Date; + onMessage(message: Message): void; + onError(error: Error): void; +}): LocalAIUIMessageStream { + let controller: ReadableStreamDefaultController | undefined; + let closed = false; + const stream = new ReadableStream({ + start(streamController) { + controller = streamController; + }, + }); + + const done = (async () => { + try { + for await (const message of readUIMessageStream({ + message: { + id: options.messageId, + role: "assistant", + parts: [], + }, + stream, + onError: (error) => { + options.onError( + error instanceof Error ? error : new Error(String(error)), + ); + }, + })) { + options.onMessage(toRendererMessage(message, options.createdAt)); + } + } catch (error) { + options.onError( + error instanceof Error ? error : new Error(String(error)), + ); + } + })(); + + return { + push(chunk) { + if (!closed) controller?.enqueue(chunk); + }, + close() { + if (closed) return; + closed = true; + controller?.close(); + }, + done, + }; +} diff --git a/packages/app/src/renderer/libs/stores/chat-history-store.ts b/packages/app/src/renderer/libs/stores/chat-history-store.ts index 79f4ad76..fa23a871 100644 --- a/packages/app/src/renderer/libs/stores/chat-history-store.ts +++ b/packages/app/src/renderer/libs/stores/chat-history-store.ts @@ -131,6 +131,7 @@ export function useChatHistoryStore() { typeof m.content === "string" ? m.content : JSON.stringify(m.content), + parts: m.parts, toolInvocations: m.toolInvocations, experimental_attachments: m.experimental_attachments?.map((a) => ({ url: a.url, @@ -151,6 +152,7 @@ export function useChatHistoryStore() { typeof message.content === "string" ? message.content : JSON.stringify(message.content), + parts: message.parts, toolInvocations: message.toolInvocations, experimental_attachments: message.experimental_attachments?.map( (a) => ({ @@ -187,6 +189,7 @@ export function useChatHistory( id: m.id, role: m.role as "user" | "assistant" | "system" | "data", content: m.content, + parts: m.parts as Message["parts"], toolInvocations: m.toolInvocations as Message["toolInvocations"], experimental_attachments: m.experimental_attachments as Message["experimental_attachments"], diff --git a/packages/app/src/renderer/libs/stores/chat-store.tsx b/packages/app/src/renderer/libs/stores/chat-store.tsx index 5634c4f8..4f609f7c 100644 --- a/packages/app/src/renderer/libs/stores/chat-store.tsx +++ b/packages/app/src/renderer/libs/stores/chat-store.tsx @@ -201,6 +201,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ typeof m.content === "string" ? m.content : JSON.stringify(m.content), + parts: m.parts, toolInvocations: m.toolInvocations, experimental_attachments: m.experimental_attachments?.map( (a: Attachment) => ({ diff --git a/packages/app/src/renderer/types/chat.ts b/packages/app/src/renderer/types/chat.ts index 611e1b20..df29ebb2 100644 --- a/packages/app/src/renderer/types/chat.ts +++ b/packages/app/src/renderer/types/chat.ts @@ -1,3 +1,5 @@ +import type { UIMessage as AISDKUIMessage } from "ai"; + export interface Attachment { url: string; name?: string; @@ -12,7 +14,7 @@ export interface ToolInvocation { result?: unknown; } -export type MessagePart = +export type LegacyMessagePart = | { type: "text"; text: string; @@ -22,6 +24,8 @@ export type MessagePart = toolInvocation: ToolInvocation; }; +export type MessagePart = AISDKUIMessage["parts"][number] | LegacyMessagePart; + /** * Renderer and Dexie use the stable, content-based message shape that Convera * persisted before AI SDK 6. Provider-specific ModelMessage conversion happens diff --git a/packages/app/src/shared/types/local-ai.ts b/packages/app/src/shared/types/local-ai.ts index 61eba8e2..1fbe6e8a 100644 --- a/packages/app/src/shared/types/local-ai.ts +++ b/packages/app/src/shared/types/local-ai.ts @@ -4,6 +4,8 @@ * or other process-owned objects on this boundary. */ +import type { UIMessageChunk } from "ai"; + export type LocalAIProviderKind = | "claude-code" | "codex-cli" @@ -72,12 +74,6 @@ export interface LocalAIInteractionResponse { value?: string; } -export type LocalAIToolState = - | "input-streaming" - | "input-available" - | "output-available" - | "output-error"; - export type LocalAIFinishReason = | "stop" | "length" @@ -89,19 +85,9 @@ export type LocalAIFinishReason = export type LocalAIStreamEvent = | { - type: "delta"; + type: "ui-message"; requestId: string; - text: string; - } - | { - type: "tool"; - requestId: string; - toolCallId: string; - name: string; - state: LocalAIToolState; - input?: unknown; - output?: unknown; - error?: LocalAISerializableError; + chunk: UIMessageChunk; } | { type: "error"; From 21954298f21a3cb46d41084fa9580501b63a5bca Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 19:55:12 +0800 Subject: [PATCH 8/9] refactor(app): remove legacy tool call rendering --- .../components/chat/message/chat-content.tsx | 145 ++++------------- .../components/chat/message/tool-call.tsx | 25 +-- .../components/chat/tools/ask-user-input.tsx | 23 ++- .../components/chat/tools/execute-command.tsx | 25 +-- .../components/chat/tools/tool-part.test.ts | 51 ++++++ .../components/chat/tools/tool-part.ts | 48 ++++++ .../components/chat/tools/web-fetch.tsx | 151 +++++++++--------- .../components/chat/tools/web-search.tsx | 25 +-- .../app/src/renderer/components/chat/types.ts | 42 ----- packages/app/src/renderer/libs/db/database.ts | 1 - packages/app/src/renderer/libs/db/hooks.ts | 1 - .../libs/stores/chat-history-store.ts | 3 - .../src/renderer/libs/stores/chat-store.tsx | 1 - packages/app/src/renderer/types/chat.ts | 21 +-- 14 files changed, 256 insertions(+), 306 deletions(-) create mode 100644 packages/app/src/renderer/components/chat/tools/tool-part.test.ts create mode 100644 packages/app/src/renderer/components/chat/tools/tool-part.ts delete mode 100644 packages/app/src/renderer/components/chat/types.ts diff --git a/packages/app/src/renderer/components/chat/message/chat-content.tsx b/packages/app/src/renderer/components/chat/message/chat-content.tsx index c987ff84..e9646530 100644 --- a/packages/app/src/renderer/components/chat/message/chat-content.tsx +++ b/packages/app/src/renderer/components/chat/message/chat-content.tsx @@ -1,76 +1,17 @@ import { BaseLogo } from "@/renderer/components/common/base-logo"; import { Markdown } from "@/renderer/components/common/markdown"; import { SelectedContent } from "@/renderer/libs/stores/chat-store"; -import type { - MessagePart, - ToolInvocation, - UIMessage, -} from "@/renderer/types/chat"; -import { - getToolName, - isToolUIPart, - type UIMessage as AISDKUIMessage, -} from "ai"; +import type { UIMessage } from "@/renderer/types/chat"; +import { getToolName, isToolUIPart } from "ai"; import { AnimatePresence, motion } from "framer-motion"; import { Loader2 } from "lucide-react"; import React, { useCallback, useEffect, useState } from "react"; import ModifiedContentBlock from "../selected/modified-content-block"; import { TOOL_COMPONENTS } from "../tools"; +import type { ToolMessagePart } from "../tools/tool-part"; import ChatMessage from "./chat-message"; import ToolCall from "./tool-call"; -function normalizeToolArgs(input: unknown): Record { - if (input && typeof input === "object" && !Array.isArray(input)) { - return input as Record; - } - if (typeof input === "string") { - try { - const parsed = JSON.parse(input); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - return { input }; - } - } - return input === undefined ? {} : { input }; -} - -function toToolInvocation(part: MessagePart): ToolInvocation | undefined { - if ("toolInvocation" in part) { - return part.toolInvocation; - } - - const sdkPart = part as AISDKUIMessage["parts"][number]; - if (!isToolUIPart(sdkPart)) return undefined; - - const completed = - sdkPart.state === "output-available" || - sdkPart.state === "output-error" || - sdkPart.state === "output-denied"; - const result = - sdkPart.state === "output-available" - ? sdkPart.output - : sdkPart.state === "output-error" - ? { error: sdkPart.errorText } - : sdkPart.state === "output-denied" - ? { error: "Tool execution was denied." } - : undefined; - - return { - toolCallId: sdkPart.toolCallId, - toolName: getToolName(sdkPart), - args: normalizeToolArgs(sdkPart.input), - state: - sdkPart.state === "input-streaming" - ? "partial-call" - : completed - ? "result" - : "call", - ...(completed ? { result } : {}), - }; -} - interface ChatContentProps { messages: UIMessage[]; messagesEndRef: React.RefObject; @@ -253,62 +194,34 @@ export default function ChatContent({ ); // Render tool calls with detailed information - const renderToolCall = useCallback((part: MessagePart, index: number) => { - const toolInvocation = toToolInvocation(part); - if (!toolInvocation) return null; - const toolName = toolInvocation.toolName || "Tool"; - const rendererName = toolName.includes(":") - ? toolName.slice(toolName.lastIndexOf(":") + 1) - : toolName; - - // Check if there's a custom renderer for this tool - const CustomRenderer = - TOOL_COMPONENTS[rendererName as keyof typeof TOOL_COMPONENTS]; - if (CustomRenderer) { + const renderToolCall = useCallback( + (toolPart: ToolMessagePart, index: number) => { + const toolName = getToolName(toolPart); + const rendererName = toolName.includes(":") + ? toolName.slice(toolName.lastIndexOf(":") + 1) + : toolName; + + // Check if there's a custom renderer for this tool + const CustomRenderer = + TOOL_COMPONENTS[rendererName as keyof typeof TOOL_COMPONENTS]; + if (CustomRenderer) { + return ( + + ); + } + return ( - ); - } - - // Fallback to generic ToolCall component - const args = toolInvocation.args || {}; - let result = "Pending result..."; - - if (toolInvocation.result !== undefined) { - if (typeof toolInvocation.result === "string") { - result = toolInvocation.result; - } else if ( - toolInvocation.result && - typeof toolInvocation.result === "object" - ) { - // Try to extract message from result object if it exists - const resultObject = toolInvocation.result as Record; - if (resultObject.message) { - result = String(resultObject.message); - } else { - result = JSON.stringify(toolInvocation.result, null, 2); - } - } else { - result = String(toolInvocation.result); - } - } - - const isCompleted = - toolInvocation.state === "result" || !!toolInvocation.result; - - return ( - - ); - }, []); + }, + [], + ); // Renders tool calls and text content in order const renderToolCalls = useCallback( @@ -327,7 +240,7 @@ export default function ChatContent({ {renderMessageContent(part.text, message.id, isStreaming)}
, ); - } else if (toToolInvocation(part)) { + } else if (isToolUIPart(part)) { contentElements.push(renderToolCall(part, index)); } }); diff --git a/packages/app/src/renderer/components/chat/message/tool-call.tsx b/packages/app/src/renderer/components/chat/message/tool-call.tsx index 5a3d316a..44183e92 100644 --- a/packages/app/src/renderer/components/chat/message/tool-call.tsx +++ b/packages/app/src/renderer/components/chat/message/tool-call.tsx @@ -1,22 +1,27 @@ import { ChevronDown, ChevronUp, Code, Loader } from "lucide-react"; +import { getToolName } from "ai"; import React, { useState } from "react"; +import { + formatToolOutput, + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "../tools/tool-part"; interface ToolCallProps { - tool: string; - args: Record; - result: string; - isCompleted?: boolean; + toolPart: ToolMessagePart; } /** * Tool Call component to display tool invocations in an expanded/collapsed view */ -const ToolCall = ({ - tool, - args, - result, - isCompleted = false, -}: ToolCallProps) => { +const ToolCall = ({ toolPart }: ToolCallProps) => { + const tool = getToolName(toolPart); + const args = normalizeToolInput(toolPart.input); + const isCompleted = isToolComplete(toolPart); + const result = formatToolOutput(getToolOutput(toolPart)); + // Auto-collapse completed tool calls const [isExpanded, setIsExpanded] = useState(!isCompleted); diff --git a/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx b/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx index 2b69c854..acae55b5 100644 --- a/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx +++ b/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx @@ -1,9 +1,14 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; -import { ToolInvocation } from "../types"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface AskUserInputRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** @@ -12,20 +17,14 @@ export interface AskUserInputRendererProps { * The actual input UI is handled by AskUserInputOverlay in the input area. */ export const AskUserInputRenderer = memo( - ({ toolInvocation }: AskUserInputRendererProps) => { - const args = toolInvocation.args as { - question?: string; - options?: string[]; - }; + ({ toolPart }: AskUserInputRendererProps) => { + const args = normalizeToolInput(toolPart.input); const question = args?.question || "Waiting for your input..."; - - // Check if completed (has result) - const isCompleted = - toolInvocation.state === "result" && "result" in toolInvocation; + const isCompleted = isToolComplete(toolPart); // If completed, show the question and user's answer if (isCompleted) { - const result = toolInvocation.result; + const result = getToolOutput(toolPart); const resultObject = result && typeof result === "object" ? (result as Record) diff --git a/packages/app/src/renderer/components/chat/tools/execute-command.tsx b/packages/app/src/renderer/components/chat/tools/execute-command.tsx index 207bf611..7f097257 100644 --- a/packages/app/src/renderer/components/chat/tools/execute-command.tsx +++ b/packages/app/src/renderer/components/chat/tools/execute-command.tsx @@ -1,28 +1,29 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; -import { ToolInvocation } from "../types"; import { CodeBlock } from "../../common/code-block"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface ExecuteCommandRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** * Special component for execute-command tool calls */ export const ExecuteCommandRenderer = memo( - ({ toolInvocation }: ExecuteCommandRendererProps) => { - let isCompleted = false; + ({ toolPart }: ExecuteCommandRendererProps) => { + const isCompleted = isToolComplete(toolPart); let result = ""; - let command = ""; + const args = normalizeToolInput(toolPart.input); + const command = String(args.command || ""); - // Extract command from args - command = String(toolInvocation.args?.command || ""); - - // Check if the tool invocation has a result (AI SDK structure) - if (toolInvocation.state === "result" && "result" in toolInvocation) { - isCompleted = true; - const toolResult = toolInvocation.result; + if (isCompleted) { + const toolResult = getToolOutput(toolPart); if (toolResult && typeof toolResult === "object") { const resultObj = toolResult as Record; diff --git a/packages/app/src/renderer/components/chat/tools/tool-part.test.ts b/packages/app/src/renderer/components/chat/tools/tool-part.test.ts new file mode 100644 index 00000000..792bb03d --- /dev/null +++ b/packages/app/src/renderer/components/chat/tools/tool-part.test.ts @@ -0,0 +1,51 @@ +import type { DynamicToolUIPart } from "ai"; +import { describe, expect, it } from "vitest"; +import { + formatToolOutput, + getToolOutput, + isToolComplete, + normalizeToolInput, +} from "./tool-part"; + +describe("AI SDK tool part rendering", () => { + it("reads input and output directly from a completed dynamic tool part", () => { + const part: DynamicToolUIPart = { + type: "dynamic-tool", + toolName: "exec", + toolCallId: "tool-1", + state: "output-available", + input: '{"command":"pwd"}', + output: { stdout: "/workspace", exitCode: 0 }, + }; + + expect(normalizeToolInput(part.input)).toEqual({ command: "pwd" }); + expect(isToolComplete(part)).toBe(true); + expect(formatToolOutput(getToolOutput(part))).toContain('"exitCode": 0'); + }); + + it("uses native AI SDK error and denial states", () => { + const errorPart: DynamicToolUIPart = { + type: "dynamic-tool", + toolName: "exec", + toolCallId: "tool-2", + state: "output-error", + input: { command: "false" }, + errorText: "Command failed", + }; + const deniedPart: DynamicToolUIPart = { + type: "dynamic-tool", + toolName: "exec", + toolCallId: "tool-3", + state: "output-denied", + input: { command: "rm file" }, + approval: { + id: "approval-1", + approved: false, + reason: "Denied by user", + }, + }; + + expect(getToolOutput(errorPart)).toEqual({ error: "Command failed" }); + expect(getToolOutput(deniedPart)).toEqual({ error: "Denied by user" }); + }); +}); diff --git a/packages/app/src/renderer/components/chat/tools/tool-part.ts b/packages/app/src/renderer/components/chat/tools/tool-part.ts new file mode 100644 index 00000000..f3d83eeb --- /dev/null +++ b/packages/app/src/renderer/components/chat/tools/tool-part.ts @@ -0,0 +1,48 @@ +import type { DynamicToolUIPart, ToolUIPart } from "ai"; + +export type ToolMessagePart = ToolUIPart | DynamicToolUIPart; + +export function normalizeToolInput(input: unknown): Record { + if (input && typeof input === "object" && !Array.isArray(input)) { + return input as Record; + } + if (typeof input === "string") { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return { input }; + } + } + return input === undefined ? {} : { input }; +} + +export function isToolComplete(part: ToolMessagePart): boolean { + return ( + part.state === "output-available" || + part.state === "output-error" || + part.state === "output-denied" + ); +} + +export function getToolOutput(part: ToolMessagePart): unknown { + if (part.state === "output-available") return part.output; + if (part.state === "output-error") return { error: part.errorText }; + if (part.state === "output-denied") { + return { error: part.approval.reason || "Tool execution was denied." }; + } + return undefined; +} + +export function formatToolOutput(output: unknown): string { + if (output === undefined) return "Pending result..."; + if (typeof output === "string") return output; + if (output && typeof output === "object") { + const outputObject = output as Record; + if (outputObject.message) return String(outputObject.message); + return JSON.stringify(output, null, 2); + } + return String(output); +} diff --git a/packages/app/src/renderer/components/chat/tools/web-fetch.tsx b/packages/app/src/renderer/components/chat/tools/web-fetch.tsx index 687f2c9e..def956c5 100644 --- a/packages/app/src/renderer/components/chat/tools/web-fetch.tsx +++ b/packages/app/src/renderer/components/chat/tools/web-fetch.tsx @@ -1,102 +1,101 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; import { Markdown } from "../../common/markdown"; -import { ToolInvocation } from "../types"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface WebFetchRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** * Special component for web-fetch tool calls */ -export const WebFetchRenderer = memo( - ({ toolInvocation }: WebFetchRendererProps) => { - let isCompleted = false; - let result = ""; - let url = ""; - let status = ""; - let contentType = ""; +export const WebFetchRenderer = memo(({ toolPart }: WebFetchRendererProps) => { + const isCompleted = isToolComplete(toolPart); + let result = ""; + const args = normalizeToolInput(toolPart.input); + const url = String(args.url || ""); + let status = ""; + let contentType = ""; - // Extract URL from args - url = String(toolInvocation.args?.url || ""); + if (isCompleted) { + const toolResult = getToolOutput(toolPart); - // Check if the tool invocation has a result (AI SDK structure) - if (toolInvocation.state === "result" && "result" in toolInvocation) { - isCompleted = true; - const toolResult = toolInvocation.result; - - if (toolResult && typeof toolResult === "object") { - const resultObject = toolResult as Record; - // Extract status information - if (resultObject.status && resultObject.statusText) { - status = `${resultObject.status} ${resultObject.statusText}`; - } + if (toolResult && typeof toolResult === "object") { + const resultObject = toolResult as Record; + // Extract status information + if (resultObject.status && resultObject.statusText) { + status = `${resultObject.status} ${resultObject.statusText}`; + } - if (resultObject.contentType) { - contentType = String(resultObject.contentType); - } + if (resultObject.contentType) { + contentType = String(resultObject.contentType); + } - // Format the result for display - if (resultObject.success && resultObject.content) { - // Show successful fetch with content - const parts: string[] = []; + // Format the result for display + if (resultObject.success && resultObject.content) { + // Show successful fetch with content + const parts: string[] = []; - if (status) { - parts.push(`Status: ${status}`); - } + if (status) { + parts.push(`Status: ${status}`); + } - if (contentType) { - parts.push(`Content-Type: ${contentType}`); - } + if (contentType) { + parts.push(`Content-Type: ${contentType}`); + } - parts.push(""); // Empty line - parts.push("Content:"); - parts.push(String(resultObject.content)); + parts.push(""); // Empty line + parts.push("Content:"); + parts.push(String(resultObject.content)); - result = parts.join("\n"); - } else if (!resultObject.success) { - // Show error information - result = `Error: ${resultObject.error || resultObject.message || "Failed to fetch"}`; - } else { - // Fallback to message - result = String(resultObject.message || "No content available"); - } - } else if (typeof toolResult === "string") { - result = toolResult; + result = parts.join("\n"); + } else if (!resultObject.success) { + // Show error information + result = `Error: ${resultObject.error || resultObject.message || "Failed to fetch"}`; + } else { + // Fallback to message + result = String(resultObject.message || "No content available"); } + } else if (typeof toolResult === "string") { + result = toolResult; } + } - return ( -
- {/* Tool Call */} -
- ๐ŸŒ Fetching {url} - {!isCompleted && } -
+ return ( +
+ {/* Tool Call */} +
+ ๐ŸŒ Fetching {url} + {!isCompleted && } +
- {/* Results */} - {isCompleted && result && ( -
-
- Response: -
-
- {/* Use markdown for syntax highlighting if it looks like code */} - {contentType.includes("json") || - contentType.includes("xml") || - contentType.includes("html") ? ( - {`\`\`\`${getLanguageFromContentType(contentType)}\n${result}\n\`\`\``} - ) : ( - {`\`\`\`\n${result}\n\`\`\``} - )} -
+ {/* Results */} + {isCompleted && result && ( +
+
+ Response:
- )} -
- ); - }, -); +
+ {/* Use markdown for syntax highlighting if it looks like code */} + {contentType.includes("json") || + contentType.includes("xml") || + contentType.includes("html") ? ( + {`\`\`\`${getLanguageFromContentType(contentType)}\n${result}\n\`\`\``} + ) : ( + {`\`\`\`\n${result}\n\`\`\``} + )} +
+
+ )} +
+ ); +}); /** * Helper to determine language from content type for syntax highlighting diff --git a/packages/app/src/renderer/components/chat/tools/web-search.tsx b/packages/app/src/renderer/components/chat/tools/web-search.tsx index c7a50282..67139ccd 100644 --- a/packages/app/src/renderer/components/chat/tools/web-search.tsx +++ b/packages/app/src/renderer/components/chat/tools/web-search.tsx @@ -1,29 +1,30 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; import { Markdown } from "../../common/markdown"; -import { ToolInvocation } from "../types"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface WebSearchRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** * Special component for web_fetch tool calls */ export const WebSearchRenderer = memo( - ({ toolInvocation }: WebSearchRendererProps) => { - let isCompleted = false; + ({ toolPart }: WebSearchRendererProps) => { + const isCompleted = isToolComplete(toolPart); let result = ""; - // Extract search query from args - const searchQuery = String( - toolInvocation.args?.query || toolInvocation.args?.url || "", - ); + const args = normalizeToolInput(toolPart.input); + const searchQuery = String(args.query || args.url || ""); - // Check if the tool invocation has a result (AI SDK structure) - if (toolInvocation.state === "result" && "result" in toolInvocation) { - isCompleted = true; - const toolResult = toolInvocation.result; + if (isCompleted) { + const toolResult = getToolOutput(toolPart); if (typeof toolResult === "string") { result = toolResult; diff --git a/packages/app/src/renderer/components/chat/types.ts b/packages/app/src/renderer/components/chat/types.ts deleted file mode 100644 index d7a7fd7c..00000000 --- a/packages/app/src/renderer/components/chat/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - MessagePart, - ToolInvocation, - UIMessage, -} from "@/renderer/types/chat"; - -/** - * Re-export AI SDK types for consistency - */ -export type { MessagePart, ToolInvocation, UIMessage }; - -/** - * Type guards for different part types - */ -export function isTextPart( - part: MessagePart, -): part is Extract { - return part.type === "text"; -} - -export function isToolInvocationPart( - part: MessagePart, -): part is Extract { - return part.type === "tool-invocation"; -} - -/** - * Component props using AI SDK types - */ -export interface MessagePartRendererProps { - part: MessagePart; - index: number; -} - -export interface ToolCallRendererProps { - toolInvocation: ToolInvocation; - index: number; -} - -export interface MessageContentRendererProps { - message: UIMessage; -} diff --git a/packages/app/src/renderer/libs/db/database.ts b/packages/app/src/renderer/libs/db/database.ts index 964525a8..e647589d 100644 --- a/packages/app/src/renderer/libs/db/database.ts +++ b/packages/app/src/renderer/libs/db/database.ts @@ -41,7 +41,6 @@ export interface Message { role: "user" | "assistant" | "system" | "tool"; content: string; parts?: unknown[]; - toolInvocations?: unknown[]; experimental_attachments?: Array<{ url: string; name: string; diff --git a/packages/app/src/renderer/libs/db/hooks.ts b/packages/app/src/renderer/libs/db/hooks.ts index 39922fa4..bc3bffc0 100644 --- a/packages/app/src/renderer/libs/db/hooks.ts +++ b/packages/app/src/renderer/libs/db/hooks.ts @@ -458,7 +458,6 @@ export async function branchFromMessage( role: msg.role, content: msg.content, parts: msg.parts, - toolInvocations: msg.toolInvocations, experimental_attachments: msg.experimental_attachments, createdAt: new Date(baseTime + index), })), diff --git a/packages/app/src/renderer/libs/stores/chat-history-store.ts b/packages/app/src/renderer/libs/stores/chat-history-store.ts index fa23a871..ac4c812d 100644 --- a/packages/app/src/renderer/libs/stores/chat-history-store.ts +++ b/packages/app/src/renderer/libs/stores/chat-history-store.ts @@ -132,7 +132,6 @@ export function useChatHistoryStore() { ? m.content : JSON.stringify(m.content), parts: m.parts, - toolInvocations: m.toolInvocations, experimental_attachments: m.experimental_attachments?.map((a) => ({ url: a.url, name: a.name ?? "", @@ -153,7 +152,6 @@ export function useChatHistoryStore() { ? message.content : JSON.stringify(message.content), parts: message.parts, - toolInvocations: message.toolInvocations, experimental_attachments: message.experimental_attachments?.map( (a) => ({ url: a.url, @@ -190,7 +188,6 @@ export function useChatHistory( role: m.role as "user" | "assistant" | "system" | "data", content: m.content, parts: m.parts as Message["parts"], - toolInvocations: m.toolInvocations as Message["toolInvocations"], experimental_attachments: m.experimental_attachments as Message["experimental_attachments"], createdAt: m.createdAt, diff --git a/packages/app/src/renderer/libs/stores/chat-store.tsx b/packages/app/src/renderer/libs/stores/chat-store.tsx index 4f609f7c..a7fcc8e3 100644 --- a/packages/app/src/renderer/libs/stores/chat-store.tsx +++ b/packages/app/src/renderer/libs/stores/chat-store.tsx @@ -202,7 +202,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ ? m.content : JSON.stringify(m.content), parts: m.parts, - toolInvocations: m.toolInvocations, experimental_attachments: m.experimental_attachments?.map( (a: Attachment) => ({ url: a.url, diff --git a/packages/app/src/renderer/types/chat.ts b/packages/app/src/renderer/types/chat.ts index df29ebb2..49b19cbb 100644 --- a/packages/app/src/renderer/types/chat.ts +++ b/packages/app/src/renderer/types/chat.ts @@ -6,25 +6,7 @@ export interface Attachment { contentType?: string; } -export interface ToolInvocation { - toolCallId: string; - toolName: string; - args?: Record; - state: "partial-call" | "call" | "result"; - result?: unknown; -} - -export type LegacyMessagePart = - | { - type: "text"; - text: string; - } - | { - type: "tool-invocation"; - toolInvocation: ToolInvocation; - }; - -export type MessagePart = AISDKUIMessage["parts"][number] | LegacyMessagePart; +export type MessagePart = AISDKUIMessage["parts"][number]; /** * Renderer and Dexie use the stable, content-based message shape that Convera @@ -37,7 +19,6 @@ export interface UIMessage { content: string; createdAt?: Date; parts?: MessagePart[]; - toolInvocations?: ToolInvocation[]; experimental_attachments?: Attachment[]; } From cfe362ce5af76e607cab9ecc082114d2c84fb308 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 21:25:35 +0800 Subject: [PATCH 9/9] fix(app): keep local tool rendering type-safe --- .../ai/__tests__/codex-cli-mcp.test.ts | 23 +++++++++++++++++-- .../components/chat/tools/ask-user-input.tsx | 5 +++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts index 7065824d..09c39fa1 100644 --- a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts +++ b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts @@ -33,6 +33,25 @@ vi.mock("ai-sdk-provider-codex-cli", () => ({ tool: mocks.tool, })); +function providerSettings() { + const calls = mocks.provider.mock.calls as unknown as Array< + [ + string, + { + mcpServers?: { convera?: unknown }; + serverRequests?: { + onMcpElicitation?: (request: { + id: number; + method: string; + params: Record; + }) => Promise; + }; + }, + ] + >; + return calls.at(-1)?.[1]; +} + describe("CodexCliAdapter MCP transport", () => { it("attaches Convera tools without the obsolete RMCP feature flag", async () => { const adapter = new CodexCliAdapter(); @@ -79,7 +98,7 @@ describe("CodexCliAdapter MCP transport", () => { mcpServers: { convera: mcpServer }, }), ); - expect(mocks.provider.mock.calls[0]?.[1]).not.toHaveProperty("rmcpClient"); + expect(providerSettings()).not.toHaveProperty("rmcpClient"); await adapter.dispose(); }); @@ -117,7 +136,7 @@ describe("CodexCliAdapter MCP transport", () => { requestInteraction: vi.fn(async () => ({ approved: false })), }); - const settings = mocks.provider.mock.calls.at(-1)?.[1]; + const settings = providerSettings(); const handler = settings?.serverRequests?.onMcpElicitation; expect(handler).toBeTypeOf("function"); await expect( diff --git a/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx b/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx index acae55b5..4c9e60b1 100644 --- a/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx +++ b/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx @@ -19,7 +19,10 @@ export interface AskUserInputRendererProps { export const AskUserInputRenderer = memo( ({ toolPart }: AskUserInputRendererProps) => { const args = normalizeToolInput(toolPart.input); - const question = args?.question || "Waiting for your input..."; + const question = + typeof args?.question === "string" + ? args.question + : "Waiting for your input..."; const isCompleted = isToolComplete(toolPart); // If completed, show the question and user's answer