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.
+
+ {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"]);
+ });
+});