Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 6 additions & 4 deletions scripts/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions src/main/ipc/drafts.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<IpcResponse<{ taskId: string }>> => {
async (
_,
{ emailId }: { emailId: string },
): Promise<IpcResponse<{ taskId: string; providerIds: string[] }>> => {
if (useFakeData) {
return { success: false, error: "Agent drafting is not available in demo/test mode" };
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions src/main/ipc/settings.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
MODEL_TIER_IDS,
resolveModelId,
resolveAgentOllamaConfig,
resolveBackgroundAgentProviderId,
DEFAULT_BACKGROUND_AGENT_PROVIDER,
DEFAULT_OLLAMA_MODEL,
DEFAULT_HOSTLER_HARNESS,
} from "../../shared/types";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src/main/services/prefetch-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
23 changes: 16 additions & 7 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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") {
Expand Down
21 changes: 17 additions & 4 deletions src/renderer/components/AgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) {
Expand All @@ -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 || "",
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/components/EmailPreviewSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || "",
Expand Down
72 changes: 71 additions & 1 deletion src/renderer/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<{
Expand Down Expand Up @@ -2735,6 +2759,52 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) {
</p>
</div>

{/* Background agent — which provider runs the automatic new-email drafter */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
Background Agent
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The agent that automatically drafts replies for new emails (and powers
&quot;Regenerate draft&quot;). Enable OpenCode or Hostler in Settings →
Extensions to select them here.
</p>
</div>
<select
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
value={backgroundAgentProvider}
onChange={async (e) => {
const value = e.target.value;
setBackgroundAgentProvider(value);
await window.api.settings.set({ backgroundAgentProvider: value });
queryClient.invalidateQueries({ queryKey: ["general-config"] });
}}
>
<option value="claude">Claude (Anthropic) — default</option>
<option value="opencode" disabled={!generalConfig?.opencode?.enabled}>
OpenCode
</option>
<option
value="hostler"
disabled={!generalConfig?.hostler?.enabled || !generalConfig?.hostler?.apiKey}
>
Hostler (cloud)
</option>
</select>
</div>
{effectiveBackgroundProvider !== backgroundAgentProvider && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
{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.`}
</p>
)}
</div>

{/* Authentication */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 p-6">
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100 mb-4">
Expand Down
Loading
Loading