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