diff --git a/docs/images/background-agent/agent-tab-hostler-run.png b/docs/images/background-agent/agent-tab-hostler-run.png new file mode 100644 index 00000000..5a736490 Binary files /dev/null and b/docs/images/background-agent/agent-tab-hostler-run.png differ diff --git a/docs/images/background-agent/settings-card-default.png b/docs/images/background-agent/settings-card-default.png new file mode 100644 index 00000000..edb1d909 Binary files /dev/null and b/docs/images/background-agent/settings-card-default.png differ diff --git a/docs/images/background-agent/settings-card-fallback-warning.png b/docs/images/background-agent/settings-card-fallback-warning.png new file mode 100644 index 00000000..2b894b50 Binary files /dev/null and b/docs/images/background-agent/settings-card-fallback-warning.png differ diff --git a/docs/images/background-agent/settings-card-hostler.png b/docs/images/background-agent/settings-card-hostler.png new file mode 100644 index 00000000..262fdcc0 Binary files /dev/null and b/docs/images/background-agent/settings-card-hostler.png differ diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index eaeafb2d..4ec88c91 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -107,20 +107,22 @@ run_with_display() { # Clean up per-worker test databases and stale config left by parallel E2E runs. # Config files (electron-store) are shared global state — we only clean them # before/after the full test suite, never during parallel execution. +# +# ONLY the dev Electron binary's dirs are cleaned. Tests launch via +# node_modules/electron, whose userData dir is "Electron" — never the packaged +# app's "exo" dir. exo-config.json under ".../Application Support/exo" is the +# PRODUCTION config (real API keys and settings); an earlier version of this +# list included the exo dirs and deleted it on every test run. Do not re-add. clean_test_dbs() { local home="${HOME:-/root}" local cleaned=0 local data_dirs=( "$home/Library/Application Support/Electron/data" - "$home/Library/Application Support/exo/data" "$home/.config/Electron/data" - "$home/.config/exo/data" ) local config_dirs=( "$home/Library/Application Support/Electron" - "$home/Library/Application Support/exo" "$home/.config/Electron" - "$home/.config/exo" ) for dir in "${data_dirs[@]}"; do if [ -d "$dir" ]; then diff --git a/src/main/ipc/drafts.ipc.ts b/src/main/ipc/drafts.ipc.ts index a30a17d7..4ed55522 100644 --- a/src/main/ipc/drafts.ipc.ts +++ b/src/main/ipc/drafts.ipc.ts @@ -13,7 +13,7 @@ import { deleteGmailDraftById, deleteGmailDraftsBatch, } from "../services/gmail-draft-sync"; -import { getConfig, getFeatureModelConfig } from "./settings.ipc"; +import { getConfig, getFeatureModelConfig, getBackgroundAgentProviderId } from "./settings.ipc"; import { buildMemoryContext } from "../services/memory-context"; import { prefetchService } from "../services/prefetch-service"; import { agentCoordinator } from "../agents/agent-coordinator"; @@ -177,7 +177,10 @@ FORMATTING: Write plain text paragraphs separated by blank lines. Do NOT use HTM // Rerun agent draft for a single email ipcMain.handle( "drafts:rerun-agent", - async (_, { emailId }: { emailId: string }): Promise> => { + async ( + _, + { emailId }: { emailId: string }, + ): Promise> => { if (useFakeData) { return { success: false, error: "Agent drafting is not available in demo/test mode" }; } @@ -231,7 +234,8 @@ FORMATTING: Write plain text paragraphs separated by blank lines. Do NOT use HTM prefetchService.trackManualAgentDraft(emailId, taskId); // Launch agent — events auto-stream to renderer via agent:event IPC - await agentCoordinator.runAgent(taskId, ["claude"], prompt, context); + const providerId = getBackgroundAgentProviderId(); + await agentCoordinator.runAgent(taskId, [providerId], prompt, context); // Link draft to agent task when it completes (async, don't block response) agentCoordinator @@ -252,7 +256,7 @@ FORMATTING: Write plain text paragraphs separated by blank lines. Do NOT use HTM prefetchService.markAgentDraftDone(emailId, "failed"); }); - return { success: true, data: { taskId } }; + return { success: true, data: { taskId, providerIds: [providerId] } }; } catch (error) { return { success: false, diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index ee1b5ffa..3f94c9fe 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -18,6 +18,8 @@ import { MODEL_TIER_IDS, resolveModelId, resolveAgentOllamaConfig, + resolveBackgroundAgentProviderId, + DEFAULT_BACKGROUND_AGENT_PROVIDER, DEFAULT_OLLAMA_MODEL, DEFAULT_HOSTLER_HARNESS, } from "../../shared/types"; @@ -223,6 +225,31 @@ export function getFeatureModelConfig(feature: keyof ModelConfig): { return { provider: "anthropic", model: resolveModelId(mc[feature]) }; } +/** + * Which agent provider background auto-drafts should launch right now. + * + * Wraps the pure resolveBackgroundAgentProviderId with the one gate it can't + * express: OpenCode also needs an LLM credential (its isAvailable() requires + * Ollama or Anthropic), and the Anthropic key may come from process.env, + * which the renderer-safe resolver can't read. Without this, enabling + * OpenCode with no credentials would fail every background draft — and each + * failed email is skipped for the rest of the session. + * + * The bundled opencode binary is deliberately not checked here: it ships + * with the app, so its absence is a broken install that should fail loudly + * in the provider, not silently fall back. + */ +export function getBackgroundAgentProviderId(): string { + const config = getConfig(); + const resolved = resolveBackgroundAgentProviderId(config); + if (resolved === "opencode") { + const hasAnthropic = Boolean(config.anthropicApiKey || process.env.ANTHROPIC_API_KEY); + const hasOllama = Boolean(config.ollamaCloud?.apiKey); + if (!hasAnthropic && !hasOllama) return DEFAULT_BACKGROUND_AGENT_PROVIDER; + } + return resolved; +} + export function registerSettingsIpc(): void { // Validate an Anthropic API key with a minimal API call ipcMain.handle( @@ -349,6 +376,19 @@ export function registerSettingsIpc(): void { }; } } + // backgroundAgentProvider routes every background auto-draft to an + // agent provider. IPC payloads are compile-time-typed only, so guard + // the type here — a persisted non-string would wedge every future + // auto-draft on "Unknown provider". + if ( + "backgroundAgentProvider" in config && + typeof config.backgroundAgentProvider !== "string" + ) { + newConfig = { + ...newConfig, + backgroundAgentProvider: currentConfig.backgroundAgentProvider, + }; + } // Deep-merge hostler for the same reason as ollamaCloud: the Extensions // card never sends baseUrl (a dev/test escape hatch), so a shallow // merge would silently erase it on every UI save. diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index e0fd6aef..9bba307b 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -14,7 +14,11 @@ import { loadCompletedAgentDraftEmailIds, isSenderBlocked, } from "../db"; -import { getConfig, getFeatureModelConfig } from "../ipc/settings.ipc"; +import { + getConfig, + getFeatureModelConfig, + getBackgroundAgentProviderId, +} from "../ipc/settings.ipc"; import { getExtensionHost } from "../extensions"; import { agentCoordinator } from "../agents/agent-coordinator"; import { buildAutoDraftTaskId } from "../agents/task-id"; @@ -1087,8 +1091,11 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with // Track which taskId is active for this email so we can detect superseded tasks this.activeAgentTaskIds.set(emailId, taskId); + const providerId = getBackgroundAgentProviderId(); + log.info(`[Prefetch] Agent draft for ${emailId} using provider ${providerId}`); + // Launch the agent and await its actual completion (not just startup) - await agentCoordinator.runAgent(taskId, ["claude"], prompt, context); + await agentCoordinator.runAgent(taskId, [providerId], prompt, context); await agentCoordinator.waitForCompletion(taskId); // Link the draft record to the agent task so the trace can be loaded later diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a14a72f8..7001469f 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -43,7 +43,7 @@ import { addBreadcrumb, captureException, } from "./services/posthog"; -import { LocalDraftSchema } from "../shared/types"; +import { LocalDraftSchema, DEFAULT_BACKGROUND_AGENT_PROVIDER } from "../shared/types"; import type { DashboardEmail, OutboxStats, @@ -1177,12 +1177,21 @@ export default function App() { // Save sidebar tab — startAgentTask unconditionally sets it to "agent", // but background auto-drafts shouldn't steal focus from the user const prevTab = store.sidebarTab; - store.startAgentTask(taskId, emailId, ["claude"], "", { - accountId: email.accountId || "", - currentEmailId: emailId, - currentThreadId: email.threadId, - userEmail: "", - }); + // The background provider is configurable (backgroundAgentProvider), + // so derive it from the event — appendAgentEvent drops events whose + // providerId has no registered run. + store.startAgentTask( + taskId, + emailId, + [event.providerId ?? DEFAULT_BACKGROUND_AGENT_PROVIDER], + "", + { + accountId: email.accountId || "", + currentEmailId: emailId, + currentThreadId: email.threadId, + userEmail: "", + }, + ); trackEvent("agent_run_started", { source: "auto_draft", provider_count: 1 }); // Restore tab if this auto-draft is for a different email than what the user is viewing if (store.selectedEmailId !== emailId && prevTab !== "agent") { diff --git a/src/renderer/components/AgentPanel.tsx b/src/renderer/components/AgentPanel.tsx index b9901e2d..d4506175 100644 --- a/src/renderer/components/AgentPanel.tsx +++ b/src/renderer/components/AgentPanel.tsx @@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { useAppStore } from "../store"; import type { ScopedAgentEvent, AgentTaskState, AgentTaskInfo } from "../../shared/agent-types"; +import { DEFAULT_BACKGROUND_AGENT_PROVIDER } from "../../shared/types"; import { AgentConfirmationDialog } from "./AgentConfirmationDialog"; import { trackEvent } from "../services/posthog"; @@ -745,7 +746,7 @@ export const AgentTabContent = memo(function AgentTabContent({ emailId }: { emai // Call backend to delete draft, clean up trace, and launch a new agent const result = (await window.api?.drafts?.rerunAgent?.(emailId)) as - | { success: boolean; data?: { taskId: string }; error?: string } + | { success: boolean; data?: { taskId: string; providerIds?: string[] }; error?: string } | undefined; if (result?.success && result.data) { @@ -754,12 +755,19 @@ export const AgentTabContent = memo(function AgentTabContent({ emailId }: { emai // The real context is built by buildAgentDraftContext on the backend — this // is only for the store's tracking entry. const email = useAppStore.getState().emails.find((e) => e.id === emailId); + // Drop providerConversationIds from the reused context: a regenerate is + // a fresh conversation (the backend deleted the old trace), and the new + // run may be on a different provider than the one that minted those ids + // — a stale id would make a later follow-up skip conversation history. + const reusedContext = task?.context + ? { ...task.context, providerConversationIds: undefined } + : undefined; startAgentTask( taskId, emailId, - ["claude"], + result.data.providerIds ?? [DEFAULT_BACKGROUND_AGENT_PROVIDER], task?.prompt || "", - task?.context || { + reusedContext ?? { accountId: email?.accountId || "", currentEmailId: emailId, currentThreadId: email?.threadId || "", @@ -795,7 +803,12 @@ export const AgentTabContent = memo(function AgentTabContent({ emailId }: { emai console.log("[AgentPanel] Follow-up providerConversationIds:", providerConversationIds); - const hasStatefulProvider = Object.keys(providerConversationIds).length > 0; + // Only count conversation ids belonging to a provider that will actually + // run this follow-up. The background provider is configurable, so a task + // context can carry a stale id from a previous provider (e.g. a hostler + // session id after a regenerate resolved to claude) — treating that as + // "stateful" would send Claude the bare prompt with no history. + const hasStatefulProvider = currentTask.providerIds.some((id) => providerConversationIds[id]); // Build conversation history for stateless providers (e.g. Claude) const history = buildConversationHistory(currentTask); diff --git a/src/renderer/components/EmailPreviewSidebar.tsx b/src/renderer/components/EmailPreviewSidebar.tsx index 613cc576..3dc149d5 100644 --- a/src/renderer/components/EmailPreviewSidebar.tsx +++ b/src/renderer/components/EmailPreviewSidebar.tsx @@ -2,6 +2,7 @@ import { memo, useMemo, useEffect, useRef } from "react"; import { useAppStore } from "../store"; import { useExtensionPanels, ExtensionPanelSlot } from "../extensions"; import { AgentTabContent } from "./AgentPanel"; +import { deriveTraceProviderIds } from "../../shared/agent-types"; import type { ScopedAgentEvent } from "../../shared/agent-types"; // SVG icon components for sidebar tabs @@ -264,10 +265,13 @@ export const EmailPreviewSidebar = memo(function EmailPreviewSidebar() { if (!email) return; // Replay entire trace in a single store update (avoids O(n²) from N appendAgentEvent calls) + // Derive provider ids from the persisted events — the background + // provider is configurable, and replayAgentTrace drops events whose + // providerId isn't in this list. replayAgentTrace( taskId, email.id, - ["claude"], + deriveTraceProviderIds(result.data.events), "", { accountId: email.accountId || "", diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index d5e23c87..102e9391 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -23,6 +23,8 @@ import { SENDER_LOOKUP_PROVIDERS, type SenderLookupProvider, DEFAULT_OLLAMA_MODEL, + DEFAULT_BACKGROUND_AGENT_PROVIDER, + resolveBackgroundAgentProviderId, type BlockedSender, } from "../../shared/types"; import { useAppStore, type Account, type SettingsTab } from "../store"; @@ -148,6 +150,12 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { const [chromeProfilePath, setChromeProfilePath] = useState(""); const [isSavingBrowser, setIsSavingBrowser] = useState(false); + // Which agent provider runs background auto-drafts (new-email drafter + regenerate). + // Provider gates (opencode/hostler enabled state) are derived from generalConfig. + const [backgroundAgentProvider, setBackgroundAgentProvider] = useState( + DEFAULT_BACKGROUND_AGENT_PROVIDER, + ); + // PostHog analytics state — initialized once from config, not clobbered by react-query refetch const [posthogEnabled, setPosthogEnabled] = useState(false); const [isSavingAnalytics, setIsSavingAnalytics] = useState(false); @@ -213,6 +221,16 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { }, }); + // What the main process will actually launch for background drafts, given + // the current provider gates — the same resolver prefetch/rerun use, so the + // fallback warning in the Agents tab can't drift from real behavior. + const effectiveBackgroundProvider = resolveBackgroundAgentProviderId({ + backgroundAgentProvider, + opencode: generalConfig?.opencode, + hostler: generalConfig?.hostler, + openclaw: generalConfig?.openclaw, + }); + useEffect(() => { if (prompts) { setAnalysisPrompt(prompts.analysisPrompt); @@ -265,6 +283,9 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { setMcpServers(generalConfig.mcpServers ?? {}); setCliTools((generalConfig.cliTools ?? []).map((t) => ({ ...t, _key: nextCliToolKey() }))); setExtraPathDirs(generalConfig.extraPathDirs ?? []); + setBackgroundAgentProvider( + generalConfig.backgroundAgentProvider || DEFAULT_BACKGROUND_AGENT_PROVIDER, + ); // PostHog analytics config — only set once to avoid clobbering unsaved edits on refetch if (!analyticsInitialized.current) { analyticsInitialized.current = true; @@ -313,9 +334,12 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { return cleanup; }, []); - // Check Claude CLI availability and auth status when Agents tab is shown + // Check Claude CLI availability and auth status when Agents tab is shown. + // Also refetch config so provider gates flipped in the Extensions tab + // (OpenCode/Hostler enablement) are reflected without reopening Settings. useEffect(() => { if (activeTab !== "agents") return; + queryClient.invalidateQueries({ queryKey: ["general-config"] }); setClaudeAuthStatus("checking"); ( window.api.agent.claudeAuthStatus() as Promise<{ @@ -2735,6 +2759,52 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) {

+ {/* Background agent — which provider runs the automatic new-email drafter */} +
+
+
+

+ Background Agent +

+

+ The agent that automatically drafts replies for new emails (and powers + "Regenerate draft"). Enable OpenCode or Hostler in Settings → + Extensions to select them here. +

+
+ +
+ {effectiveBackgroundProvider !== backgroundAgentProvider && ( +

+ {backgroundAgentProvider === "opencode" + ? "OpenCode is disabled — background drafts fall back to Claude until it's re-enabled." + : backgroundAgentProvider === "hostler" + ? `Hostler is ${generalConfig?.hostler?.enabled ? "missing an API key" : "disabled"} — background drafts fall back to Claude until it's configured.` + : `"${backgroundAgentProvider}" isn't available — background drafts fall back to Claude until it's configured.`} +

+ )} +
+ {/* Authentication */}

diff --git a/src/shared/agent-types.ts b/src/shared/agent-types.ts index e74b2167..595cc146 100644 --- a/src/shared/agent-types.ts +++ b/src/shared/agent-types.ts @@ -2,6 +2,7 @@ * Renderer-safe agent types. No Node.js or Electron imports. * These are the types used by the Zustand store and React components. */ +import { DEFAULT_BACKGROUND_AGENT_PROVIDER } from "./types"; // Re-export event types that are safe for renderer export type AgentTaskState = @@ -124,3 +125,18 @@ export interface RemoteConversationView { lastSyncedAt: number; messages: ScopedAgentEvent[]; } + +/** + * Unique provider ids present in a persisted agent trace, in first-seen order. + * Used to rebuild the per-provider run map when replaying a trace — + * replayAgentTrace drops events whose providerId isn't in this list. Traces + * that predate provider stamping (no event carries a providerId) ran under + * the claude provider, so fall back to it. + */ +export function deriveTraceProviderIds(events: ScopedAgentEvent[]): string[] { + const ids = new Set(); + for (const event of events) { + if (typeof event.providerId === "string") ids.add(event.providerId); + } + return ids.size > 0 ? [...ids] : [DEFAULT_BACKGROUND_AGENT_PROVIDER]; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 71189f12..ffd0ee05 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -539,6 +539,13 @@ export const ConfigSchema = z.object({ .optional(), }) .optional(), + // Which agent provider runs background auto-draft tasks — the agent that + // fires on every new email needing a reply, plus the "Regenerate draft" + // rerun path. "claude" (default), "opencode", or "hostler" today; kept + // free-text so future providers work without a schema change. Resolution + // (including fallback when the chosen provider is disabled) happens in + // resolveBackgroundAgentProviderId below. + backgroundAgentProvider: z.string().optional(), ollamaCloud: OllamaCloudConfigSchema.optional(), featureProviders: z.record(z.string(), LlmProviderSchema).optional(), configVersion: z.number().optional(), @@ -583,6 +590,42 @@ export function resolveAgentOllamaConfig( }; } +/** Provider id used for background auto-drafts when nothing else is configured. */ +export const DEFAULT_BACKGROUND_AGENT_PROVIDER = "claude"; + +/** + * Resolve which agent provider runs background auto-draft tasks (the agent + * that fires on every new email needing a reply, and the "Regenerate draft" + * rerun path). + * + * Falls back to "claude" when the configured provider's config-level gates + * aren't met — mirroring each provider's isAvailable() check, which runs in + * the agent worker where the main process can't call it. Without the + * fallback, disabling e.g. Hostler while it's selected would make every + * background draft fail until the user also updated this setting. + * + * Unknown provider ids (e.g. an installed provider) pass through unchanged: + * we can't know their config gates here, and the orchestrator fails + * explicitly for unregistered ids. + */ +export function resolveBackgroundAgentProviderId( + cfg: Pick, +): string { + // `||` (not `??`) so an empty string in a hand-edited config counts as + // unset instead of reaching the orchestrator as an unknown provider id. + const requested = cfg.backgroundAgentProvider || DEFAULT_BACKGROUND_AGENT_PROVIDER; + if (requested === "opencode" && !cfg.opencode?.enabled) { + return DEFAULT_BACKGROUND_AGENT_PROVIDER; + } + if (requested === "hostler" && !(cfg.hostler?.enabled && cfg.hostler.apiKey)) { + return DEFAULT_BACKGROUND_AGENT_PROVIDER; + } + if (requested === "openclaw-agent" && !(cfg.openclaw?.enabled && cfg.openclaw.gatewayUrl)) { + return DEFAULT_BACKGROUND_AGENT_PROVIDER; + } + return requested; +} + // Dashboard-specific types // Email with analysis and draft status for the UI diff --git a/tests/unit/background-agent-provider.spec.ts b/tests/unit/background-agent-provider.spec.ts new file mode 100644 index 00000000..b347afe1 --- /dev/null +++ b/tests/unit/background-agent-provider.spec.ts @@ -0,0 +1,189 @@ +/** + * Unit tests for resolveBackgroundAgentProviderId — which agent provider runs + * the automatic new-email drafter and the "Regenerate draft" rerun path. + * + * Pure config-resolution tests — no DB, mocks, or native modules needed. + */ +import { test, expect } from "@playwright/test"; +import { + ConfigSchema, + DEFAULT_BACKGROUND_AGENT_PROVIDER, + resolveBackgroundAgentProviderId, +} from "../../src/shared/types"; +import { deriveTraceProviderIds } from "../../src/shared/agent-types"; +import type { ScopedAgentEvent } from "../../src/shared/agent-types"; + +test.describe("ConfigSchema backgroundAgentProvider", () => { + test("parses config with backgroundAgentProvider set", () => { + const result = ConfigSchema.parse({ backgroundAgentProvider: "hostler" }); + expect(result.backgroundAgentProvider).toBe("hostler"); + }); + + test("parses config without backgroundAgentProvider (undefined, resolver defaults to claude)", () => { + const result = ConfigSchema.parse({}); + expect(result.backgroundAgentProvider).toBeUndefined(); + }); +}); + +test.describe("resolveBackgroundAgentProviderId", () => { + test("defaults to claude when unset", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: undefined, + opencode: undefined, + hostler: undefined, + }); + expect(result).toBe(DEFAULT_BACKGROUND_AGENT_PROVIDER); + }); + + test("returns claude when explicitly selected", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "claude", + opencode: { enabled: true }, + hostler: { enabled: true, apiKey: "cpk_123", harness: "opencode" }, + }); + expect(result).toBe("claude"); + }); + + test("returns opencode when selected and enabled", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "opencode", + opencode: { enabled: true }, + hostler: undefined, + }); + expect(result).toBe("opencode"); + }); + + test("falls back to claude when opencode selected but disabled", () => { + // Disabling a provider must not strand background drafts on a dead + // provider — they'd fail on every new email until the user also fixed + // this setting. + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "opencode", + opencode: { enabled: false }, + hostler: undefined, + }); + expect(result).toBe("claude"); + }); + + test("falls back to claude when opencode selected but config missing", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "opencode", + opencode: undefined, + hostler: undefined, + }); + expect(result).toBe("claude"); + }); + + test("returns hostler when selected, enabled, and keyed", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "hostler", + opencode: undefined, + hostler: { enabled: true, apiKey: "cpk_123", harness: "opencode" }, + }); + expect(result).toBe("hostler"); + }); + + test("falls back to claude when hostler selected but disabled", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "hostler", + opencode: undefined, + hostler: { enabled: false, apiKey: "cpk_123", harness: "opencode" }, + }); + expect(result).toBe("claude"); + }); + + test("falls back to claude when hostler selected but apiKey empty", () => { + // Mirrors HostlerAgentProvider.isAvailable(): enabled && apiKey. An + // enabled-but-keyless hostler would fail every run with an auth error. + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "hostler", + opencode: undefined, + hostler: { enabled: true, apiKey: "", harness: "opencode" }, + }); + expect(result).toBe("claude"); + }); + + test("treats empty string as unset (hand-edited config)", () => { + // "" would otherwise reach the orchestrator and throw "Unknown provider: ". + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "", + opencode: undefined, + hostler: undefined, + }); + expect(result).toBe("claude"); + }); + + test("falls back to claude when openclaw-agent selected but not configured", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "openclaw-agent", + opencode: undefined, + hostler: undefined, + openclaw: { enabled: false, gatewayUrl: "", gatewayToken: "" }, + }); + expect(result).toBe("claude"); + }); + + test("returns openclaw-agent when enabled with a gateway URL", () => { + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "openclaw-agent", + opencode: undefined, + hostler: undefined, + openclaw: { enabled: true, gatewayUrl: "https://gw.example.com", gatewayToken: "t" }, + }); + expect(result).toBe("openclaw-agent"); + }); + + test("passes through unknown provider ids unchanged", () => { + // Installed/private providers have config gates we can't see here — the + // orchestrator fails explicitly for ids that aren't registered. + const result = resolveBackgroundAgentProviderId({ + backgroundAgentProvider: "my-installed-provider", + opencode: undefined, + hostler: undefined, + }); + expect(result).toBe("my-installed-provider"); + }); +}); + +test.describe("deriveTraceProviderIds", () => { + const event = (overrides: Partial): ScopedAgentEvent => + ({ type: "text_delta", text: "x", ...overrides }) as ScopedAgentEvent; + + test("collects the single provider id from a stamped trace", () => { + const events = [ + event({ providerId: "hostler" }), + event({ providerId: "hostler" }), + event({ providerId: "hostler" }), + ]; + expect(deriveTraceProviderIds(events)).toEqual(["hostler"]); + }); + + test("falls back to claude for legacy traces with no stamped events", () => { + const events = [event({}), event({})]; + expect(deriveTraceProviderIds(events)).toEqual(["claude"]); + }); + + test("ignores unstamped events when at least one event is stamped", () => { + // Orchestrator-emitted confirmation_required events carry no providerId; + // replayAgentTrace buckets them under providerIds[0]. + const events = [ + event({ providerId: "opencode" }), + event({}), + event({ providerId: "opencode" }), + ]; + expect(deriveTraceProviderIds(events)).toEqual(["opencode"]); + }); + + test("preserves first-seen order for multi-provider traces", () => { + const events = [ + event({ providerId: "claude" }), + event({ providerId: "hostler" }), + event({ providerId: "claude" }), + ]; + expect(deriveTraceProviderIds(events)).toEqual(["claude", "hostler"]); + }); + + test("returns claude for an empty trace", () => { + expect(deriveTraceProviderIds([])).toEqual(["claude"]); + }); +});