From 9fe6b920c4ef3e90a9c795e94f077bef23698dd9 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 6 Feb 2026 22:23:44 +0800 Subject: [PATCH 1/4] fix(agent): always provide getApiKey callback and emit error events PiAgentCore was created with an empty object when no API key was initially configured. This broke dynamic provider switching because setProvider() updated currentApiKey but PiAgentCore had no getApiKey callback to read it. Always provide the callback so it dynamically reads the current key. Also adds AgentErrorEvent to MulticaEvent and emits it from AsyncAgent.write() catch handlers so errors flow through the subscriber mechanism to IPC listeners. Co-Authored-By: Claude Opus 4.6 --- src/agent/async-agent.ts | 3 +++ src/agent/events.ts | 12 +++++++++--- src/agent/runner.ts | 25 +++++++++++++++++-------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index c1eb9a5e..c1b30ea4 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -51,11 +51,14 @@ export class AsyncAgent { // Normal text is delivered via message_end event; only handle errors here if (result.error) { this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); + this.agent.emitError(result.error); } }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); this.channel.send({ id: uuidv7(), content: `[error] ${message}` }); + // Also emit through subscriber mechanism so IPC listeners receive the error + this.agent.emitError(message); }) .finally(() => { this.pendingWrites = Math.max(0, this.pendingWrites - 1); diff --git a/src/agent/events.ts b/src/agent/events.ts index 8eb8b422..3ae35b64 100644 --- a/src/agent/events.ts +++ b/src/agent/events.ts @@ -21,10 +21,16 @@ export type CompactionEndEvent = { type: "compaction_end"; removed: number; kept: number; - tokensRemoved?: number; - tokensKept?: number; + tokensRemoved?: number | undefined; + tokensKept?: number | undefined; reason: "count" | "tokens" | "summary" | "pruning"; }; +/** Emitted when an agent encounters an error during execution */ +export type AgentErrorEvent = { + type: "agent_error"; + message: string; +}; + /** Union of all Multica-specific events */ -export type MulticaEvent = CompactionStartEvent | CompactionEndEvent; +export type MulticaEvent = CompactionStartEvent | CompactionEndEvent | AgentErrorEvent; diff --git a/src/agent/runner.ts b/src/agent/runner.ts index d364f0fa..c6fa4566 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -1,7 +1,7 @@ import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core"; import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js"; -import type { MulticaEvent } from "./events.js"; +import type { MulticaEvent, CompactionEndEvent } from "./events.js"; import { createAgentOutput } from "./cli/output.js"; import { resolveModel, resolveTools, type ResolveToolsOptions } from "./tools.js"; import { @@ -159,11 +159,14 @@ export class Agent { : 0; } - this.agent = new PiAgentCore( - this.currentApiKey - ? { getApiKey: (_provider: string) => this.currentApiKey! } - : {}, - ); + this.agent = new PiAgentCore({ + getApiKey: (_provider: string) => { + if (!this.currentApiKey) { + throw new Error(`No API key configured for provider: ${this.resolvedProvider}`); + } + return this.currentApiKey; + }, + }); // Load Agent Profile (if profileId is specified) // Every Agent should have a Profile for memory, tools config, and other settings @@ -352,6 +355,11 @@ export class Agent { } } + /** Emit an error event through the subscriber mechanism */ + emitError(message: string): void { + this.emitMulticaEvent({ type: "agent_error", message }); + } + async run(prompt: string): Promise { await this.ensureInitialized(); this.output.state.lastAssistantText = ""; @@ -461,14 +469,15 @@ export class Agent { if (result?.kept) { this.agent.replaceMessages(result.kept); } - this.emitMulticaEvent({ + const endEvent: CompactionEndEvent = { type: "compaction_end", removed: result?.removedCount ?? 0, kept: result?.kept.length ?? messages.length, tokensRemoved: result?.tokensRemoved, tokensKept: result?.tokensKept, reason: result?.reason ?? "tokens", - }); + }; + this.emitMulticaEvent(endEvent); } catch (err) { throw err; } From 0e383f51ab7785363402d36d517d766c7bc0ae49 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 6 Feb 2026 22:23:53 +0800 Subject: [PATCH 2/4] feat(desktop): display agent errors in Chat UI instead of hanging When the agent encounters an error (e.g. no API key configured), the Chat UI now shows an error banner instead of silently hanging. The user can still type and retry after fixing their configuration. - Add AgentErrorEvent to SDK stream types - Forward agent_error events through IPC to renderer - Handle error events in useLocalChat hook - Keep chat input enabled for AGENT_ERROR (retriable) Co-Authored-By: Claude Opus 4.6 --- apps/desktop/electron/ipc/hub.ts | 11 +++++++++++ apps/desktop/src/hooks/use-local-chat.ts | 9 +++++++++ packages/sdk/src/actions/index.ts | 1 + packages/sdk/src/actions/stream.ts | 10 ++++++++-- packages/ui/src/components/chat-view.tsx | 4 ++-- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index b038efe8..ca972cef 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -281,6 +281,17 @@ export function registerHubIpcHandlers(): void { return } + // Agent error events: forward so the UI can display them + if (event.type === 'agent_error') { + safeLog(`[IPC] Sending agent_error event to renderer: ${(event as { message: string }).message}`) + mainWindowRef.webContents.send('localChat:event', { + agentId, + streamId: null, + event, + }) + return + } + // Filter events same as Hub.consumeAgent() const maybeMessage = (event as { message?: { role?: string } }).message const isAssistantMessage = maybeMessage?.role === 'assistant' diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index cf637f25..c6353e12 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -5,6 +5,7 @@ import type { ExecApprovalRequestPayload, ApprovalDecision, AgentMessageItem, + AgentErrorEvent, } from '@multica/sdk' import { DEFAULT_MESSAGES_LIMIT } from '@multica/sdk' @@ -56,6 +57,14 @@ export function useLocalChat() { const payload = data as unknown as StreamPayload if (!payload.event) return + // Handle agent error events + if (payload.event.type === 'agent_error') { + const errorEvent = payload.event as AgentErrorEvent + chatRef.current.setError({ code: 'AGENT_ERROR', message: errorEvent.message }) + setIsLoading(false) + return + } + chatRef.current.handleStream(payload) if (payload.event.type === 'message_start') setIsLoading(true) if (payload.event.type === 'message_end') setIsLoading(false) diff --git a/packages/sdk/src/actions/index.ts b/packages/sdk/src/actions/index.ts index c378a6d6..b3ef94e3 100644 --- a/packages/sdk/src/actions/index.ts +++ b/packages/sdk/src/actions/index.ts @@ -38,6 +38,7 @@ export { type CompactionEvent, type CompactionStartEvent, type CompactionEndEvent, + type AgentErrorEvent, type ContentBlock, type TextContent, type ThinkingContent, diff --git a/packages/sdk/src/actions/stream.ts b/packages/sdk/src/actions/stream.ts index c0249353..dfaf06fa 100644 --- a/packages/sdk/src/actions/stream.ts +++ b/packages/sdk/src/actions/stream.ts @@ -45,16 +45,22 @@ export type CompactionEndEvent = { /** Union of all compaction events */ export type CompactionEvent = CompactionStartEvent | CompactionEndEvent; +/** Emitted when an agent encounters an error during execution */ +export type AgentErrorEvent = { + type: "agent_error"; + message: string; +}; + // --- Stream event types --- /** - * Hub forwards AgentEvent from pi-agent-core and CompactionEvent as-is. + * Hub forwards AgentEvent from pi-agent-core, CompactionEvent, and AgentErrorEvent as-is. * StreamPayload wraps them with routing metadata. */ export interface StreamPayload { streamId: string; agentId: string; - event: AgentEvent | CompactionEvent; + event: AgentEvent | CompactionEvent | AgentErrorEvent; } /** Extract thinking/reasoning content from an AgentEvent that carries a message */ diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 89c6217a..4582b7be 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -236,8 +236,8 @@ export function ChatView({
From 8d32a06b5c2b2ab3d10677f0edc708fd5d385a15 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 13:51:45 +0800 Subject: [PATCH 3/4] fix(agent): validate API key before calling PiAgentCore.prompt() getApiKey errors thrown inside PiAgentCore's internal async context result in UnhandledPromiseRejection instead of propagating to the caller. Return a graceful error early so AsyncAgent can emit it through the subscriber mechanism to the UI. Co-Authored-By: Claude Opus 4.6 --- src/agent/runner.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index c6fa4566..7d63bfe5 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -364,6 +364,14 @@ export class Agent { await this.ensureInitialized(); this.output.state.lastAssistantText = ""; + // Early validation: check API key before calling PiAgentCore.prompt(), + // because getApiKey errors thrown inside PiAgentCore's internal async + // context result in UnhandledPromiseRejection instead of propagating. + if (!this.currentApiKey) { + const errorMsg = `No API key configured for provider: ${this.resolvedProvider}. Please configure a provider in Agent Settings.`; + return { text: "", error: errorMsg }; + } + const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; let lastError: unknown; From ed681a96bfd14d53b1cbc7f874fd9f765442d396 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 13:51:55 +0800 Subject: [PATCH 4/4] feat(desktop): add Configure button in chat error banner When the agent fails due to missing API key, the error banner now shows a "Configure" button that opens the same ApiKeyDialog (or OAuthDialog) used on the home page. After successful configuration the error clears and the user can immediately start chatting. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/components/local-chat.tsx | 92 +++++++++++++++++++--- apps/desktop/src/hooks/use-local-chat.ts | 5 ++ packages/ui/src/components/chat-view.tsx | 35 +++++--- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/components/local-chat.tsx b/apps/desktop/src/components/local-chat.tsx index 5384c063..ade9db6c 100644 --- a/apps/desktop/src/components/local-chat.tsx +++ b/apps/desktop/src/components/local-chat.tsx @@ -1,6 +1,10 @@ +import { useState, useCallback } from 'react' import { Loading } from '@multica/ui/components/ui/loading' import { ChatView } from '@multica/ui/components/chat-view' import { useLocalChat } from '../hooks/use-local-chat' +import { useProvider } from '../hooks/use-provider' +import { ApiKeyDialog } from './api-key-dialog' +import { OAuthDialog } from './oauth-dialog' export function LocalChat() { const { @@ -17,8 +21,41 @@ export function LocalChat() { sendMessage, loadMore, resolveApproval, + clearError, } = useLocalChat() + const { providers, current, setProvider: switchProvider, refresh: refreshProviders } = useProvider() + + // Provider config dialog state + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + + const handleConfigureProvider = useCallback(() => { + const providerId = current?.provider + if (!providerId) return + + const meta = providers.find((p) => p.id === providerId) + if (!meta) return + + if (meta.authMethod === 'oauth') { + setOauthDialogOpen(true) + } else { + setApiKeyDialogOpen(true) + } + }, [current, providers]) + + const handleProviderConfigSuccess = useCallback(async () => { + const providerId = current?.provider + if (!providerId) return + + await refreshProviders() + await switchProvider(providerId) + clearError() + }, [current, refreshProviders, switchProvider, clearError]) + + // Derive provider info for dialogs + const currentMeta = current ? providers.find((p) => p.id === current.provider) : null + if (initError) { return (
@@ -36,19 +73,48 @@ export function LocalChat() { ) } + // Show "Configure" button when error is about provider/API key + const errorAction = error?.code === 'AGENT_ERROR' && currentMeta + ? { label: 'Configure', onClick: handleConfigureProvider } + : undefined + return ( - + <> + + + {currentMeta && currentMeta.authMethod === 'api-key' && ( + + )} + + {currentMeta && currentMeta.authMethod === 'oauth' && ( + + )} + ) } diff --git a/apps/desktop/src/hooks/use-local-chat.ts b/apps/desktop/src/hooks/use-local-chat.ts index c6353e12..749ee071 100644 --- a/apps/desktop/src/hooks/use-local-chat.ts +++ b/apps/desktop/src/hooks/use-local-chat.ts @@ -142,6 +142,10 @@ export function useLocalChat() { [], ) + const clearError = useCallback(() => { + chatRef.current.setError(null) + }, []) + return { agentId, initError, @@ -156,5 +160,6 @@ export function useLocalChat() { sendMessage, loadMore, resolveApproval, + clearError, } } diff --git a/packages/ui/src/components/chat-view.tsx b/packages/ui/src/components/chat-view.tsx index 4582b7be..b47edf0b 100644 --- a/packages/ui/src/components/chat-view.tsx +++ b/packages/ui/src/components/chat-view.tsx @@ -38,6 +38,8 @@ export interface ChatViewProps { loadMore?: () => void; resolveApproval: (approvalId: string, decision: "allow-once" | "allow-always" | "deny") => void; onDisconnect?: () => void; + /** Optional action button in the error banner (e.g. "Configure Provider") */ + errorAction?: { label: string; onClick: () => void }; } export function ChatView({ @@ -53,6 +55,7 @@ export function ChatView({ loadMore, resolveApproval, onDisconnect, + errorAction, }: ChatViewProps) { const mainRef = useRef(null); const sentinelRef = useRef(null); @@ -219,16 +222,28 @@ export function ChatView({
{error.message} - {onDisconnect && ( - - )} +
+ {errorAction && ( + + )} + {onDisconnect && ( + + )} +
)}