diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 68dd5550..f71fe7f7 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/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 cf637f25..749ee071 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) @@ -133,6 +142,10 @@ export function useLocalChat() { [], ) + const clearError = useCallback(() => { + chatRef.current.setError(null) + }, []) + return { agentId, initError, @@ -147,5 +160,6 @@ export function useLocalChat() { sendMessage, loadMore, resolveApproval, + clearError, } } 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..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 && ( + + )} +
)} @@ -236,8 +251,8 @@ export function ChatView({
diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 0f7f7d88..9aed5913 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -62,11 +62,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 9433d8bc..3deef81e 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 { @@ -163,11 +163,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 @@ -356,6 +359,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 { // Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages return this.withRunMutex(() => this._run(prompt)); @@ -402,6 +410,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; @@ -509,14 +525,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; }