diff --git a/docs/superpowers/plans/2026-07-24-packaged-opencode-binary.md b/docs/superpowers/plans/2026-07-24-packaged-opencode-binary.md new file mode 100644 index 00000000..f64bd3ec --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-packaged-opencode-binary.md @@ -0,0 +1,131 @@ +# Packaged OpenCode Binary Resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the packaged macOS Exo app discover its bundled OpenCode executable so enabled OpenCode settings produce an OpenCode option in the agent picker. + +**Architecture:** Reuse the platform-package executable electron-builder already preserves beneath `process.resourcesPath`, choosing the compatibility-safe baseline variant on x64. Resolve that path directly in the utility-process worker, then cover PATH lookup, executability, and provider availability with unit and packaged smoke tests. + +**Tech Stack:** Electron, TypeScript, Node.js filesystem/module resolution, Playwright. + +## Global Constraints + +- Preserve the existing development resolution paths and memoization. +- Do not read or modify the production Exo profile during tests. +- Use Node.js `22.22.0` for install, tests, and packaging. +- Keep the PR limited to OpenCode binary packaging, its regression coverage, and this plan. + +--- + +### Task 1: Resolve the existing packaged OpenCode executable + +**Files:** + +- Modify: `src/main/agents/providers/opencode/opencode-agent-provider.ts` +- Modify: `tests/unit/opencode-binary-resolution.spec.ts` + +**Interfaces:** + +- Consumes: the executable already shipped by the platform-specific optional dependency. +- Produces: its direct `app.asar.unpacked/node_modules/opencode--[-baseline]/bin/opencode[.exe]` path. + +- [ ] **Step 1: Prove the canonical shim is absent from the pre-fix bundle** + +Inspect `Contents/Resources/app.asar` and `app.asar.unpacked`. Expected: `opencode-ai/package.json` is present, but `opencode-ai/bin/opencode.exe` is absent. + +- [ ] **Step 2: Resolve the existing platform binary** + +Resolve the platform dependency beneath `process.resourcesPath`, using the baseline package on x64 so distributables do not inherit the build runner's AVX2 capability. Preserve the normal package-resolution fallback for development. + +- [ ] **Step 3: Keep resolver documentation current** + +Document the packaged and development paths and keep the resolver memoized. + +### Task 2: Prove the packaged artifact + +**Files:** + +- Modify: `tests/packaged/smoke.spec.ts` + +**Interfaces:** + +- Consumes: `EXO_PACKAGED_BINARY` pointing to the packaged executable. +- Produces: smoke assertions that the platform executable is present and executable and that enabled OpenCode appears in the packaged agent picker after restart. + +- [ ] **Step 1: Add the packaged smoke assertion** + +Derive the platform package's unpacked `bin` directory from the packaged executable, assert the platform SDK command exists, invoke `opencode --version` through the production PATH lookup, and assert POSIX execute bits are non-zero where applicable. + +Persist enabled OpenCode settings in the smoke suite's isolated profile, restart that packaged app, open the agent palette, and assert the `OpenCode` provider button is visible. + +- [ ] **Step 2: Build and package** + +Run: `npm run build` + +Run: `CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack` + +- [ ] **Step 3: Run the packaged smoke suite** + +Run: `EXO_PACKAGED_BINARY=release/mac-arm64/Exo.app/Contents/MacOS/Exo npx playwright test --project=packaged` + +Expected: all packaged smoke tests pass without touching `~/Library/Application Support/exo`. + +### Task 3: Validate and publish the draft PR + +**Files:** + +- Review all changed files from Tasks 1 and 2. + +**Interfaces:** + +- Consumes: the green fix branch. +- Produces: a pushed branch and draft PR against `ankitvgupta/exo:main`. + +- [ ] **Step 1: Run local gates** + +Run: `npm run typecheck` + +Run: `npm run lint` + +Run: `npm run format:check` + +Run: `npm test` + +- [ ] **Step 2: Commit and push** + +Stage only the plan, resolver, unit coverage, and packaged smoke test. Commit as `Fix packaged OpenCode binary resolution`, then push `codex/fix-packaged-opencode-binary` to `upstream-pr`. + +- [ ] **Step 3: Open the draft PR** + +Create a draft PR against `ankitvgupta/exo:main` with root cause, impact, and exact validation commands. + +- [ ] **Step 4: Run required post-PR gates** + +Run full `npm run pre-pr`, then `/review`, `/reviewloop`, and `gh pr checks`. Fix major findings and rerun required gates until the PR is clean or an external credential/CI blocker is proven. + +### Task 4: Build and install the all-PR integration app + +**Files:** + +- No source edits beyond merge conflict resolution in a dedicated integration worktree. + +**Interfaces:** + +- Consumes: current `upstream/main`, PR heads #169, #170, #171, #180, #190, and the new OpenCode fix PR head. +- Produces: a packaged and installed `/Applications/Exo.app` using the unchanged production profile. + +- [ ] **Step 1: Create the integration worktree and merge authoritative PR heads** + +Create a fresh integration branch from current `upstream/main`. Merge each Mick-authored open PR head and the new fix head, preserving all feature behavior during conflict resolution. + +- [ ] **Step 2: Install, build, test, and package** + +Use Node.js `22.22.0`, run `npm install`, focused conflict-area tests, `npm run build`, and `CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack`. + +- [ ] **Step 3: Install safely** + +Preserve a rollback copy of `/Applications/Exo.app`, replace only the app bundle, and leave `~/Library/Application Support/exo` untouched. + +- [ ] **Step 4: Verify installed provenance and behavior** + +Compare packaged and installed `app.asar` SHA-256 values, confirm the live process runs `/Applications/Exo.app` with the production profile, and confirm the OpenCode provider appears after `Cmd+J`. diff --git a/docs/superpowers/plans/2026-07-29-opencode-feature-provider-adapter.md b/docs/superpowers/plans/2026-07-29-opencode-feature-provider-adapter.md new file mode 100644 index 00000000..a45e9cf1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-opencode-feature-provider-adapter.md @@ -0,0 +1,1346 @@ +# OpenCode Per-Feature Provider Adapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make OpenCode a first-class per-feature provider across Exo's AI Models settings, one-shot feature inference, Agent Drafter, and Agent Chat. + +**Architecture:** Extend the existing `featureProviders → getFeatureModelConfig() → createMessage()` path with an OpenCode branch backed by one lazy, tool-disabled main-process OpenCode v2 server. Keep tool-enabled agent sessions in the existing worker `OpenCodeAgentProvider`, but let both paths use OpenCode's own connected providers, credentials, defaults, and exact per-feature model selectors. + +**Tech Stack:** Electron, React 18, TypeScript, Zod 4, `@opencode-ai/sdk` 1.15.10 v1/v2 clients, TanStack Query, SQLite, Playwright. + +## Global Constraints + +- OpenCode owns provider authentication and configuration; Exo must not read, copy, expose, or persist OpenCode's `auth.json`. +- Store exact OpenCode selectors as `provider/model` in `opencode.featureModels`. +- Resolve OpenCode models in this order: per-feature selector, legacy `opencode.model`, then OpenCode's default. +- Preserve unavailable saved selectors and surface them; never silently replace them. +- Never cross-fallback from OpenCode to Anthropic or Ollama Cloud. +- Keep existing feature-specific conservative parsing fallbacks after a successful response. +- Disable every OpenCode tool and deny every OpenCode permission for one-shot feature calls. +- Keep the existing mail MCP tools and Exo permission gate for Agent Drafter and Agent Chat. +- Sender Lookup may use OpenCode only when Exa supplies search results. +- Use the installed Zod and OpenCode dependencies; add no package. +- Keep the main-process and worker OpenCode servers separate. +- Use Node.js `22.22.0` for install, tests, build, and packaging. +- Do not push, open a PR, or replace `/Applications/Exo.app` without the user's explicit authorization at that execution stage. + +--- + +### Task 1: Add OpenCode configuration and deterministic model routing + +**Files:** + +- Modify: `src/shared/types.ts:300-665` +- Modify: `src/main/ipc/settings.ipc.ts:175-255` +- Modify: `src/main/ipc/settings.ipc.ts:353-575` +- Create: `tests/unit/opencode-config.spec.ts` +- Modify: `tests/unit/background-agent-provider.spec.ts:190-235` + +**Interfaces:** + +- Consumes: existing `ModelConfig`, `featureProviders`, `ConfigSchema`, and `applyAgentDrafterSelection()`. +- Produces: `OpenCodeModelOption`, `OpenCodeRoute`, `parseOpenCodeModelSelector()`, `resolveOpenCodeRoute()`, `getOpenCodeModelSelector()`, and OpenCode-aware `getFeatureModelConfig()`. + +- [ ] **Step 1: Write failing schema and route-resolution tests** + +Create `tests/unit/opencode-config.spec.ts` with focused tests: + +```ts +import { test, expect } from "@playwright/test"; +import { + ConfigSchema, + LlmProviderSchema, + parseOpenCodeModelSelector, + resolveOpenCodeRoute, + type OpenCodeModelOption, +} from "../../src/shared/types"; + +const models: OpenCodeModelOption[] = [ + { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }, + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.2", + modelName: "GPT-5.2", + }, +]; + +test("opencode is a valid LLM provider", () => { + expect(LlmProviderSchema.parse("opencode")).toBe("opencode"); +}); + +test("ConfigSchema preserves legacy and per-feature OpenCode models", () => { + const cfg = ConfigSchema.parse({ + opencode: { + enabled: true, + model: "anthropic/claude-sonnet-4-5", + featureModels: { + analysis: "openai/gpt-5.2", + drafts: "anthropic/claude-sonnet-4-5", + }, + }, + }); + expect(cfg.opencode?.featureModels?.analysis).toBe("openai/gpt-5.2"); + expect(cfg.opencode?.model).toBe("anthropic/claude-sonnet-4-5"); +}); + +test("exact selectors split on the first slash", () => { + expect(parseOpenCodeModelSelector("openrouter/openai/gpt-5.2")).toEqual({ + providerID: "openrouter", + modelID: "openai/gpt-5.2", + }); +}); + +test("blank selector delegates to the OpenCode default", () => { + expect(resolveOpenCodeRoute("", models)).toBeUndefined(); +}); + +test("legacy bare selector resolves only when unique", () => { + expect(resolveOpenCodeRoute("gpt-5.2", models)).toEqual({ + providerID: "openai", + modelID: "gpt-5.2", + }); + expect(() => + resolveOpenCodeRoute("same-id", [ + { ...models[0], modelId: "same-id" }, + { ...models[1], modelId: "same-id" }, + ]), + ).toThrow(/ambiguous/i); +}); + +test("missing legacy selector fails visibly", () => { + expect(() => resolveOpenCodeRoute("removed-model", models)).toThrow(/not available/i); +}); + +test("missing exact selector fails visibly", () => { + expect(() => resolveOpenCodeRoute("openai/removed-model", models)).toThrow(/not available/i); +}); +``` + +Update the Agent Drafter selection test to require: + +```ts +expect(applyAgentDrafterSelection("opencode")).toEqual({ + backgroundAgentProvider: "opencode", + agentDrafterProvider: "opencode", +}); +``` + +- [ ] **Step 2: Run the focused tests and verify the new expectations fail** + +Run: + +```bash +npx playwright test --project=unit tests/unit/opencode-config.spec.ts tests/unit/background-agent-provider.spec.ts +``` + +Expected: failure because `opencode` is not an `LlmProvider`, the model types/helpers do not exist, and the Agent Drafter selection does not synchronize `featureProviders.agentDrafter`. + +- [ ] **Step 3: Add the shared OpenCode types and pure selector resolver** + +Implement in `src/shared/types.ts`: + +```ts +export const LLM_PROVIDERS = ["anthropic", "ollama-cloud", "opencode"] as const; +export const LlmProviderSchema = z.enum(LLM_PROVIDERS); + +export type OpenCodeModelOption = { + providerId: string; + providerName: string; + modelId: string; + modelName: string; +}; + +export type OpenCodeRoute = { + providerID: string; + modelID: string; +}; + +export function parseOpenCodeModelSelector( + selector: string | undefined, +): OpenCodeRoute | undefined { + const value = selector?.trim(); + if (!value) return undefined; + const slash = value.indexOf("/"); + if (slash <= 0 || slash === value.length - 1) return undefined; + return { + providerID: value.slice(0, slash), + modelID: value.slice(slash + 1), + }; +} + +export function resolveOpenCodeRoute( + selector: string | undefined, + models: OpenCodeModelOption[], +): OpenCodeRoute | undefined { + const value = selector?.trim(); + if (!value) return undefined; + const exact = parseOpenCodeModelSelector(value); + if (exact) { + const available = models.some( + (model) => model.providerId === exact.providerID && model.modelId === exact.modelID, + ); + if (available) return exact; + throw new Error(`OpenCode model "${value}" is not available from a connected provider`); + } + const matches = models.filter((model) => model.modelId === value); + if (matches.length === 1) { + return { providerID: matches[0].providerId, modelID: matches[0].modelId }; + } + if (matches.length === 0) { + throw new Error(`OpenCode model "${value}" is not available from a connected provider`); + } + throw new Error(`OpenCode model "${value}" is ambiguous; select an exact provider/model`); +} +``` + +Extend the existing inline `opencode` schema with: + +```ts +featureModels: z.record(z.string(), z.string()).optional(), +``` + +Return `agentDrafterProvider: "opencode"` from the OpenCode external-runtime branch of `applyAgentDrafterSelection()`. Leave Hostler's branch unchanged. + +- [ ] **Step 4: Route features through per-feature, legacy, and default selectors** + +Add to `src/main/ipc/settings.ipc.ts`: + +```ts +export function getOpenCodeModelSelector(feature: keyof ModelConfig): string { + const opencode = getConfig().opencode; + return opencode?.featureModels?.[feature] ?? opencode?.model ?? ""; +} +``` + +Extend `getFeatureModelConfig()`: + +```ts +if (provider === "opencode") { + return { provider, model: getOpenCodeModelSelector(feature) }; +} +``` + +Remove the Anthropic/Ollama credential gate from `getBackgroundAgentProviderId()`. OpenCode availability is `enabled + bundled binary`; its subprocess owns credentials and reports missing auth/model failures. + +- [ ] **Step 5: Deep-merge OpenCode settings and propagate the full config** + +In `settings:set`, preserve fields owned by the other settings surface: + +```ts +if ("opencode" in config) { + const incoming = config.opencode; + const existing = currentConfig.opencode; + newConfig = { + ...newConfig, + opencode: incoming + ? { + enabled: incoming.enabled ?? existing?.enabled ?? false, + model: incoming.model ?? existing?.model, + featureModels: incoming.featureModels ?? existing?.featureModels, + } + : undefined, + }; +} +``` + +Propagate `featureModels` through `agentCoordinator.updateConfig({ opencode: ... })`, and include `"featureProviders" in config` in that update condition so the worker sees Agent Drafter/Chat routing changes immediately. + +- [ ] **Step 6: Re-run focused tests** + +```bash +npx playwright test --project=unit tests/unit/opencode-config.spec.ts tests/unit/background-agent-provider.spec.ts +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/shared/types.ts src/main/ipc/settings.ipc.ts tests/unit/opencode-config.spec.ts tests/unit/background-agent-provider.spec.ts +git commit -m "Add per-feature OpenCode model routing" +``` + +--- + +### Task 2: Build the lazy, tool-disabled OpenCode inference service + +**Files:** + +- Create: `src/main/services/opencode-inference-service.ts` +- Modify: `src/main/agents/providers/opencode/opencode-agent-provider.ts:741-875` +- Create: `tests/unit/opencode-inference-service.spec.ts` + +**Interfaces:** + +- Consumes: `resolveOpencodeBinary()`, `resolveOpenCodeRoute()`, OpenCode v2 `provider.list`, `tool.ids`, `session.create`, `session.prompt`, and `session.delete`. +- Produces: `OpenCodeInferenceService.complete()`, `.listModels()`, `.close()`, `OpenCodeInferenceRequest`, and `OpenCodeInferenceResult`. + +- [ ] **Step 1: Write failing service tests with an injected launcher** + +Create a fake v2 client that records calls and returns: + +```ts +const response = { + info: { + id: "assistant-1", + role: "assistant" as const, + providerID: "openai", + modelID: "gpt-5.2", + cost: 0.0123, + tokens: { + input: 120, + output: 35, + reasoning: 7, + cache: { read: 10, write: 4 }, + }, + finish: "stop", + }, + parts: [{ type: "text" as const, text: "hello" }], +}; +``` + +Cover these behaviors: + +- two simultaneous first calls invoke the launcher once; +- `close()` during startup invalidates and closes the stale handle instead of publishing it; +- `listModels()` returns only models from `connected` provider IDs; +- exact and unique legacy bare selectors resolve correctly; +- the prompt sends every returned tool ID as `false`; +- failure to load tool IDs fails closed before prompting; +- the session receives `[{ permission: "*", pattern: "*", action: "deny" }]`; +- `format: { type: "json_schema", schema }` is sent when requested; +- response text, structured value, route, tokens, cache tokens, finish reason, and dollar cost are preserved; +- `session.delete()` runs on success, prompt failure, and abort; +- a cleanup error is secondary to the original prompt error; +- `close()` closes the server and clears the lazy handle. + +- [ ] **Step 2: Run the service tests and verify they fail** + +```bash +npx playwright test --project=unit tests/unit/opencode-inference-service.spec.ts +``` + +Expected: module-not-found failure. + +- [ ] **Step 3: Export the existing binary resolver** + +Add `export` to the current `resolveOpencodeBinary(): string | null` declaration without changing its memoized development or packaged resolution body. + +- [ ] **Step 4: Define the narrow service boundary** + +Create these public types: + +```ts +export type OpenCodeInferenceRequest = { + selector?: string; + system?: string; + prompt: string; + outputSchema?: Record; + signal?: AbortSignal; +}; + +export type OpenCodeInferenceResult = { + id: string; + text: string; + structured?: unknown; + providerId: string; + modelId: string; + finishReason: string | null; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + costDollars?: number; +}; + +type OpenCodeClient = ReturnType; +type OpenCodeClientLike = { + provider: Pick; + tool: Pick; + session: Pick; +}; + +type OpenCodeHandle = { + client: OpenCodeClientLike; + close: () => void; +}; + +export type OpenCodeLauncher = () => Promise; +``` + +The tests may cast their deliberately partial fake through `unknown` to `OpenCodeClientLike`; production remains checked against the installed SDK without using `any`. + +- [ ] **Step 5: Implement the production v2 launcher** + +Use the existing CJS-safe dynamic import pattern with the v2 exports: + +```ts +import type * as OpenCodeV2Client from "@opencode-ai/sdk/v2/client"; +import type * as OpenCodeV2Server from "@opencode-ai/sdk/v2/server"; + +const importDynamic = new Function("s", "return import(s)") as ( + specifier: string, +) => Promise; + +async function launchOpenCode(): Promise { + const binPath = resolveOpencodeBinary(); + if (!binPath) throw new Error("Bundled OpenCode executable was not found"); + const binDir = dirname(binPath); + const currentPath = process.env.PATH ?? ""; + if (!currentPath.split(pathDelimiter).includes(binDir)) { + process.env.PATH = `${binDir}${pathDelimiter}${currentPath}`; + } + + const serverModule = (await importDynamic( + "@opencode-ai/sdk/v2/server", + )) as typeof OpenCodeV2Server; + const clientModule = (await importDynamic( + "@opencode-ai/sdk/v2/client", + )) as typeof OpenCodeV2Client; + const server = await serverModule.createOpencodeServer({ + hostname: "127.0.0.1", + port: 0, + timeout: 30_000, + config: { logLevel: "WARN" }, + }); + const client = clientModule.createOpencodeClient({ baseUrl: server.url }); + return { client, close: () => server.close() }; +} +``` + +Do not pass `provider`, provider keys, `disabled_providers`, or MCP configuration. That allows OpenCode to load its own global provider/auth configuration. + +- [ ] **Step 6: Implement catalog filtering, routing, and isolated completion** + +`listModels()` must require a successful `provider.list()` response, then flatten `data.all`, restricted to `data.connected`, into `OpenCodeModelOption[]`, sorted by provider name then model name. Do not turn provider-list errors into an empty catalog. + +Reuse the existing OpenCode agent provider's generation-guard pattern: capture `configGeneration` before launching, publish the handle only if the generation still matches, close a stale handle, and clear the shared startup promise on failure. `close()` increments the generation before clearing/closing handles so an in-flight launch cannot republish itself. + +`complete()` must: + +```ts +const models = await this.listModels(); +const route = resolveOpenCodeRoute(request.selector, models); +const toolResponse = await client.tool.ids(); +const toolIds = toolResponse.data; +if (!toolIds) throw new Error("OpenCode tool catalog could not be loaded"); +const tools = Object.fromEntries(toolIds.map((id) => [id, false])); +const created = await client.session.create({ + title: `exo-feature:${randomUUID()}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], +}); +const sessionId = created.data?.id; +if (!sessionId) throw new Error("OpenCode session.create returned no id"); +``` + +Then call: + +```ts +const prompted = await client.session.prompt( + { + sessionID: sessionId, + ...(route ? { model: route } : {}), + system: request.system, + tools, + format: request.outputSchema + ? { type: "json_schema", schema: request.outputSchema } + : { type: "text" }, + parts: [{ type: "text", text: request.prompt }], + }, + { signal: request.signal }, +); +if (!prompted.data) throw new Error("OpenCode session.prompt returned no response"); +``` + +Extract `prompted.data.info.structured` when present; otherwise concatenate its text parts. Delete with `client.session.delete({ sessionID: sessionId })` in `finally`. If deletion fails after a prompt failure, log cleanup separately and rethrow the prompt failure. + +Export the singleton used by IPC and `createMessage()`: + +```ts +export const openCodeInferenceService = new OpenCodeInferenceService(); +``` + +- [ ] **Step 7: Re-run service and binary tests** + +```bash +npx playwright test --project=unit tests/unit/opencode-inference-service.spec.ts tests/unit/opencode-binary-resolution.spec.ts +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/services/opencode-inference-service.ts src/main/agents/providers/opencode/opencode-agent-provider.ts tests/unit/opencode-inference-service.spec.ts +git commit -m "Add tool-disabled OpenCode inference service" +``` + +--- + +### Task 3: Route `createMessage()` through OpenCode and record real usage + +**Files:** + +- Modify: `src/main/services/llm-service.ts:1-375` +- Modify: `src/main/services/llm-service.ts:600-745` +- Modify: `tests/unit/llm-service.spec.ts` + +**Interfaces:** + +- Consumes: `OpenCodeInferenceService.complete()` and existing Anthropic-compatible `MessageCreateParamsNonStreaming`. +- Produces: OpenCode-compatible `CreateOptions.outputSchema`, an Anthropic-compatible `Message`, and `llm_calls` rows with exact route/tokens/cost. + +- [ ] **Step 1: Add failing OpenCode adapter and recording tests** + +Inject a fake service result: + +```ts +{ + id: "assistant-1", + text: "{\"needs_reply\":true,\"reason\":\"Direct question\"}", + structured: { needs_reply: true, reason: "Direct question" }, + providerId: "openai", + modelId: "gpt-5.2", + finishReason: "stop", + inputTokens: 120, + outputTokens: 35, + cacheReadTokens: 10, + cacheWriteTokens: 4, + reasoningTokens: 7, + costDollars: 0.0123, +} +``` + +Assert: + +- `createMessage(..., { provider: "opencode" })` calls the fake exactly once; +- no Anthropic/Ollama retry client is called; +- system and message text are flattened without cache-control metadata; +- Zod schema is converted with `z.toJSONSchema()` and forwarded; +- returned `Message.model` is `openai/gpt-5.2`; +- returned usage matches OpenCode tokens; +- the DB row has `provider = "opencode"`, exact model, and `cost_cents = 1.23`; +- a service rejection creates one failed row and is rethrown; +- OpenCode calls with `params.tools` fail explicitly before inference. + +- [ ] **Step 2: Run the focused LLM tests and verify they fail** + +```bash +npx playwright test --project=unit tests/unit/llm-service.spec.ts +``` + +- [ ] **Step 3: Add the OpenCode schema and test seam** + +Import runtime `z` from `zod` and add: + +```ts +outputSchema?: z.ZodType; +``` + +Place that property at the end of the existing `CreateOptions` interface. + +Add: + +```ts +type OpenCodeServiceLike = Pick; +let openCodeService: OpenCodeServiceLike = openCodeInferenceService; + +export function _setOpenCodeServiceForTesting(service?: OpenCodeServiceLike): void { + openCodeService = service ?? openCodeInferenceService; +} +``` + +Add `provider: string` to `LlmCallRecord`; the database query already returns that column and the OpenCode tests inspect it. + +- [ ] **Step 4: Allow provider-reported cost without changing existing pricing** + +Extend `recordCall()` with a final optional `costCentsOverride?: number`. Add it after `provider` and calculate: + +```ts +const costCents = + costCentsOverride ?? + (provider === "ollama-cloud" + ? 0 + : calculateCostCents(model, inputTokens, outputTokens, cacheReadTokens, cacheCreateTokens)); +``` + +Existing Anthropic and Ollama callers continue unchanged. + +- [ ] **Step 5: Add the OpenCode early branch before the retry loop** + +Reject tool-bearing requests, flatten the current system/messages using the existing text helpers, create one timeout controller, and call the service once: + +```ts +if (provider === "opencode") { + if (params.tools?.length) { + throw new Error("OpenCode feature inference does not support tools"); + } + const controller = timeoutMs ? new AbortController() : undefined; + const timer = timeoutMs ? setTimeout(() => controller?.abort(), timeoutMs) : undefined; + try { + const result = await openCodeService.complete({ + selector: params.model || undefined, + system: flattenSystemPrompt(params.system), + prompt: params.messages + .map( + (message) => `${message.role.toUpperCase()}:\n${flattenMessageContent(message.content)}`, + ) + .join("\n\n"), + outputSchema: options.outputSchema + ? (z.toJSONSchema(options.outputSchema) as Record) + : undefined, + signal: controller?.signal, + }); + const resolvedModel = `${result.providerId}/${result.modelId}`; + const text = result.structured === undefined ? result.text : JSON.stringify(result.structured); + const response = { + id: result.id, + type: "message", + role: "assistant", + model: resolvedModel, + content: [{ type: "text", text, citations: null }], + container: null, + stop_details: null, + stop_reason: result.finishReason === "length" ? "max_tokens" : "end_turn", + stop_sequence: null, + usage: { + input_tokens: result.inputTokens, + output_tokens: result.outputTokens, + cache_creation_input_tokens: result.cacheWriteTokens, + cache_read_input_tokens: result.cacheReadTokens, + server_tool_use: null, + service_tier: null, + }, + } as Message; + recordCall( + resolvedModel, + caller, + emailId ?? null, + accountId ?? null, + result.inputTokens, + result.outputTokens, + result.cacheReadTokens, + result.cacheWriteTokens, + Date.now() - startTime, + true, + null, + "opencode", + result.costDollars === undefined ? undefined : result.costDollars * 100, + ); + return response; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + recordCall( + params.model || "opencode-default", + caller, + emailId ?? null, + accountId ?? null, + 0, + 0, + 0, + 0, + Date.now() - startTime, + false, + errorMessage, + "opencode", + ); + throw error; + } finally { + if (timer) clearTimeout(timer); + } +} +``` + +When `result.structured` exists, serialize it as the text block. Use `result.providerId/result.modelId` for the recorded and returned model. Map cache read/write values to the existing Anthropic usage fields. Do not enter `RETRY_CONFIGS`. + +- [ ] **Step 6: Re-run LLM tests** + +```bash +npx playwright test --project=unit tests/unit/llm-service.spec.ts +``` + +Expected: all tests pass, including existing Anthropic/Ollama retry tests. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/services/llm-service.ts tests/unit/llm-service.spec.ts +git commit -m "Route feature inference through OpenCode" +``` + +--- + +### Task 4: Constrain structured features and remove hidden provider fallbacks + +**Files:** + +- Modify: `src/main/services/email-analyzer.ts:170-220` +- Modify: `src/main/services/calendaring-agent.ts:1-70` +- Modify: `src/main/services/archive-ready-analyzer.ts:1-80` +- Modify: `src/main/services/analysis-edit-learner.ts:285-500` +- Modify: `src/extensions/mail-ext-web-search/src/web-search-provider.ts:1-520` +- Modify: `tests/unit/email-analyzer.spec.ts` +- Modify: `tests/unit/calendaring-agent.spec.ts` +- Modify: `tests/unit/archive-ready.spec.ts` +- Modify: `tests/unit/sender-lookup.spec.ts` + +**Interfaces:** + +- Consumes: `CreateOptions.outputSchema` and each feature's existing Zod schema. +- Produces: native OpenCode JSON-schema requests while preserving every current parser and conservative fallback. + +- [ ] **Step 1: Add failing assertions for structured options and Sender Lookup behavior** + +Construct the analyzer, calendaring, and archive-ready services with `provider: "opencode"`, inject the fake OpenCode service via `_setOpenCodeServiceForTesting()`, and assert each captured `complete()` request contains its expected converted JSON schema. + +Add a Sender Lookup case where: + +```ts +getSearchConfig: () => ({ + provider: "exa", + exaApiKey: "", + anthropicConfigured: true, +}), +getParsingModelConfig: () => ({ + provider: "opencode", + model: "openai/gpt-5.2", +}), +``` + +Assert the provider logs/skips the lookup and does not invoke Anthropic web search. + +- [ ] **Step 2: Run the four focused suites and verify the assertions fail** + +```bash +npx playwright test --project=unit tests/unit/email-analyzer.spec.ts tests/unit/calendaring-agent.spec.ts tests/unit/archive-ready.spec.ts tests/unit/sender-lookup.spec.ts +``` + +- [ ] **Step 3: Pass existing schemas to structured feature calls** + +Pass these exact options: + +```ts +{ caller: "email-analyzer", emailId: email.id, accountId, provider: this.provider, outputSchema: AnalysisResultSchema } +{ caller: "calendaring-agent", emailId: email.id, provider: this.provider, outputSchema: CalendaringResultSchema } +{ caller: "archive-ready-analyzer", provider: this.provider, outputSchema: ArchiveReadyResultSchema } +``` + +Import `CalendaringResultSchema` from shared types. Keep all three services' existing `JSON.parse` + Zod/conservative fallback code unchanged. + +- [ ] **Step 4: Generalize analysis-learning model selection** + +Replace each Ollama-only branch with its existing pinned Anthropic model and the configured model for every non-Anthropic provider: + +```ts +const { provider, model: configuredModel } = getFeatureModelConfig("analysis"); +const model = provider === "anthropic" ? "claude-sonnet-4-20250514" : configuredModel; +``` + +Use `claude-sonnet-4-20250514` in `analyzeOverride()`, `claude-sonnet-4-5-20250929` in `matchAnalysisDraftMemories()`, and `claude-haiku-4-5-20251001` in `classifyScope()`. This prevents an OpenCode route from receiving a hardcoded bare Claude ID. + +Import `z` and define: + +```ts +const AnalysisObservationResponseSchema = z.array( + z.object({ + scope: z.string(), + scopeValue: z.string().nullable(), + content: z.string(), + emailContext: z.string().optional(), + }), +); +const AnalysisMatchResponseSchema = z.array( + z.object({ + observationIndex: z.number().int().nonnegative(), + matchedDraftMemoryId: z.string().nullable(), + }), +); +const AnalysisScopeResponseSchema = z.object({ + scope: z.string(), + scopeValue: z.string().nullable(), +}); +``` + +Pass the matching schema as `outputSchema` at the three callsites and keep `parseJsonArray()` / `normalizeScope()` as the final trust-boundary validation. + +- [ ] **Step 5: Constrain Exa profile extraction and prohibit cross-fallback** + +Add a local response schema: + +```ts +const SenderProfileResponseSchema = z.object({ + name: z.string(), + summary: z.string(), + title: z.string().optional(), + company: z.string().optional(), + linkedinUrl: z.string().optional(), +}); +``` + +Pass it to `lookupViaExa()`'s `createMessage()` options. + +When Exa is selected but its key is missing: + +```ts +const parsingModel = deps.getParsingModelConfig(); +if (parsingModel.provider !== "anthropic") { + context.logger.error( + "Sender Lookup requires an Exa API key when its parsing model is not Anthropic", + ); + return null; +} +``` + +Retain the existing Anthropic fallback only when the selected parsing provider is Anthropic. Exa request and OpenCode inference failures continue through the existing logged `null` enrichment path; neither invokes another LLM provider. + +- [ ] **Step 6: Re-run the focused suites** + +```bash +npx playwright test --project=unit tests/unit/email-analyzer.spec.ts tests/unit/calendaring-agent.spec.ts tests/unit/archive-ready.spec.ts tests/unit/sender-lookup.spec.ts +``` + +Expected: all tests pass and existing malformed-output fallbacks remain covered. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/services/email-analyzer.ts src/main/services/calendaring-agent.ts src/main/services/archive-ready-analyzer.ts src/main/services/analysis-edit-learner.ts src/extensions/mail-ext-web-search/src/web-search-provider.ts tests/unit/email-analyzer.spec.ts tests/unit/calendaring-agent.spec.ts tests/unit/archive-ready.spec.ts tests/unit/sender-lookup.spec.ts +git commit -m "Constrain OpenCode structured feature output" +``` + +--- + +### Task 5: Expose connected OpenCode models through narrow IPC + +**Files:** + +- Modify: `src/main/ipc/settings.ipc.ts:335-355` +- Modify: `src/preload/index.ts:225-265` +- Modify: `src/shared/types.ts:975-990` +- Modify: `src/main/index.ts:1-45` +- Modify: `src/main/index.ts:640-660` +- Modify: `tests/unit/opencode-config.spec.ts` + +**Interfaces:** + +- Consumes: `OpenCodeInferenceService.listModels()` and `.close()`. +- Produces: `settings:list-opencode-models` returning `IpcResponse`. + +- [ ] **Step 1: Add a failing preload/channel contract test** + +Read `src/preload/index.ts` and `src/shared/types.ts` as source text and assert they contain: + +```ts +listOpenCodeModels: (): Promise => ipcRenderer.invoke("settings:list-opencode-models"); +``` + +The shared-types source assertion must require `"settings:list-opencode-models": void` in `IpcChannels`. + +- [ ] **Step 2: Run the contract test and verify it fails** + +```bash +npx playwright test --project=unit tests/unit/opencode-config.spec.ts +``` + +- [ ] **Step 3: Register the catalog handler** + +In `registerSettingsIpc()`: + +```ts +ipcMain.handle( + "settings:list-opencode-models", + async (): Promise> => { + try { + const config = getConfig(); + if (!config.opencode?.enabled) { + return { success: false, error: "Enable OpenCode in Settings → Extensions first" }; + } + return { success: true, data: await openCodeInferenceService.listModels() }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Could not load OpenCode models", + }; + } + }, +); +``` + +Add the preload method and channel type. Return model/provider IDs and names only. + +- [ ] **Step 4: Close the main-process server before app shutdown** + +In `settings:set`, compare the prior and validated `opencode.enabled` values. When they differ, call `openCodeInferenceService.close()` so disabling OpenCode releases the subprocess and enabling it starts from a fresh lazy handle on demand. + +Import `openCodeInferenceService` in `src/main/index.ts` and call: + +```ts +void openCodeInferenceService.close(); +``` + +at the start of `before-quit`, before database/log shutdown. + +- [ ] **Step 5: Re-run the contract and service tests** + +```bash +npx playwright test --project=unit tests/unit/opencode-config.spec.ts tests/unit/opencode-inference-service.spec.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ipc/settings.ipc.ts src/preload/index.ts src/shared/types.ts src/main/index.ts tests/unit/opencode-config.spec.ts +git commit -m "Expose connected OpenCode models" +``` + +--- + +### Task 6: Make agent sessions use OpenCode's global providers and record actual usage + +**Files:** + +- Modify: `src/main/agents/types.ts:1-205` +- Modify: `src/main/agents/agent-worker.ts:95-165` +- Modify: `src/main/agents/orchestrator.ts:225-250` +- Modify: `src/main/agents/agent-coordinator.ts:55-110` +- Modify: `src/main/agents/agent-coordinator.ts:220-240` +- Modify: `src/main/agents/agent-coordinator.ts:365-425` +- Modify: `src/main/agents/providers/opencode/opencode-agent-provider.ts:130-720` +- Modify: `src/main/ipc/agent.ipc.ts:1-70` +- Modify: `src/main/services/llm-service.ts:300-345` +- Modify: `tests/unit/opencode-resolve-route.spec.ts` +- Modify: `tests/unit/background-agent-provider.spec.ts` +- Create: `tests/unit/opencode-agent-usage.spec.ts` + +**Interfaces:** + +- Consumes: exact per-feature OpenCode selectors, connected OpenCode catalog, and v1 `message.updated` assistant metadata. +- Produces: global-config agent routing, Agent Drafter/Chat model overrides, and one actual-usage `llm_calls` record per OpenCode run. + +- [ ] **Step 1: Write failing global-config, model-override, and usage tests** + +Cover: + +- `buildOpenCodeAgentConfig(bridgeUrl)` contains the mail MCP bridge and no `provider` or `disabled_providers`; +- `OpenCodeAgentProvider.isAvailable()` needs only `opencode.enabled` and a resolvable binary; +- an exact runtime selector wins over legacy `opencode.model`; +- blank selection omits `body.model` so OpenCode chooses its default; +- unique legacy bare models resolve through the connected catalog; +- Agent Drafter defaults to `opencode.featureModels.agentDrafter`; +- Agent Chat uses `opencode.featureModels.agentChat` only when `providerIds` selects OpenCode; +- a palette override back to Claude receives the normal Anthropic model, not the OpenCode selector; +- repeated snapshots for the same assistant message are counted once using the latest values; +- completed and failed OpenCode runs report actual tokens/cache/cost/route once. + +- [ ] **Step 2: Run focused agent tests and verify they fail** + +```bash +npx playwright test --project=unit tests/unit/opencode-resolve-route.spec.ts tests/unit/background-agent-provider.spec.ts tests/unit/opencode-agent-usage.spec.ts +``` + +- [ ] **Step 3: Stop injecting Exo LLM credentials into the agent server** + +Extract and export: + +```ts +export function buildOpenCodeAgentConfig(bridgeUrl: string): Config { + return { + logLevel: "WARN", + mcp: { + "mail-app-tools": { type: "remote", url: bridgeUrl, enabled: true }, + }, + permission: { edit: "allow", bash: "allow", webfetch: "allow" }, + }; +} +``` + +Delete the custom Anthropic/Ollama `provider` registration and `computeDisabledProviders()`. Limit server invalidation to the `opencode` config key. Change `isAvailable()` to enabled + binary only. + +- [ ] **Step 4: Resolve each run against OpenCode's connected catalog** + +After the server is ready, call `client.provider.list()`, flatten connected models to `OpenCodeModelOption[]`, and resolve: + +```ts +const selector = modelOverride?.trim() || this.frameworkConfig.opencode?.model || ""; +const route = resolveOpenCodeRoute(selector, connectedModels); +``` + +Pass `model: route` only when defined. OpenCode's default handles the blank case. + +- [ ] **Step 5: Send the correct per-feature model override** + +Include `opencode.featureModels` in `AgentFrameworkConfig`. + +In `AgentCoordinator.runAgent()`, when no explicit override is supplied and the sole provider is OpenCode, use `getOpenCodeModelSelector("agentDrafter")`. + +In `agent.ipc.ts`, select: + +```ts +const selectedProvider = providerIds[0]; +const modelOverride = + selectedProvider === "opencode" + ? getOpenCodeModelSelector("agentChat") + : (resolveAgentOllamaConfig(getConfig())?.model ?? getModelIdForFeature("agentChat")); +``` + +This preserves palette overrides: choosing Claude never sends an OpenCode model to the Claude provider. + +- [ ] **Step 6: Replace the zero-token OpenCode approximation with completed-run usage** + +Extend the existing `AgentSessionStartFn` arguments without renaming the callback across the worker boundary: + +```ts +export type AgentSessionStartFn = (args: { + harness: string; + provider: LlmProvider; + model: string; + accountId?: string; + emailId?: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreateTokens?: number; + costDollars?: number; + durationMs?: number; + success?: boolean; + errorMessage?: string; +}) => void; +``` + +Keep Claude/Hostler's current zero-token start behavior through `recordSessionStart`. For OpenCode, maintain a `Map` from `message.updated` events, replacing each snapshot. At every terminal return, sum the latest snapshots and invoke `recordSessionStart` once using actual provider/model, tokens, cache, dollars, duration, success, and error. + +Change `recordAgentSessionStart()` to accept these optional values and pass `costDollars * 100` as the `recordCall()` override. Use caller `agent-run:`. + +- [ ] **Step 7: Re-run focused agent tests** + +```bash +npx playwright test --project=unit tests/unit/opencode-resolve-route.spec.ts tests/unit/background-agent-provider.spec.ts tests/unit/opencode-agent-usage.spec.ts +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/agents/types.ts src/main/agents/agent-worker.ts src/main/agents/orchestrator.ts src/main/agents/agent-coordinator.ts src/main/agents/providers/opencode/opencode-agent-provider.ts src/main/ipc/agent.ipc.ts src/main/services/llm-service.ts tests/unit/opencode-resolve-route.spec.ts tests/unit/background-agent-provider.spec.ts tests/unit/opencode-agent-usage.spec.ts +git commit -m "Use global OpenCode agent models and usage" +``` + +--- + +### Task 7: Add OpenCode to every eligible AI Models row + +**Files:** + +- Create: `src/renderer/components/OpenCodeModelInput.tsx` +- Modify: `src/renderer/components/SettingsPanel.tsx:1-330` +- Modify: `src/renderer/components/SettingsPanel.tsx:430-485` +- Modify: `src/renderer/components/SettingsPanel.tsx:1270-1540` +- Modify: `src/renderer/App.tsx:700-780` +- Modify: `src/renderer/components/AgentCommandPalette.tsx:150-245` +- Modify: `tests/e2e/settings.spec.ts:430-570` +- Modify: `tests/packaged/smoke.spec.ts:210-285` + +**Interfaces:** + +- Consumes: `settings.listOpenCodeModels()`, `opencode.featureModels`, existing `defaultAgentIds`, and existing staged Save Changes flow. +- Produces: native searchable model fields, refresh/error/unavailable states, Agent Drafter synchronization, and Agent Chat's Cmd+J default. + +- [ ] **Step 1: Add a failing deterministic Playwright flow** + +Before Settings mounts, replace `settings:list-opencode-models` in the Electron main process with: + +```ts +{ + success: true, + data: [ + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.2", + modelName: "GPT-5.2", + }, + { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }, + ], +} +``` + +Set OpenCode enabled and Exa selected. Assert: + +- all eight visible rows have an enabled OpenCode option; +- Analysis saves `openai/gpt-5.2`; +- Draft Generation saves `anthropic/claude-sonnet-4-5`; +- closing/reopening Settings preserves both; +- a saved removed selector remains in the input with an “unavailable” warning; +- leaving Exa resets Sender Lookup from OpenCode to Anthropic; +- selecting OpenCode for Agent Drafter saves both `backgroundAgentProvider = "opencode"` and `featureProviders.agentDrafter = "opencode"`; +- selecting OpenCode for Agent Chat makes Cmd+J preselect OpenCode; +- clicking Claude in the palette overrides that conversation; +- reopening Cmd+J returns to the configured OpenCode default. + +- [ ] **Step 2: Run the Settings flow and verify it fails** + +```bash +npx playwright test --project=e2e tests/e2e/settings.spec.ts +``` + +- [ ] **Step 3: Build the native model input** + +`OpenCodeModelInput` uses `` and ``: + +```tsx +interface OpenCodeModelInputProps { + value: string; + onChange: (value: string) => void; + models: OpenCodeModelOption[]; + loading: boolean; + error?: string; + onRefresh: () => void; + ariaLabel: string; +} + +export function OpenCodeModelInput({ + value, + onChange, + models, + loading, + error, + onRefresh, + ariaLabel, +}: OpenCodeModelInputProps) { + const listId = useId(); + const available = models.some((model) => `${model.providerId}/${model.modelId}` === value); + return ( +
+
+ onChange(event.target.value)} + placeholder="OpenCode default" + aria-label={ariaLabel} + /> + +
+ + {models.map((model) => ( + + ))} + + {error &&

{error}

} + {value && !loading && !error && !available && ( +

Saved model is unavailable in OpenCode.

+ )} +
+ ); +} +``` + +Use the existing field/button classes from `SettingsPanel`; do not introduce a component library. + +- [ ] **Step 4: Fetch the catalog with TanStack Query and stage per-feature values** + +Add: + +```ts +const [openCodeModels, setOpenCodeModels] = useState>({}); +const openCodeCatalog = useQuery({ + queryKey: ["opencode-models"], + enabled: generalConfig?.opencode?.enabled === true, + queryFn: async () => { + const result = (await window.api.settings.listOpenCodeModels()) as IpcResponse< + OpenCodeModelOption[] + >; + if (!result.success) throw new Error(result.error); + return result.data; + }, +}); +``` + +Hydrate `openCodeModels` once from `generalConfig.opencode?.featureModels`. Save only: + +```ts +opencode: { featureModels: openCodeModels }, +``` + +so the main-process deep merge preserves Extensions-owned `enabled` and legacy `model`. + +- [ ] **Step 5: Render OpenCode for every eligible row** + +Always render: + +```tsx + +``` + +except Sender Lookup when its backend is not Exa. If a disabled saved OpenCode value exists, retain the disabled option and show the Extensions enablement link. + +Render: + +- Anthropic tier select for `provider === "anthropic"`; +- `OllamaModelSelect` for `provider === "ollama-cloud"`; +- `OpenCodeModelInput` for `provider === "opencode"`; +- the existing Extensions model link only for Hostler. + +When Sender Lookup leaves Exa, reset both Ollama and OpenCode selections to Anthropic. + +- [ ] **Step 6: Make the existing Agent Chat default store effective** + +Select `setDefaultAgentIds` from the existing app store. When `App.tsx` loads settings, call: + +```ts +setDefaultAgentIds([ + result.data.featureProviders?.agentChat === "opencode" && result.data.opencode?.enabled + ? "opencode" + : "claude", +]); +``` + +After Settings saves successfully, update the same store field from the staged selection. + +In `AgentCommandPalette`, consume `defaultAgentIds` and on every open set: + +```ts +setSelectedAgentIds(defaultAgentIds.length ? defaultAgentIds : ["claude"]); +``` + +The user may click a different provider before submitting; reopening the palette restores the configured default. + +- [ ] **Step 7: Extend packaged smoke coverage** + +In the isolated packaged profile, enable OpenCode, save one OpenCode feature selector, restart, open Settings, and assert the Analysis and Agent Chat provider controls show OpenCode. Keep the existing bundled-binary and Cmd+J provider checks. + +- [ ] **Step 8: Re-run E2E and packaged-source tests** + +```bash +npx playwright test --project=e2e tests/e2e/settings.spec.ts +npx playwright test --project=unit tests/unit/opencode-config.spec.ts +``` + +Expected: all tests pass. + +- [ ] **Step 9: Commit** + +```bash +git add src/renderer/components/OpenCodeModelInput.tsx src/renderer/components/SettingsPanel.tsx src/renderer/App.tsx src/renderer/components/AgentCommandPalette.tsx tests/e2e/settings.spec.ts tests/packaged/smoke.spec.ts +git commit -m "Add per-feature OpenCode model controls" +``` + +--- + +### Task 8: Verify the complete adapter and prove the packaged runtime + +**Files:** + +- Review every changed source and test file. +- Update: `docs/superpowers/specs/2026-07-29-opencode-feature-provider-adapter-design.md` only if implementation reveals a factual mismatch. + +**Interfaces:** + +- Consumes: the complete implementation from Tasks 1-7. +- Produces: green local gates, a packaged artifact, installed-runtime evidence, and a review-ready branch. + +- [ ] **Step 1: Run formatting and static gates** + +```bash +npm run format:check +npm run typecheck +npm run lint +git diff --check +``` + +Expected: all commands exit zero. + +- [ ] **Step 2: Run focused OpenCode coverage** + +```bash +npx playwright test --project=unit tests/unit/opencode-config.spec.ts tests/unit/opencode-inference-service.spec.ts tests/unit/llm-service.spec.ts tests/unit/opencode-resolve-route.spec.ts tests/unit/opencode-agent-usage.spec.ts tests/unit/background-agent-provider.spec.ts tests/unit/email-analyzer.spec.ts tests/unit/calendaring-agent.spec.ts tests/unit/archive-ready.spec.ts tests/unit/sender-lookup.spec.ts +npx playwright test --project=e2e tests/e2e/settings.spec.ts +``` + +Expected: all tests pass with no retries or teardown failures. + +- [ ] **Step 3: Run the full baseline suite** + +```bash +npm test +``` + +Expected: all unit, integration, and E2E tests pass. Treat any failure as a failure, not flakiness. + +- [ ] **Step 4: Build and package with Node 22** + +```bash +npm run build +CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack +EXO_PACKAGED_BINARY=release/mac-arm64/Exo.app/Contents/MacOS/Exo npx playwright test --project=packaged +``` + +Expected: build, package, and packaged smoke all pass. + +- [ ] **Step 5: Commit any verification-only corrections** + +```bash +git add -u +git diff --cached --check +git commit -m "Finish OpenCode provider verification" +``` + +Skip this commit when verification required no source change. + +- [ ] **Step 6: Request authorization for external publication and installation** + +Before pushing, opening/updating a PR, or replacing `/Applications/Exo.app`, ask the user for explicit authorization. Do not combine that request with unrelated decisions. + +- [ ] **Step 7: After authorization, push and open/update the draft PR** + +Push `codex/opencode-feature-adapter-design` to `upstream-pr`, open its draft PR against `ankitvgupta/exo:main`, and start the required full pre-PR gate immediately: + +```bash +git push -u upstream-pr codex/opencode-feature-adapter-design +/opt/homebrew/bin/gh pr create --repo ankitvgupta/exo --base main --head mickn:codex/opencode-feature-adapter-design --draft --fill +npm run pre-pr +/opt/homebrew/bin/gh pr checks codex/opencode-feature-adapter-design --repo ankitvgupta/exo +``` + +After `pre-pr` is running, add the design, architecture, test evidence, and UI screenshots to the PR body. If a draft PR for this branch already exists, replace `pr create` with `/opt/homebrew/bin/gh pr view codex/opencode-feature-adapter-design --repo ankitvgupta/exo`; do not create a duplicate. If `Verify pre-pr report` raced the report update, verify the marker and rerun only the failed workflow job. Then run the required `/review` and `/reviewloop` workflow until major findings are fixed and CI is green. + +- [ ] **Step 8: After installation authorization, replace the app safely** + +Quit only the running Exo process by its verified PID. Preserve a rollback copy of `/Applications/Exo.app`, replace only the app bundle with `release/mac-arm64/Exo.app`, and leave `~/Library/Application Support/exo` untouched. + +Compare SHA-256 values for packaged and installed `Contents/Resources/app.asar`, launch `/Applications/Exo.app`, and verify the live PID's executable path points to that bundle. + +- [ ] **Step 9: Prove the installed behavior** + +In the installed app: + +1. Confirm all eight eligible AI Models rows expose OpenCode and connected models. +2. Run Email Analysis through one OpenCode model and Draft Generation through another. +3. Run Agent Drafter through its selected OpenCode model. +4. Open Cmd+J and confirm Agent Chat defaults to OpenCode; override it once in the palette. +5. Query `llm_calls` read-only and verify provider, exact model, tokens, cost when supplied, duration, and success/error. +6. Select an unavailable model or temporarily use a missing credential and confirm the error is visible, no Anthropic/Ollama call occurs, and the saved selector remains unchanged. + +Capture screenshots of loaded, disabled/unavailable, and error states for the PR. diff --git a/docs/superpowers/specs/2026-07-29-opencode-feature-provider-adapter-design.md b/docs/superpowers/specs/2026-07-29-opencode-feature-provider-adapter-design.md new file mode 100644 index 00000000..f0a52c2e --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-opencode-feature-provider-adapter-design.md @@ -0,0 +1,217 @@ +# OpenCode as a Per-Feature AI Provider + +**Status:** Approved design, awaiting implementation-plan approval +**Date:** 2026-07-29 + +## The outcome + +When OpenCode is enabled, every user-visible row in **Settings → AI Models** can select OpenCode and an exact model from the providers already configured in OpenCode. Exo uses that provider/model for the selected feature, records the real route and usage, and surfaces configuration or inference failures instead of silently switching to Anthropic or Ollama Cloud. + +This extends the provider path already used by Exo. It does not create a second feature framework or make Exo manage OpenCode credentials. + +## Product decisions + +- OpenCode owns its provider connections, authentication, and model catalog. +- Exo stores an exact OpenCode selector per feature in `provider/model` form. +- OpenCode is available for Analysis, Draft Generation, Draft Refinement, Scheduling Detection, Archive-Ready Analysis, Sender Lookup when Exa performs the search, Agent Drafter, and Agent Chat. +- Each feature can use a different OpenCode model. +- Agent Chat uses the OpenCode selection from its AI Models row as the default Cmd+J runtime. An explicit runtime choice in the command palette still overrides that default for that conversation. +- Agent Drafter uses the OpenCode selection from its AI Models row and keeps `backgroundAgentProvider` synchronized with that selection. +- Failures are visible. Exo does not cross-fallback from OpenCode to Anthropic or Ollama Cloud. +- Existing feature-specific safe parsing behavior remains. For example, a feature that already treats malformed structured output conservatively continues to do so after a successful OpenCode response. + +## Configuration + +Extend the existing provider and OpenCode configuration instead of introducing a parallel settings object: + +```ts +export const LLM_PROVIDERS = [ + "anthropic", + "ollama-cloud", + "opencode", +] as const; + +opencode: { + enabled: boolean; + model?: string; // legacy global override + featureModels?: Record; // exact provider/model selectors +} +``` + +`featureProviders[feature]` remains the source of truth for which provider a feature uses. When it is `opencode`, model resolution is: + +1. `opencode.featureModels[feature]` +2. legacy `opencode.model` +3. OpenCode's configured default model + +The legacy `opencode.model` field remains readable and editable during the transition; no destructive migration is needed. Saving an exact per-feature selection writes `featureModels` and leaves the legacy value intact for features without their own selection. + +The third case stays unset in Exo: the inference request omits its explicit model and lets OpenCode choose its configured default. Exo then records the actual provider and model returned by OpenCode. This avoids a synchronous catalog lookup in `getFeatureModelConfig()`. + +If a saved selector is no longer present in OpenCode's catalog, Exo preserves it and displays it as unavailable. It does not replace it with a different model. + +Internal `styleInference` can use the same resolver if configured programmatically, but this project does not add a new visible row for it. + +## Runtime architecture + +### Shared feature calls + +`getFeatureModelConfig()` returns the selected provider and exact model selector when one is configured; an empty OpenCode selector means “use OpenCode's default.” `createMessage()` keeps its current Anthropic-compatible input and output boundary and gains an OpenCode branch: + +```text +feature caller + → getFeatureModelConfig(feature) + → createMessage(params, { provider: "opencode", ... }) + → lazy OpenCodeInferenceService + → OpenCode local server + → configured OpenCode provider/model + → Anthropic-compatible Message + → existing feature parser +``` + +`OpenCodeInferenceService` is a small main-process service that reuses the binary discovery, lazy server startup, SDK loading, and shutdown patterns already proven by `OpenCodeAgentProvider`. It uses the installed OpenCode SDK's v2 client because that client exposes structured-output formats and returned usage/cost metadata. It starts only on the first OpenCode feature call or model-catalog request and reuses the server while Exo is running. + +Each one-shot feature call: + +1. Creates an isolated OpenCode session. +2. Sends the existing system and user content to the exact provider/model. +3. Enumerates the server's tool IDs, passes each one as disabled on the prompt, and denies OpenCode permissions for bash, edit, fetch, and other side effects. +4. Converts the final text, usage, finish state, and model metadata to Exo's existing `Message` shape. +5. Deletes the session in `finally`, whether the call succeeds, fails, aborts, or times out. + +Session deletion is lifecycle cleanup, not a promise of secure erasure from OpenCode's local database or an upstream provider's logs. + +The service accepts an injected client in tests, matching the existing `llm-service` test seam. No new dependency or generic provider abstraction is needed. + +### Agent Drafter and Agent Chat + +Agent Drafter and Agent Chat continue through the existing agent worker and `OpenCodeAgentProvider`; they do not use the tool-disabled one-shot service. + +The agent provider changes its server configuration so it honors OpenCode's global providers and credentials instead of registering only Exo's Anthropic or Ollama Cloud settings. It stops injecting Exo's provider credentials and `disabled_providers`, while retaining the mail MCP bridge and Exo permission gate. A run receives the exact per-feature selector as `modelOverride`. + +Two lazy OpenCode server processes are acceptable: + +- the main process owns stateless, tool-disabled feature calls and catalog discovery; +- the agent worker owns conversational, tool-enabled agent sessions. + +Keeping these lifecycles separate avoids new cross-process request routing and preserves the current worker fault boundary. + +## Model discovery and settings UI + +Add a narrow settings IPC method that asks the main-process OpenCode service for `provider.list()`, filters the result to OpenCode's `connected` provider IDs, and returns their models. The response contains only: + +```ts +type OpenCodeModelOption = { + providerId: string; + providerName: string; + modelId: string; + modelName: string; +}; +``` + +Exo does not read, copy, expose, or persist OpenCode's `auth.json`. The OpenCode subprocess resolves its own global configuration and credentials. Catalog results are kept in renderer state only and refreshed on demand. + +Every visible AI Models row receives an OpenCode option when OpenCode is enabled. Selecting it shows a searchable native input backed by `` options, avoiding another UI dependency. The field stores the exact `provider/model` value, permits an exact manual selector, and provides: + +- provider-prefixed model labels sorted by provider; +- a **Refresh models** action; +- a loading state; +- a clear startup or catalog error; +- an unavailable marker for a saved model that disappeared. + +If OpenCode is disabled, an existing saved selection remains visible but unavailable, with a link to enable OpenCode in Extensions. + +Sender Lookup only offers OpenCode when `senderLookupProvider === "exa"`, because Exa supplies the search results and the selected LLM only extracts them. With Anthropic's bundled web-search backend selected, the UI explains that sender lookup must remain on Anthropic. + +## Structured output + +Structured callers may add their existing Zod schema to `CreateOptions`. The OpenCode branch converts it with the installed Zod 4 `z.toJSONSchema()` support and sends `format: { type: "json_schema", schema }` through the installed OpenCode v2 client. Text-only callers do not provide a schema. + +The returned structured value is serialized into the existing message text boundary, then processed by the current Zod validation and conservative feature fallback. This keeps parsing behavior centralized in current feature code while giving OpenCode a constrained generation target. + +Schema translation or validation failure is recorded and surfaced with the feature name and selected model. Exo does not retry the request on another provider. + +## Retry, timeout, and failure behavior + +- OpenCode owns retries within the selected provider. Exo does not wrap it in the Anthropic/Ollama retry loop. +- Exo applies the existing per-call timeout and abort signal to the OpenCode request. +- Startup, catalog, missing-auth, missing-model, model-rejection, timeout, malformed-response, and session-cleanup failures are logged with actionable context. +- The original inference error remains primary if session cleanup also fails; cleanup failure is logged separately. +- No error path changes `featureProviders`, `featureModels`, or the saved model. +- No OpenCode failure silently invokes Anthropic or Ollama Cloud. + +The UI uses the current feature failure surface where one exists. Background work logs and exposes the same failure through its existing status/error channel. + +## Usage and observability + +OpenCode feature calls write normal `llm_calls` rows with: + +- `provider = "opencode"`; +- the exact `provider/model` selector; +- actual input and output tokens returned by OpenCode; +- actual cost when OpenCode returns it; +- caller, account, email, duration, success, and error details. + +`recordCall()` accepts an optional provider-reported cost override, converted from OpenCode's dollar value to the table's `cost_cents`, instead of applying Anthropic's fallback price table to an arbitrary OpenCode model. + +For Agent Drafter and Agent Chat, the provider aggregates the completed OpenCode assistant messages for that run and sends exact route, token, cache, and cost totals back through the existing worker-to-main recording channel. Failed runs record the route and any usage accumulated before failure. This replaces the current zero-token session-start approximation and stops collapsing non-Ollama OpenCode routes to Anthropic. + +If OpenCode does not provide a usage or cost field, Exo records the available values without inventing an estimate. + +Logs must not include credentials, full OpenCode configuration, or email/prompt content beyond the app's existing logging policy. + +## Lifecycle and concurrency + +- Server startup is lazy, idempotent, and guarded against configuration changes during startup. +- Changing OpenCode enablement or model routing invalidates the relevant lazy server configuration. +- One-shot sessions are independent and may run concurrently. +- App shutdown closes both the main-process service and the existing worker provider. +- A catalog refresh reuses the main-process server; it does not spawn a server per row. + +## Expected code areas + +The implementation should stay within existing paths where possible: + +- `src/shared/types.ts` for provider/config schema changes; +- `src/main/ipc/settings.ipc.ts` for routing and model-catalog IPC; +- `src/main/services/llm-service.ts` for the OpenCode `createMessage()` branch and recording; +- one main-process OpenCode inference service beside the existing OpenCode provider; +- `src/main/agents/providers/opencode/opencode-agent-provider.ts` for global-provider routing and exact model overrides; +- `src/main/agents/agent-coordinator.ts` and agent types for per-feature model propagation; +- `src/renderer/components/SettingsPanel.tsx` for provider/model controls; +- preload/API typing only as required for the catalog method. + +Do not add a new provider framework, credential store, model database, catalog dependency, or auth UI. + +## Verification + +### Automated checks + +- Model resolution tests cover per-feature selector, legacy fallback, OpenCode default, disabled OpenCode, and missing saved models. +- `createMessage()` tests cover text conversion, structured output, exact route, usage/cost recording, abort, timeout, visible startup/auth/model errors, and session cleanup on every exit. +- Agent-provider tests cover global OpenCode configuration, Agent Drafter and Agent Chat overrides, and exact completed-run recording. +- Settings tests cover OpenCode on every visible row, catalog refresh, saved unavailable model, disabled state, Agent Drafter synchronization, Agent Chat defaulting, and Sender Lookup's Exa gate. +- One Playwright settings flow selects different OpenCode models for two features, saves, closes, reopens, and verifies both values. +- Existing typecheck, lint, focused tests, packaged-provider smoke, and full pre-PR suite remain green. + +### Installed-app proof + +Build and run the packaged macOS app containing this branch. In that installed runtime: + +1. Confirm OpenCode and its configured models appear on every eligible AI Models row. +2. Run one structured feature and one text feature through different OpenCode models. +3. Run Agent Drafter through its selected OpenCode model. +4. Start Agent Chat with Cmd+J and confirm its selected OpenCode model is the default. +5. Inspect `llm_calls` and logs to verify exact provider/model, tokens, cost when supplied, success/failure, and no fallback. +6. Exercise an unavailable model or missing credential and confirm the failure is visible and the saved selection is unchanged. + +## Out of scope + +- Managing OpenCode authentication inside Exo. +- Copying Exo's Anthropic or Ollama credentials into OpenCode. +- Persisting or pricing the OpenCode catalog. +- Guessing capability tiers or mapping arbitrary models to Haiku/Sonnet/Opus. +- Automatic provider or model fallback. +- Tool-enabled one-shot feature calls. +- Making Sender Lookup use OpenCode while Anthropic's bundled search backend is selected. +- Consolidating the main and worker OpenCode servers into a new IPC service. diff --git a/package-lock.json b/package-lock.json index 3bfef5e0..8c0c7877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@floating-ui/dom": "^1.7.6", "@hostler/sdk": "^0.1.0", "@modelcontextprotocol/sdk": "^1.26.0", - "@opencode-ai/sdk": "^1.15.10", + "@opencode-ai/sdk": "1.18.10", "@tanstack/react-query": "^5.62.0", "@tanstack/react-virtual": "^3.13.21", "@tiptap/extension-image": "^3.19.0", @@ -39,7 +39,7 @@ "googleapis": "^170.1.0", "mime-types": "^3.0.2", "nodemailer": "^9.0.1", - "opencode-ai": "^1.15.10", + "opencode-ai": "1.18.10", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-js": "^1.359.1", @@ -425,7 +425,6 @@ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz", "integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==", "license": "MIT", - "peer": true, "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" @@ -473,7 +472,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -794,7 +792,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1745,7 +1742,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -1765,12 +1761,12 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -2157,13 +2153,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", - "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2308,9 +2303,9 @@ "license": "MIT" }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz", - "integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.10.tgz", + "integrity": "sha512-K+glWbBp5pfGepZ/6EHvuXY3IpVG2qgsEqF4z9mOiUQUrs6KWN72Vdp15Qsl2TCDbemgHWXKQKgbX3b8PSK2hg==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -2838,7 +2833,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.21.0.tgz", "integrity": "sha512-IfnQiuEeabDSPr1C/zHFTbnvlTf5z0DE/d/xz4C6bkL4ZBDJ3rr99h2qsaV0l8F+kbNswZMlQdM8rxNlMy95fQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3074,7 +3068,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.21.0.tgz", "integrity": "sha512-KeBlEtLrGce2d3dgL89hmwWEtREuzlW4XY5bYWpKNvCbFqvdSb3n7vkdkw32YclZmMWxAcABgW6ucCStkE0rsQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3206,7 +3199,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.21.0.tgz", "integrity": "sha512-MN1uh5PmHT1F2BNsbc21MIS0AMFFA73oODlp/4ckpBR4o5AxRwV+8f43Cd52UL4MgMkKj/A+QfZ7iK9IDb0h5A==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3221,7 +3213,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.21.0.tgz", "integrity": "sha512-I3sNo7oMMsR6FFz1ecvPb9uCF0VQuS2WV67j8Io2M7DJicRWCE/GM5DaiYjTeWBbnByk6BuG0txoJATAqPVliQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3578,7 +3569,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3589,7 +3579,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3688,7 +3677,6 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -3957,7 +3945,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4222,6 +4209,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -4241,6 +4229,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -4263,6 +4252,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4278,7 +4268,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -4286,6 +4277,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -4566,20 +4558,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -4589,6 +4581,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -4659,7 +4664,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5336,6 +5340,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -5582,6 +5587,7 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -5595,6 +5601,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -5932,7 +5939,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -6037,9 +6043,9 @@ "optional": true }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6147,7 +6153,6 @@ "integrity": "sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -6192,6 +6197,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -6205,6 +6211,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -6220,6 +6227,7 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -6233,6 +6241,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -6350,12 +6359,12 @@ "license": "ISC" }, "node_modules/electron-updater": { - "version": "6.8.3", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", - "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.5.1", + "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -6366,9 +6375,9 @@ } }, "node_modules/electron-updater/node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", - "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -6636,7 +6645,6 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6979,7 +6987,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7151,9 +7158,9 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -8002,7 +8009,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -8460,7 +8466,8 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -8521,7 +8528,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8551,9 +8557,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -8703,6 +8709,7 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -8716,6 +8723,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8731,7 +8739,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -8739,6 +8748,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8778,9 +8788,9 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -8827,14 +8837,16 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -8847,7 +8859,8 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -8861,14 +8874,16 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -10609,9 +10624,9 @@ } }, "node_modules/opencode-ai": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.15.10.tgz", - "integrity": "sha512-/JOvvD50/fVW3se3dS6ZAZ3E4P/A8uD6+XmcuRzr2WGzoDvEjAvrYagT7plv5SB+yL+lcN5JIKPdPbyw74ih9g==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.10.tgz", + "integrity": "sha512-/PoGrZnTrSrmHvvXUg12O4yAEjytuqEQNG8usCydrYg4EtosvbtKtGIHI40DFaIhPws91Wa20niSvFG32xL8ZQ==", "cpu": [ "arm64", "x64" @@ -10627,24 +10642,24 @@ "opencode": "bin/opencode.exe" }, "optionalDependencies": { - "opencode-darwin-arm64": "1.15.10", - "opencode-darwin-x64": "1.15.10", - "opencode-darwin-x64-baseline": "1.15.10", - "opencode-linux-arm64": "1.15.10", - "opencode-linux-arm64-musl": "1.15.10", - "opencode-linux-x64": "1.15.10", - "opencode-linux-x64-baseline": "1.15.10", - "opencode-linux-x64-baseline-musl": "1.15.10", - "opencode-linux-x64-musl": "1.15.10", - "opencode-windows-arm64": "1.15.10", - "opencode-windows-x64": "1.15.10", - "opencode-windows-x64-baseline": "1.15.10" + "opencode-darwin-arm64": "1.18.10", + "opencode-darwin-x64": "1.18.10", + "opencode-darwin-x64-baseline": "1.18.10", + "opencode-linux-arm64": "1.18.10", + "opencode-linux-arm64-musl": "1.18.10", + "opencode-linux-x64": "1.18.10", + "opencode-linux-x64-baseline": "1.18.10", + "opencode-linux-x64-baseline-musl": "1.18.10", + "opencode-linux-x64-musl": "1.18.10", + "opencode-windows-arm64": "1.18.10", + "opencode-windows-x64": "1.18.10", + "opencode-windows-x64-baseline": "1.18.10" } }, "node_modules/opencode-darwin-arm64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.15.10.tgz", - "integrity": "sha512-P24xNtL++mK3Saox8R/VeqyUs+fzArw4tl6vlp+U6b9DlKnlchFWcBh5Jnm0XV0sbKXuNPATmHzfAnFWc00IEw==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.10.tgz", + "integrity": "sha512-fqxI5y86eaqLfag44AM77lFdrBesnJw/c+JMD7L/U4tSUNkhtsI044x+gxDlr8VVC0Yn21tCukSSWH0tbYTqmQ==", "cpu": [ "arm64" ], @@ -10654,9 +10669,9 @@ ] }, "node_modules/opencode-darwin-x64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.15.10.tgz", - "integrity": "sha512-GMzziADb7UwVHo28wZqraa2+QFf2gOjCH4cyS1KjE518AwUXAgDIRoTrDhh+k7NYjVvu0tcpJRd9OZaUbKQdgQ==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.10.tgz", + "integrity": "sha512-GVlaup1DWDIEI9St8xyUgaHn1lKAK25L15/WfuOya7HbyPpS4hG6TE+AuzPhRXqtQIplqhCOC5lfvibur9AZqw==", "cpu": [ "x64" ], @@ -10666,9 +10681,9 @@ ] }, "node_modules/opencode-darwin-x64-baseline": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.15.10.tgz", - "integrity": "sha512-1UCnmgqO+fO1HWQSiCxcxDX+4LnA5AvkUYMeyM77jJNweEAwFfF5mwqtELN+oU9KiCrCs6YaX/IglvBqnoRu+Q==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.10.tgz", + "integrity": "sha512-JxnnmPl7PWsh3VA5xcAeAHaVDimg74R8WIXz2Xv6sxHW0eoMn6UYrqxmqmAdVBJBBzOvF55AkGVJbVulC8/mrA==", "cpu": [ "x64" ], @@ -10678,9 +10693,9 @@ ] }, "node_modules/opencode-linux-arm64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.15.10.tgz", - "integrity": "sha512-lprzGrIywjnO1M9vOuiM6MyK5stYcB5ebJYZcOcvept7dSjARtOz/xnWxFVL0Gf4scbaOuYRSZs67DVcWsjWpA==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.10.tgz", + "integrity": "sha512-UR6KlDtikR5kaepEWAXQNDHecLzoWL+7fDb6tYj+FQf/oIAvYRO4azganzfp+1f0djEAplmuQfAlG9k0JD/70A==", "cpu": [ "arm64" ], @@ -10690,21 +10705,24 @@ ] }, "node_modules/opencode-linux-arm64-musl": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.15.10.tgz", - "integrity": "sha512-L1bAgonUxNFdsGbKSLZ1uNlJ2nED+hTgIeGj7gAoYHz4ofuozRgXFJ4Udjz6G1HzoLEUF+NpXY7CJrDotV026Q==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.10.tgz", + "integrity": "sha512-mAk8AQGlYymVgbWRaTjCfv5cSO8slJVVR5r4VPCd8L8II56WnP6z860EP8zC7f41aSudus+cR1ZZeNmRH6rYIA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/opencode-linux-x64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.15.10.tgz", - "integrity": "sha512-QIQwP/3lwHFzital4PEP/4Sq8C5CBKE/gCbseuAQKxK0/vaaRIMsBh3rYwSVFKiCGP9BD2E82fMjXxOfLUkYHQ==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.10.tgz", + "integrity": "sha512-gkmZQfI1h1HJiaAud8zeEIGpqVIoINce9B4bP4+4VNhrrkq2qL73WIc2IOLdO/7dGT1tbSoOjrH14EJPEjkFTg==", "cpu": [ "x64" ], @@ -10714,9 +10732,9 @@ ] }, "node_modules/opencode-linux-x64-baseline": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.15.10.tgz", - "integrity": "sha512-VxFC+8SKX/HyZO9+iAvj0JzFjIWaamyiOTxYvDLIK6S9Ae7BIrilI62tSvq39D0XXvaFjuiRsF5TCOPpO7l5dw==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.10.tgz", + "integrity": "sha512-5AxMI8ABbEgoQuuHeBuw6P/CJsaZK1com5eQj1Giu++zL3uK8l4z2PIpF+c0B10gzRvMye2nPrs8uqKou3BgNQ==", "cpu": [ "x64" ], @@ -10726,33 +10744,39 @@ ] }, "node_modules/opencode-linux-x64-baseline-musl": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.15.10.tgz", - "integrity": "sha512-jBU2dSB9ktNX3C99Fm0hX+QaTbXEhrs0lOQfV2mvJLMn2QfnwKYJ8EPASSSOpuwdLL3FZIXJ67ur0fAwTqASEQ==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.10.tgz", + "integrity": "sha512-ER8brj5ZUDf4/lJ7RQDlwcCUDsov917OaN76MqIfkn29ZWD8lHQu3JTrDq4S5ZhTqufOlUkSAwao82GMa2Azsw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/opencode-linux-x64-musl": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.15.10.tgz", - "integrity": "sha512-SxpXejiKWWH9QImqaZ/bbYiK0jwZcAcGcFOvP0qAOWmv4Bs9JjMIP2UcbGBc9nXduSxwzjo+Pyy0coozWnMQ9g==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.10.tgz", + "integrity": "sha512-A70VYebmhmXafkvcBm7WkAnSnyiAQkeyZu21TNvISM+YRgFIDTETo6rCqknDHBYteTTV2krcYjp/baJjI8AYEw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/opencode-windows-arm64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.15.10.tgz", - "integrity": "sha512-w6k7rrlr/qkULVl7gMlwXzV69NJ9Lel/Y38erFBY6fajyGrSjC+2MN1yu+1XMeUxftrVJgo2Q0D0gjhUdJx4Ew==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.10.tgz", + "integrity": "sha512-7P8rb6aFMhEgI401vQiCFr4LsUuYMeCBOovAhi+W5kNqr4uBEgQzfsGv0weyEOze31hu77/2vyrnGeBrOBjfBg==", "cpu": [ "arm64" ], @@ -10762,9 +10786,9 @@ ] }, "node_modules/opencode-windows-x64": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.15.10.tgz", - "integrity": "sha512-Un8vYVqm+nor1DA78GY7Zhq+LDs2/Zq3oSoG6a5yjBtj267BkSv1hYde3L+YNFy4MbCPmhQxEBSXrUs+oMGASA==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.10.tgz", + "integrity": "sha512-E2f57u8yYCbOZCRCI7vc0XthGDy6+ygijlvfOrF1v9EGrG0d/h/bhqszVOiHHWXUcy4HN4tNR8aGTWaUfUuOaQ==", "cpu": [ "x64" ], @@ -10774,9 +10798,9 @@ ] }, "node_modules/opencode-windows-x64-baseline": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.15.10.tgz", - "integrity": "sha512-y8eETB9YmB41LmshNpOb2iPblg6X0GF46giOU+kTT5ewZpcETfP0l6U53H8iccbO3uGcozPZ+ChYDRjjPDfwQg==", + "version": "1.18.10", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.10.tgz", + "integrity": "sha512-IFDdagAZyogqg+9IgFKOemdO8aSich8a9/4tEqHBgMM68AYLknLU4etOpG07/W5UWm85HPjzXS7wzP6BWMsJ4g==", "cpu": [ "x64" ], @@ -11256,7 +11280,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11484,7 +11507,8 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/process-warning": { "version": "5.0.0", @@ -11654,7 +11678,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -11684,7 +11707,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -11733,7 +11755,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.7.tgz", "integrity": "sha512-jUwKNCEIGiqdvhlS91/2QAg21e4dfU5bH2iwmSDQeosXJgKF7smG0YSplOWK0cjSNgIqXe7VXqo7EIfUFJdt3w==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -11902,7 +11923,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11915,7 +11935,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12004,6 +12023,7 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -12013,7 +12033,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/readdir-glob/node_modules/brace-expansion": { "version": "2.0.3", @@ -12021,6 +12042,7 @@ "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -12031,6 +12053,7 @@ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -13129,7 +13152,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -13363,7 +13385,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13517,7 +13538,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -14069,17 +14089,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -14088,7 +14125,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14402,7 +14438,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14511,7 +14546,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14723,6 +14757,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -14738,6 +14773,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -14759,7 +14795,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9f7599d7..5ab890b3 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@floating-ui/dom": "^1.7.6", "@hostler/sdk": "^0.1.0", "@modelcontextprotocol/sdk": "^1.26.0", - "@opencode-ai/sdk": "^1.15.10", + "@opencode-ai/sdk": "1.18.10", "@tanstack/react-query": "^5.62.0", "@tanstack/react-virtual": "^3.13.21", "@tiptap/extension-image": "^3.19.0", @@ -71,7 +71,7 @@ "googleapis": "^170.1.0", "mime-types": "^3.0.2", "nodemailer": "^9.0.1", - "opencode-ai": "^1.15.10", + "opencode-ai": "1.18.10", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-js": "^1.359.1", diff --git a/src/extensions/mail-ext-web-search/src/web-search-provider.ts b/src/extensions/mail-ext-web-search/src/web-search-provider.ts index 498e2a31..a6045040 100644 --- a/src/extensions/mail-ext-web-search/src/web-search-provider.ts +++ b/src/extensions/mail-ext-web-search/src/web-search-provider.ts @@ -1,4 +1,5 @@ import { createMessage } from "../../../main/services/llm-service"; +import { z } from "zod"; import type { ExtensionContext, EnrichmentProvider, @@ -29,6 +30,14 @@ export interface WebSearchProviderDeps { getParsingModelConfig: () => { provider: LlmProvider; model: string }; } +const SenderProfileResponseSchema = z.object({ + name: z.string(), + summary: z.string(), + title: z.string().optional(), + company: z.string().optional(), + linkedinUrl: z.string().optional(), +}); + // Known reminder/automated service patterns const REMINDER_SERVICE_PATTERNS = [ /reminder/i, @@ -394,7 +403,11 @@ Rules: }, ], }, - { caller: "web-search-sender-lookup-exa", provider: parsingModel.provider }, + { + caller: "web-search-sender-lookup-exa", + provider: parsingModel.provider, + outputSchema: SenderProfileResponseSchema, + }, ); let text = ""; @@ -480,6 +493,13 @@ export function createWebSearchProvider( // no signal. Skip the fallback in that case and log clearly. let useExa = searchConfig.provider === "exa"; if (useExa && !searchConfig.exaApiKey) { + const parsingModel = deps.getParsingModelConfig(); + if (parsingModel.provider !== "anthropic") { + context.logger.error( + "Sender Lookup requires an Exa API key when its parsing model is not Anthropic", + ); + return null; + } if (searchConfig.anthropicConfigured) { context.logger.warn( "senderLookupProvider=exa but exaApiKey is empty; falling back to Anthropic web_search", diff --git a/src/main/agents/agent-coordinator.ts b/src/main/agents/agent-coordinator.ts index 0ce800f6..8788ff8d 100644 --- a/src/main/agents/agent-coordinator.ts +++ b/src/main/agents/agent-coordinator.ts @@ -2,15 +2,22 @@ import { utilityProcess, MessageChannelMain, type BrowserWindow, net } from "ele import path from "path"; import { existsSync } from "fs"; import { fileURLToPath } from "url"; -import type { - AgentContext, - AgentFrameworkConfig, - CoordinatorMessage, - ScopedAgentEvent, - WorkerMessage, +import { + resolveAgentDrafterModelOverrides, + type AgentContext, + type AgentFrameworkConfig, + type AgentModelOverrides, + type CoordinatorMessage, + type ScopedAgentEvent, + type WorkerMessage, } from "./types"; import { getEmailSyncService } from "../ipc/sync.ipc"; -import { getConfig, getFeatureModelConfig, getModelIdForFeature } from "../ipc/settings.ipc"; +import { + getConfig, + getFeatureModelConfig, + getModelIdForFeature, + getOpenCodeModelSelector, +} from "../ipc/settings.ipc"; import { resolveAgentOllamaConfig } from "../../shared/types"; import * as db from "../db"; import { buildStyleContext } from "../services/style-profiler"; @@ -91,7 +98,11 @@ export class AgentCoordinator { }, }, opencode: appConfig.opencode - ? { enabled: appConfig.opencode.enabled, model: appConfig.opencode.model } + ? { + enabled: appConfig.opencode.enabled, + model: appConfig.opencode.model, + featureModels: appConfig.opencode.featureModels, + } : { enabled: false }, hostler: appConfig.hostler ? { @@ -226,9 +237,8 @@ export class AgentCoordinator { cc?: string[], bcc?: string[], ) => generateForwardForEmail({ emailId, accountId, instructions, to, cc, bcc }), - // Logged once per provider.run(): stamps which harness and LLM backend were - // chosen for the session. The only visibility into OpenCode-driven sessions, - // since their actual LLM calls happen inside the spawned opencode server. + // Claude/Hostler stamp their resolved route at start. OpenCode reports one + // terminal record with the usage metadata returned by its child server. recordAgentSessionStart: (args: Parameters[0]) => recordAgentSessionStart(args), } as const; @@ -367,7 +377,7 @@ export class AgentCoordinator { providerIds: string[], prompt: string, context: AgentContext, - modelOverride?: string, + modelOverrides?: AgentModelOverrides, ): Promise { const worker = this.ensureWorker(); @@ -436,9 +446,22 @@ export class AgentCoordinator { port1.start(); // Send the run command with port2 to the worker - worker.postMessage({ type: "run", taskId, providerIds, prompt, context, modelOverride }, [ - port2, - ]); + const resolvedModelOverrides = resolveAgentDrafterModelOverrides( + providerIds, + modelOverrides, + getOpenCodeModelSelector("agentDrafter"), + ); + worker.postMessage( + { + type: "run", + taskId, + providerIds, + prompt, + context, + modelOverrides: resolvedModelOverrides, + }, + [port2], + ); } /** diff --git a/src/main/agents/agent-worker.ts b/src/main/agents/agent-worker.ts index 35135996..8119ea6a 100644 --- a/src/main/agents/agent-worker.ts +++ b/src/main/agents/agent-worker.ts @@ -176,7 +176,7 @@ function handleMainMessage(msg: WorkerMessage): void { return; } orchestrator - .runCommand(msg.taskId, msg.providerIds, msg.prompt, msg.context, msg.modelOverride) + .runCommand(msg.taskId, msg.providerIds, msg.prompt, msg.context, msg.modelOverrides) .then(() => { closeTaskPort(msg.taskId); }) diff --git a/src/main/agents/orchestrator.ts b/src/main/agents/orchestrator.ts index e9ca5af0..e3d0b332 100644 --- a/src/main/agents/orchestrator.ts +++ b/src/main/agents/orchestrator.ts @@ -3,6 +3,7 @@ import type { AgentContext, AgentEvent, AgentFrameworkConfig, + AgentModelOverrides, AgentProvider, AgentProviderConfig, AgentRunResult, @@ -168,7 +169,7 @@ export class AgentOrchestrator { providerIds: string[], prompt: string, context: AgentContext, - modelOverride?: string, + modelOverrides?: AgentModelOverrides, ): Promise { if (providerIds.length === 0) { throw new Error("runCommand requires at least one provider ID"); @@ -239,7 +240,7 @@ export class AgentOrchestrator { netFetch: this.deps.netFetchProxy, recordSessionStart: this.deps.recordAgentSessionStart, signal: abortController.signal, - modelOverride, + modelOverride: modelOverrides?.[id], }); let result: IteratorResult; diff --git a/src/main/agents/providers/claude-agent-provider.ts b/src/main/agents/providers/claude-agent-provider.ts index 7e14c1c2..2cec0a12 100644 --- a/src/main/agents/providers/claude-agent-provider.ts +++ b/src/main/agents/providers/claude-agent-provider.ts @@ -214,9 +214,8 @@ export class ClaudeAgentProvider implements AgentProvider { yield { type: "state", state: "running" }; - // Record one row in llm_calls stamping which harness + LLM backend + - // model this session uses. Mirrors OpenCodeAgentProvider; gives us a - // consistent session-start log across harnesses for cost / usage analysis. + // Claude records its resolved route at session start with zero usage. + // OpenCode records terminal accounting separately. { const sessionModel = modelOverride ?? this.frameworkConfig.model; const ollamaCloudEnabled = !!this.frameworkConfig.ollamaCloud?.enabled; diff --git a/src/main/agents/providers/opencode/opencode-agent-provider.ts b/src/main/agents/providers/opencode/opencode-agent-provider.ts index db463bc8..236faf93 100644 --- a/src/main/agents/providers/opencode/opencode-agent-provider.ts +++ b/src/main/agents/providers/opencode/opencode-agent-provider.ts @@ -1,10 +1,10 @@ import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { dirname, join, delimiter as pathDelimiter } from "node:path"; -import type { Config, Event } from "@opencode-ai/sdk"; -// Type-only namespace import lets us reference the SDK's function types -// (typeof OcSdk.createOpencodeServer) without emitting a runtime `require()`, +import { dirname, join, posix, win32 } from "node:path"; +import type { AssistantMessage, Config, Event } from "@opencode-ai/sdk"; +// Type-only namespace import lets us reference the SDK's client function type +// without emitting a runtime `require()`, // which is required because @opencode-ai/sdk is ESM-only and the worker // bundle is CJS. See loadOpencodeSdk() for the runtime side. import type * as OcSdk from "@opencode-ai/sdk"; @@ -15,20 +15,141 @@ import type { AgentRunResult, AgentEvent, AgentFrameworkConfig, + AgentSessionStartFn, AgentToolSpec, AgentContext, } from "../../types"; import { McpBridge } from "./mcp-bridge"; import { createEventMapper } from "./event-mapper"; import { createLogger } from "../../../services/logger"; +import { launchOpenCodeServer } from "../../../services/opencode-server"; +import { + parseOpenCodeModelSelector, + resolveOpenCodeRoute, + type OpenCodeModelOption, + type OpenCodeRoute, +} from "../../../../shared/types"; -type CreateOpencodeServerFn = typeof OcSdk.createOpencodeServer; type CreateOpencodeClientFn = typeof OcSdk.createOpencodeClient; const log = createLogger("opencode-agent"); type OpencodeClient = ReturnType; +export function buildOpenCodeAgentConfig(bridgeUrl: string): Config { + return { + logLevel: "WARN", + mcp: { + "mail-app-tools": { type: "remote", url: bridgeUrl, enabled: true }, + }, + permission: { edit: "allow", bash: "allow", webfetch: "allow" }, + }; +} + +export function createOpenCodeRunUsageTracker({ + sessionId, + requestedModel = "opencode-default", + accountId, + emailId, + recordSessionStart, +}: { + sessionId?: string; + requestedModel?: string; + accountId?: string; + emailId?: string; + recordSessionStart: AgentSessionStartFn; +}): { + setSessionId: (sessionId: string) => void; + observe: (event: Event) => void; + record: (args: { + route?: OpenCodeRoute; + durationMs: number; + success: boolean; + errorMessage?: string; + }) => void; +} { + const messages = new Map(); + let recorded = false; + let activeSessionId = sessionId; + + return { + setSessionId: (nextSessionId) => { + activeSessionId = nextSessionId; + }, + observe: (event) => { + if (event.type !== "message.updated") return; + const info = event.properties.info; + if (info.role === "assistant" && info.sessionID === activeSessionId) { + messages.set(info.id, info); + } + }, + record: ({ route, durationMs, success, errorMessage }) => { + if (recorded) return; + recorded = true; + + const snapshots = [...messages.values()]; + const last = snapshots.reduce( + (latest, message) => + !latest || message.time.created >= latest.time.created ? message : latest, + undefined, + ); + const actualRoute = last ? { providerID: last.providerID, modelID: last.modelID } : route; + + const hasUsage = snapshots.length > 0; + recordSessionStart({ + harness: "opencode", + provider: "opencode", + model: actualRoute ? `${actualRoute.providerID}/${actualRoute.modelID}` : requestedModel, + accountId, + emailId, + ...(hasUsage + ? { + inputTokens: snapshots.reduce((total, message) => total + message.tokens.input, 0), + outputTokens: snapshots.reduce((total, message) => total + message.tokens.output, 0), + cacheReadTokens: snapshots.reduce( + (total, message) => total + message.tokens.cache.read, + 0, + ), + cacheCreateTokens: snapshots.reduce( + (total, message) => total + message.tokens.cache.write, + 0, + ), + costDollars: snapshots.reduce((total, message) => total + message.cost, 0), + } + : {}), + durationMs, + success, + errorMessage, + }); + }, + }; +} + +export function requestedOpenCodeModelLabel( + config: AgentFrameworkConfig, + runtimeOverride: string | undefined, +): string { + const selector = runtimeOverride?.trim() || config.opencode?.model?.trim() || ""; + const exact = parseOpenCodeModelSelector(selector); + return exact ? `${exact.providerID}/${exact.modelID}` : "opencode-default"; +} + +async function listConnectedModels(client: OpencodeClient): Promise { + const response = await client.provider.list(); + if (!response.data) throw new Error("OpenCode provider catalog could not be loaded"); + const connected = new Set(response.data.connected); + return response.data.all + .filter((provider) => connected.has(provider.id)) + .flatMap((provider) => + Object.values(provider.models).map((model) => ({ + providerId: provider.id, + providerName: provider.name, + modelId: model.id, + modelName: model.name, + })), + ); +} + /** * Dynamic-import the OpenCode SDK. Required because: * - The worker bundle is CJS (vite.worker.config.ts → format: "cjs") @@ -42,7 +163,6 @@ type OpencodeClient = ReturnType; * `import()` into `Promise.resolve(require(...))` during the CJS build. */ type OpencodeSdk = { - createOpencodeServer: CreateOpencodeServerFn; createOpencodeClient: CreateOpencodeClientFn; }; const importDynamic = new Function("s", "return import(s)") as ( @@ -51,14 +171,10 @@ const importDynamic = new Function("s", "return import(s)") as ( let sdkCache: OpencodeSdk | null = null; async function loadOpencodeSdk(): Promise { if (sdkCache) return sdkCache; - const root = (await importDynamic("@opencode-ai/sdk")) as { - createOpencodeServer: CreateOpencodeServerFn; - }; const clientMod = (await importDynamic("@opencode-ai/sdk/client")) as { createOpencodeClient: CreateOpencodeClientFn; }; sdkCache = { - createOpencodeServer: root.createOpencodeServer, createOpencodeClient: clientMod.createOpencodeClient, }; return sdkCache; @@ -110,7 +226,7 @@ export class OpenCodeAgentProvider implements AgentProvider { id: "opencode", name: "OpenCode", description: "Multi-provider open-source agent harness", - auth: { type: "api_key", configKey: "ANTHROPIC_API_KEY" }, + auth: { type: "none" }, }; private frameworkConfig: AgentFrameworkConfig; @@ -145,36 +261,34 @@ export class OpenCodeAgentProvider implements AgentProvider { modelOverride, recordSessionStart, } = params; + const runStartedAt = Date.now(); + let route: OpenCodeRoute | undefined; + let sessionId: string | undefined; + const usage = createOpenCodeRunUsageTracker({ + requestedModel: requestedOpenCodeModelLabel(this.frameworkConfig, modelOverride), + accountId: context.accountId, + emailId: context.currentEmailId, + recordSessionStart, + }); + const finish = (state: AgentRunResult["state"], errorMessage?: string): AgentRunResult => { + usage.record({ + route, + durationMs: Date.now() - runStartedAt, + success: state === "completed", + errorMessage, + }); + return { + state, + ...(sessionId ? { providerTaskId: sessionId } : {}), + }; + }; yield { type: "state", state: "running" }; if (!this.frameworkConfig.opencode?.enabled) { - yield { type: "error", message: "OpenCode provider is not enabled in Settings" }; - return { state: "failed" }; - } - - // Resolve the model honoring (in priority order): - // 1. per-task `modelOverride` from AgentRunParams (sub-agent + orchestrator overrides) - // 2. `opencode.model` from Settings ("Model override" textbox) - // 3. framework default (Ollama Cloud / Anthropic config) - // Caveat: runtime overrides only work for models registered in the - // OpenCode server's provider config, which is fixed at server-start - // time from settings. A runtime override that selects a model NOT in - // the server's registry surfaces OpenCode's reject error to the user. - const route = this.resolveRoute(modelOverride); - - // Record one row in llm_calls stamping which harness + LLM backend + - // model this session uses. The OpenCode server's own LLM calls bypass - // our AnthropicService wrapper, so without this we'd have no record - // that the user ran an OpenCode-harness session at all. - if (route) { - recordSessionStart({ - harness: "opencode", - provider: route.providerID === "ollama-cloud" ? "ollama-cloud" : "anthropic", - model: route.modelID, - accountId: context.accountId, - emailId: context.currentEmailId, - }); + const message = "OpenCode provider is not enabled in Settings"; + yield { type: "error", message }; + return finish("failed", message); } // Hook our executor into the MCP bridge. Single-flight: this overwrites any @@ -186,19 +300,27 @@ export class OpenCodeAgentProvider implements AgentProvider { try { handle = await this.ensureServer(tools); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - yield { type: "error", message: `Failed to start OpenCode server: ${message}` }; - return { state: "failed" }; + const detail = err instanceof Error ? err.message : String(err); + const message = `Failed to start OpenCode server: ${detail}`; + yield { type: "error", message }; + return finish("failed", message); } const { client } = handle; + try { + route = resolveRoute(this.frameworkConfig, modelOverride, await listConnectedModels(client)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + yield { type: "error", message }; + return finish("failed", message); + } + // Reuse the prior OpenCode session if this is a follow-up — that's how the // model retains conversation context across turns. AgentPanel's follow-up // path stashes the previous session ID in context.providerConversationIds. // Falls back to creating a fresh session if no prior ID is provided OR if // the prior session no longer exists on the server (e.g. server restarted // between turns and lost in-memory state). - let sessionId: string; const priorSessionId = context.providerConversationIds?.opencode; if (priorSessionId) { const exists = await client.session @@ -225,11 +347,14 @@ export class OpenCodeAgentProvider implements AgentProvider { sessionId = id; } catch (err) { const message = err instanceof Error ? err.message : String(err); - yield { type: "error", message: `Failed to create OpenCode session: ${message}` }; - return { state: "failed" }; + const errorMessage = `Failed to create OpenCode session: ${message}`; + yield { type: "error", message: errorMessage }; + return finish("failed", errorMessage); } } + usage.setSessionId(sessionId); + const abortController = new AbortController(); // Pre-aborted signal short-circuit: addEventListener after the event has // already fired is a no-op, so without this guard a run started with an @@ -240,7 +365,7 @@ export class OpenCodeAgentProvider implements AgentProvider { // the listener is wired. So there's nothing to delete here. if (signal.aborted) { yield { type: "state", state: "cancelled" }; - return { state: "cancelled", providerTaskId: sessionId }; + return finish("cancelled"); } const onParentAbort = () => abortController.abort(); signal.addEventListener("abort", onParentAbort, { once: true }); @@ -276,9 +401,10 @@ export class OpenCodeAgentProvider implements AgentProvider { }; } catch (err) { cleanup(); - const message = err instanceof Error ? err.message : String(err); - yield { type: "error", message: `Failed to open OpenCode event stream: ${message}` }; - return { state: "failed" }; + const detail = err instanceof Error ? err.message : String(err); + const message = `Failed to open OpenCode event stream: ${detail}`; + yield { type: "error", message }; + return finish("failed", message); } // Declared here (outside the try) so the catch can also distinguish @@ -292,7 +418,7 @@ export class OpenCodeAgentProvider implements AgentProvider { const promptPromise = client.session.promptAsync({ path: { id: sessionId }, body: { - model: route, + ...(route ? { model: route } : {}), system: buildSystemPrompt(context), tools: buildDisabledBuiltins(), parts: [{ type: "text", text: prompt }], @@ -315,11 +441,13 @@ export class OpenCodeAgentProvider implements AgentProvider { // Consume events until terminal or aborted. let finalSummary = "Completed"; + let reachedIdle = false; while (true) { if (abortController.signal.aborted) break; const step = await streamIter.next(); if (step.done) break; const ev = step.value; + usage.observe(ev); for (const mapped of mapper.next(ev)) { yield mapped; @@ -334,9 +462,10 @@ export class OpenCodeAgentProvider implements AgentProvider { // value alone (matches the Claude provider's pattern). cleanup(); streamClose?.(); - return { state: "failed", providerTaskId: sessionId }; + return finish("failed", mapper.lastError() ?? undefined); } // session.idle — success path + reachedIdle = true; break; } } @@ -349,10 +478,17 @@ export class OpenCodeAgentProvider implements AgentProvider { // the controller, but only the former should surface as `error`. if (promptError) { yield { type: "error", message: `OpenCode prompt failed: ${promptError}` }; - return { state: "failed", providerTaskId: sessionId }; + return finish("failed", promptError); } yield { type: "state", state: "cancelled" }; - return { state: "cancelled", providerTaskId: sessionId }; + return finish("cancelled"); + } + + if (!reachedIdle) { + const message = "OpenCode event stream ended before session completion"; + yield { type: "error", message }; + cleanup(); + return finish("failed", message); } // Fetch the final assistant message text to populate the `done` summary. @@ -381,7 +517,7 @@ export class OpenCodeAgentProvider implements AgentProvider { yield { type: "done", summary: finalSummary }; cleanup(); - return { state: "completed", providerTaskId: sessionId }; + return finish("completed"); } catch (err) { cleanup(); streamClose?.(); @@ -390,14 +526,14 @@ export class OpenCodeAgentProvider implements AgentProvider { // promptAsync failures as errors, never as cancellations. if (promptError) { yield { type: "error", message: `OpenCode prompt failed: ${promptError}` }; - return { state: "failed", providerTaskId: sessionId }; + return finish("failed", promptError); } yield { type: "state", state: "cancelled" }; - return { state: "cancelled", providerTaskId: sessionId }; + return finish("cancelled"); } const message = err instanceof Error ? err.message : String(err); yield { type: "error", message }; - return { state: "failed", providerTaskId: sessionId }; + return finish("failed", message); } } @@ -420,35 +556,18 @@ export class OpenCodeAgentProvider implements AgentProvider { } async isAvailable(): Promise { - if (!this.frameworkConfig.opencode?.enabled) return false; - // Binary discovery: the opencode-ai npm package puts the executable at - // node_modules/.bin/opencode in dev; in a packaged app the same package - // lives inside app.asar.unpacked. We don't actually run it here — just - // check it can be found — to keep isAvailable() cheap. - if (resolveOpencodeBinary() === null) return false; - // Credential gate: also require at least one LLM provider to be - // configured. Without this, a user who enabled OpenCode but hasn't set - // up an Anthropic key or Ollama Cloud would see the provider in the - // picker, submit a run, and get an opaque "no model available" error - // from the spawned OpenCode binary. Matching resolveRoute's notion of - // "active provider": Ollama (enabled + apiKey) OR Anthropic key. - const ollama = this.frameworkConfig.ollamaCloud; - const hasOllama = !!(ollama?.enabled && ollama.apiKey); - const hasAnthropic = !!this.frameworkConfig.anthropicApiKey; - return hasOllama || hasAnthropic; + return !!this.frameworkConfig.opencode?.enabled && resolveOpencodeBinary() !== null; } updateConfig(config: Partial): void { this.frameworkConfig = { ...this.frameworkConfig, ...config }; // Only restart the server if a field that actually feeds into - // buildOpencodeConfig() / resolveRoute() changed. The orchestrator + // the OpenCode server changed. The orchestrator // broadcasts updateConfig() to every provider on ANY config change // (browser config, mcpServers, cliTools, openclaw settings, etc.), and // killing the server on unrelated changes wastes startup work and // crashes any in-flight OpenCode runs. - const opencodeRelevant = ["opencode", "ollamaCloud", "anthropicApiKey"] as const; - const touched = opencodeRelevant.some((k) => k in config); - if (!touched) return; + if (!("opencode" in config)) return; // Bump first so any in-flight ensureServer() IIFE sees the new generation // before it would otherwise commit its stale handle. Dropping the promise @@ -495,23 +614,14 @@ export class OpenCodeAgentProvider implements AgentProvider { this.serverStartPromise = (async () => { const bridgeUrl = await this.bridge.start(tools); - const ocConfig = this.buildOpencodeConfig(bridgeUrl); + const ocConfig = buildOpenCodeAgentConfig(bridgeUrl); - // Prepend node_modules/.bin to PATH so the SDK's `launch("opencode", …)` - // finds the local install without requiring a global install. In a - // packaged Electron app, the path resolution is more involved (asar.unpacked); - // we cover dev today and TODO packaging. const binPath = resolveOpencodeBinary(); - if (binPath) { - const binDir = dirname(binPath); - const currentPath = process.env.PATH ?? ""; - if (!currentPath.split(pathDelimiter).includes(binDir)) { - process.env.PATH = `${binDir}${pathDelimiter}${currentPath}`; - } - } + if (!binPath) throw new Error("Bundled OpenCode executable was not found"); const sdk = await loadOpencodeSdk(); - const server = await sdk.createOpencodeServer({ + const server = await launchOpenCodeServer({ + binaryPath: binPath, hostname: "127.0.0.1", port: 0, timeout: 30_000, @@ -548,201 +658,86 @@ export class OpenCodeAgentProvider implements AgentProvider { return this.serverStartPromise; } - - /** - * Translate AgentFrameworkConfig → OpenCode Config. - * - * Routing precedence mirrors the Claude provider in claude-agent-provider.ts: - * 1. Ollama Cloud (if `ollamaCloud.enabled && apiKey`) — register a custom - * "ollama-cloud" provider via the OpenAI-compatible adapter. - * 2. Anthropic (if `anthropicApiKey` is set). - * 3. Neither configured — the server starts but run() will fail when it - * can't resolve a model. isAvailable() guards this for the UI. - */ - private buildOpencodeConfig(bridgeUrl: string): Config { - const cfg: Config = { - logLevel: "WARN", - mcp: { - "mail-app-tools": { - type: "remote", - url: bridgeUrl, - enabled: true, - }, - }, - // Permission handling: allow tools through by default; the orchestrator's - // PermissionGate (around toolExecutor) is the real gate. OpenCode's own - // permission system would double-prompt the user. - permission: { edit: "allow", bash: "allow", webfetch: "allow" }, - // Disable any provider OpenCode would auto-load that we don't have keys - // for, to keep startup quiet. We also disable the *inactive* of the two - // providers we support (anthropic / ollama-cloud) — whichever isn't - // configured for this session — so OpenCode doesn't emit credential - // warnings for it. The list is built dynamically below. - disabled_providers: this.computeDisabledProviders(), - }; - - const ollama = this.frameworkConfig.ollamaCloud; - if (ollama?.enabled && ollama.apiKey) { - // Register the base Ollama default plus any settings-level override - // model so OpenCode accepts session.prompt requests that ask for it. - // Per-run modelOverride that picks a model NOT in this list will surface - // OpenCode's "model not found" error — that's the documented v1 limit. - const settingsOverride = parseModelSelector(this.frameworkConfig.opencode?.model); - const registeredModels: Record = { - [ollama.model]: { id: ollama.model, name: ollama.model, tool_call: true }, - }; - if (settingsOverride && settingsOverride.providerID === "ollama-cloud") { - registeredModels[settingsOverride.modelID] = { - id: settingsOverride.modelID, - name: settingsOverride.modelID, - tool_call: true, - }; - } - cfg.provider = { - "ollama-cloud": { - name: "Ollama Cloud", - npm: "@ai-sdk/openai-compatible", - options: { - baseURL: "https://ollama.com/v1", - apiKey: ollama.apiKey, - }, - models: registeredModels, - }, - }; - } else if (this.frameworkConfig.anthropicApiKey) { - // OpenCode's bundled Anthropic provider catalog handles known Claude - // model IDs — we just need to supply the credential. Settings-level - // model override is honored by resolveRoute() per-call. - cfg.provider = { - anthropic: { - options: { - apiKey: this.frameworkConfig.anthropicApiKey, - }, - }, - }; - } - - return cfg; - } - - /** - * Build the `disabled_providers` list for OpenCode's startup. Includes - * everything we never use plus the inactive of the two we do — whichever - * isn't configured for this session — so OpenCode doesn't emit credential - * warnings for the unused one. - */ - private computeDisabledProviders(): string[] { - const base = ["github-copilot", "openrouter", "google", "groq", "deepseek"]; - const ollama = this.frameworkConfig.ollamaCloud; - const ollamaActive = !!(ollama?.enabled && ollama.apiKey); - const anthropicActive = !!this.frameworkConfig.anthropicApiKey; - if (ollamaActive && !anthropicActive) base.push("anthropic"); - if (anthropicActive && !ollamaActive) base.push("ollama"); - return base; - } - - /** - * Resolve the route for a single run, honoring (in priority order): - * 1. per-task `modelOverride` from AgentRunParams - * 2. `opencode.model` from Settings ("Model override" textbox) - * 3. framework default (Ollama Cloud / Anthropic config) - * - * Accepts either a bare model ID (paired with the active provider) or - * the explicit `provider/model` form (e.g. `ollama-cloud/qwen3:32b`). - * - * Returns the `{providerID, modelID}` shape required by SessionPromptData.body.model. - */ - private resolveRoute( - runtimeOverride: string | undefined, - ): { providerID: string; modelID: string } | undefined { - return resolveRoute(this.frameworkConfig, runtimeOverride); - } } -/** - * Pure-function version of OpenCodeAgentProvider.resolveRoute so the priority - * logic (runtime > settings > framework default, with bare-name and parsed - * `provider/model` forms supported at each tier) can be unit tested without - * spinning up an instance. Exported for tests only. - * - * The previous implementation collapsed both inputs into a single ?? chain on - * the parsed form first, which broke priority when runtime was bare and - * settings was parsed — settings would win even though runtime should have. - * This version resolves each source end-to-end (parsed OR bare → route) - * before falling to the next. - */ export function resolveRoute( config: AgentFrameworkConfig, runtimeOverride: string | undefined, -): { providerID: string; modelID: string } | undefined { - const ollama = config.ollamaCloud; - const activeProvider: "ollama-cloud" | "anthropic" | null = - ollama?.enabled && ollama.apiKey ? "ollama-cloud" : config.anthropicApiKey ? "anthropic" : null; - - const resolveSelector = ( - s: string | undefined, - ): { providerID: string; modelID: string } | undefined => { - const trimmed = s?.trim(); - if (!trimmed) return undefined; - const parsed = parseModelSelector(trimmed); - if (parsed) return parsed; - // Bare-name selector (no slash) — pair with the active provider. - if (activeProvider) return { providerID: activeProvider, modelID: trimmed }; - return undefined; - }; - - const runtimeRoute = resolveSelector(runtimeOverride); - if (runtimeRoute) return runtimeRoute; - - const settingsRoute = resolveSelector(config.opencode?.model); - if (settingsRoute) return settingsRoute; - - // Fall through to framework default. - if (activeProvider === "ollama-cloud" && ollama) { - return { providerID: "ollama-cloud", modelID: ollama.model }; - } - if (activeProvider === "anthropic") { - return { - providerID: "anthropic", - modelID: config.model || "claude-sonnet-4-6", - }; - } - return undefined; + connectedModels: OpenCodeModelOption[], +): OpenCodeRoute | undefined { + const selector = runtimeOverride?.trim() || config.opencode?.model || ""; + return resolveOpenCodeRoute(selector, connectedModels); } +type ResolveOpencodePlatformBinaryOptions = { + platform?: NodeJS.Platform; + arch?: string; + resourcesPath?: string; + resolvePackageJson?: (specifier: string) => string; + fileExists?: (candidate: string) => boolean; +}; + /** - * Parse a "provider/model" selector string (e.g. "ollama-cloud/qwen3:32b"). - * Returns undefined for bare model names (no slash), empty/undefined input, - * or malformed strings — callers handle bare names by pairing with the - * active provider separately. + * Resolve OpenCode directly from its platform-specific optional dependency. + * + * electron-builder preserves platform-package executables under + * app.asar.unpacked, but not the postinstall-created opencode-ai/bin shim. + * Packaged workers resolve from process.resourcesPath; development resolves the + * package normally. x64 distributables use the compatibility-safe baseline. */ -function parseModelSelector( - s: string | undefined, -): { providerID: string; modelID: string } | undefined { - if (!s) return undefined; - const trimmed = s.trim(); - const slash = trimmed.indexOf("/"); - if (slash <= 0 || slash === trimmed.length - 1) return undefined; - return { - providerID: trimmed.slice(0, slash), - modelID: trimmed.slice(slash + 1), - }; +export function resolveOpencodePlatformBinary({ + platform = process.platform, + arch = process.arch, + resourcesPath = process.resourcesPath, + resolvePackageJson = (specifier) => require.resolve(specifier), + fileExists = existsSync, +}: ResolveOpencodePlatformBinaryOptions = {}): string | null { + const normalizedPlatform = platform === "win32" ? "windows" : platform; + const packageName = `opencode-${normalizedPlatform}-${arch}${arch === "x64" ? "-baseline" : ""}`; + const binaryName = platform === "win32" ? "opencode.exe" : "opencode"; + const pathApi = platform === "win32" ? win32 : posix; + + if (resourcesPath) { + const packagedCandidate = pathApi.join( + resourcesPath, + "app.asar.unpacked", + "node_modules", + packageName, + "bin", + binaryName, + ); + if (fileExists(packagedCandidate)) return packagedCandidate; + } + + try { + const packageJsonPath = resolvePackageJson(`${packageName}/package.json`); + const candidate = pathApi + .join(pathApi.dirname(packageJsonPath), "bin", binaryName) + .replace(/([\\/])app\.asar([\\/])/, "$1app.asar.unpacked$2"); + return fileExists(candidate) ? candidate : null; + } catch { + return null; + } } /** * Locate the opencode binary shipped via the opencode-ai npm package. * Memoized — the resolved path doesn't change during the worker's lifetime. * - * In dev: node_modules/.bin/opencode (symlink → optionalDep platform binary). - * In packaged Electron: node_modules/.bin lives inside app.asar; the - * post-install symlink target is in app.asar.unpacked. Same shape as the - * Claude Code resolver in claude-agent-provider.ts. + * In dev: resolve the optional platform dependency or node_modules/.bin. + * In packaged Electron: resolve its executable beneath process.resourcesPath. */ -const resolveOpencodeBinary = (() => { +export const resolveOpencodeBinary = (() => { let cached: string | null | undefined; return (): string | null => { if (cached !== undefined) return cached; + const platformBinary = resolveOpencodePlatformBinary(); + if (platformBinary) { + cached = platformBinary; + return platformBinary; + } + const candidates: string[] = []; // Primary path: resolve `opencode-ai/package.json` directly. The diff --git a/src/main/agents/tools/sub-agent-tool.ts b/src/main/agents/tools/sub-agent-tool.ts index 4097dff1..d538465f 100644 --- a/src/main/agents/tools/sub-agent-tool.ts +++ b/src/main/agents/tools/sub-agent-tool.ts @@ -103,11 +103,9 @@ export function createSubAgentTool( throw new Error("Sub-agent tools do not support nested tool execution"); }, netFetch, - // Intentional no-op for nested invocations: the parent run's own - // session-start row already covers the orchestration session, and - // adding per-subagent rows would muddy session counts in queries - // like `caller='agent-session-start:opencode'`. Swap this for the - // real callback if per-subagent records become useful later. + // Intentional no-op for nested invocations: the parent run owns + // top-level accounting, and per-subagent rows would muddy run counts. + // Swap this for the real callback if nested accounting becomes useful. recordSessionStart: () => {}, signal, }); diff --git a/src/main/agents/types.ts b/src/main/agents/types.ts index 5ac655c6..7b1a2fd7 100644 --- a/src/main/agents/types.ts +++ b/src/main/agents/types.ts @@ -59,10 +59,9 @@ export interface AgentRunParams { toolExecutor: ToolExecutorFn; /** Fetch a URL through the main process's Chromium networking stack (shared session/cookies). */ netFetch: NetFetchProxyFn; - /** Record one row in llm_calls stamping the harness + LLM backend + model - * chosen for this session. Providers should call this exactly once near - * the start of run(), after they've resolved the actual model they will - * use. The call is fire-and-forget; failures are logged, not thrown. */ + /** Record one row in llm_calls for this provider run. Claude/Hostler record + * their resolved route at start; OpenCode records available usage at the + * terminal return. The call is fire-and-forget. */ recordSessionStart: AgentSessionStartFn; signal: AbortSignal; /** Per-task model override. When set, takes precedence over the framework config model. */ @@ -75,8 +74,42 @@ export type AgentSessionStartFn = (args: { model: string; accountId?: string; emailId?: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreateTokens?: number; + costDollars?: number; + durationMs?: number; + success?: boolean; + errorMessage?: string; }) => void; +export type AgentModelOverrides = Record; + +export function resolveAgentDrafterModelOverrides( + providerIds: string[], + explicitOverrides: AgentModelOverrides | undefined, + openCodeSelector: string, +): AgentModelOverrides | undefined { + if (explicitOverrides !== undefined) return explicitOverrides; + return providerIds.length === 1 && providerIds[0] === "opencode" + ? { opencode: openCodeSelector } + : undefined; +} + +export function resolveAgentChatModelOverrides( + providerIds: string[], + openCodeSelector: string, + standardModel: string, +): AgentModelOverrides { + return Object.fromEntries( + providerIds.map((providerId) => [ + providerId, + providerId === "opencode" ? openCodeSelector : standardModel, + ]), + ); +} + export interface AgentResumeParams { taskId: string; providerTaskId: string; @@ -133,7 +166,8 @@ export interface OrchestratorDeps { dbProxy: DbProxyFn; gmailProxy: GmailProxyFn; netFetchProxy: NetFetchProxyFn; - /** Fire-and-forget — record agent-session-start row in llm_calls. */ + /** Fire-and-forget agent accounting: start-time route for Claude/Hostler, + * terminal usage for OpenCode. */ recordAgentSessionStart: AgentSessionStartFn; config: AgentFrameworkConfig; /** Set the active taskId so proxy requests can be scoped for cancellation. */ @@ -176,7 +210,7 @@ export interface AgentFrameworkConfig { providers?: Record; /** OpenCode-specific settings. Kept top-level (not under providers) so the * provider can read it without going through ProviderSettings's narrow shape. */ - opencode?: { enabled: boolean; model?: string }; + opencode?: { enabled: boolean; model?: string; featureModels?: Record }; /** Hostler-specific settings (hosted cloud agent backend). Kept top-level * for the same reason as opencode — harness/model don't fit ProviderSettings. */ hostler?: { @@ -221,7 +255,7 @@ export type WorkerMessage = providerIds: string[]; prompt: string; context: AgentContext; - modelOverride?: string; + modelOverrides?: AgentModelOverrides; } | { type: "cancel"; taskId: string } | { type: "confirm"; toolCallId: string; approved: boolean } diff --git a/src/main/db/migrations.ts b/src/main/db/migrations.ts index c250c956..ae0a271c 100644 --- a/src/main/db/migrations.ts +++ b/src/main/db/migrations.ts @@ -354,6 +354,30 @@ export const NUMBERED_MIGRATIONS: Migration[] = [ } }, }, + { + version: 8, + name: "add_llm_calls_accounting_availability", + up: (db) => { + const cols = db.prepare("PRAGMA table_info(llm_calls)").all() as Array<{ name: string }>; + if (cols.length === 0) return; + if (!cols.some((column) => column.name === "usage_available")) { + db.exec(`ALTER TABLE llm_calls ADD COLUMN usage_available INTEGER NOT NULL DEFAULT 1`); + } + if (!cols.some((column) => column.name === "cost_available")) { + db.exec(`ALTER TABLE llm_calls ADD COLUMN cost_available INTEGER NOT NULL DEFAULT 1`); + } + db.exec(` + UPDATE llm_calls + SET usage_available = 0, cost_available = 0 + WHERE caller LIKE 'agent-session-start:%' + AND input_tokens = 0 + AND output_tokens = 0 + AND COALESCE(cache_read_tokens, 0) = 0 + AND COALESCE(cache_create_tokens, 0) = 0 + AND cost_cents = 0 + `); + }, + }, ]; function runNumberedMigrations(db: DatabaseInstance): void { diff --git a/src/main/index.ts b/src/main/index.ts index a0cff875..1e24a40b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,6 +51,7 @@ import { scheduledSendService } from "./services/scheduled-send-service"; import { snoozeService } from "./services/snooze-service"; import { calendarSyncService } from "./services/calendar-sync"; import { emailSyncService } from "./services/email-sync"; +import { openCodeInferenceService } from "./services/opencode-inference-service"; import * as webSearchExtension from "../extensions/mail-ext-web-search/src/index"; import * as calendarExtension from "../extensions/mail-ext-calendar/src/index"; @@ -638,10 +639,22 @@ const walCheckpointInterval = setInterval(() => { checkpointWal(); }, WAL_CHECKPOINT_INTERVAL_MS); +// Electron's normal quit path emits before-quit, but test runners and service +// managers commonly stop the app with SIGTERM. Handle those signals explicitly +// so the long-lived OpenCode child is stopped before the main process exits. +const quitFromSignal = (): void => { + openCodeInferenceService.close(); + app.quit(); +}; +process.once("SIGTERM", quitFromSignal); +process.once("SIGINT", quitFromSignal); + // Flush WAL and close DB before the process exits to prevent data loss. // Without this, infrequent writes (e.g. memories) can be stranded in the // WAL file and lost if the file is corrupted or removed during an update. app.on("before-quit", () => { + openCodeInferenceService.close(); + // Stop all interval-based services before closing the DB — // otherwise their timers fire after the DB is gone and crash. clearInterval(walCheckpointInterval); diff --git a/src/main/ipc/agent.ipc.ts b/src/main/ipc/agent.ipc.ts index cfd91086..d1a001d7 100644 --- a/src/main/ipc/agent.ipc.ts +++ b/src/main/ipc/agent.ipc.ts @@ -2,11 +2,14 @@ import { ipcMain } from "electron"; import { execFile, execFileSync } from "child_process"; import { agentCoordinator } from "../agents/agent-coordinator"; import { authenticateProvider } from "../agents/private-providers-main"; -import { getConfig, getModelIdForFeature } from "./settings.ipc"; +import { getConfig, getModelIdForFeature, getOpenCodeModelSelector } from "./settings.ipc"; import { resolveAgentOllamaConfig } from "../../shared/types"; import { getAgentTrace } from "../db"; -import type { AgentContext } from "../agents/types"; -import type { ScopedAgentEvent } from "../agents/types"; +import { + resolveAgentChatModelOverrides, + type AgentContext, + type ScopedAgentEvent, +} from "../agents/types"; import type { IpcResponse } from "../../shared/types"; /** Check if `claude` CLI is available on PATH. Cached after first check. */ @@ -53,9 +56,14 @@ export function registerAgentIpc(): void { // If only agentChat is set to ollama-cloud (mismatched config), the // worker is still on Anthropic and would 400 with invalid_model // unless we send an Anthropic name. - const ollamaConfig = resolveAgentOllamaConfig(getConfig()); - const modelOverride = ollamaConfig?.model ?? getModelIdForFeature("agentChat"); - await agentCoordinator.runAgent(taskId, providerIds, prompt, context, modelOverride); + const standardModel = + resolveAgentOllamaConfig(getConfig())?.model ?? getModelIdForFeature("agentChat"); + const modelOverrides = resolveAgentChatModelOverrides( + providerIds, + getOpenCodeModelSelector("agentChat"), + standardModel, + ); + await agentCoordinator.runAgent(taskId, providerIds, prompt, context, modelOverrides); return { success: true, data: { taskId } }; } catch (error) { return { diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index 3f94c9fe..c24c5169 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -8,6 +8,7 @@ import { type ModelConfig, type ModelTier, type LlmProvider, + type OpenCodeModelOption, type SenderLookupProvider, DEFAULT_ANALYSIS_PROMPT, DEFAULT_DRAFT_PROMPT, @@ -19,7 +20,6 @@ import { resolveModelId, resolveAgentOllamaConfig, resolveBackgroundAgentProviderId, - DEFAULT_BACKGROUND_AGENT_PROVIDER, DEFAULT_OLLAMA_MODEL, DEFAULT_HOSTLER_HARNESS, } from "../../shared/types"; @@ -42,6 +42,7 @@ import { } from "../db"; import { getEnrichmentBySender } from "../extensions/enrichment-store"; import { autoUpdateService } from "../services/auto-updater"; +import { openCodeInferenceService } from "../services/opencode-inference-service"; import { existsSync } from "fs"; import { getDataDir } from "../data-dir"; @@ -207,7 +208,12 @@ export function getSenderLookupConfig(): { }; } -/** Resolve provider + model for a feature, supporting Ollama Cloud routing. */ +export function getOpenCodeModelSelector(feature: keyof ModelConfig): string { + const opencode = getConfig().opencode; + return opencode?.featureModels?.[feature] ?? opencode?.model ?? ""; +} + +/** Resolve provider + model for a feature, supporting configured providers. */ export function getFeatureModelConfig(feature: keyof ModelConfig): { provider: LlmProvider; model: string; @@ -221,6 +227,9 @@ export function getFeatureModelConfig(feature: keyof ModelConfig): { DEFAULT_OLLAMA_MODEL; return { provider, model }; } + if (provider === "opencode") { + return { provider, model: getOpenCodeModelSelector(feature) }; + } const mc = getModelConfig(); return { provider: "anthropic", model: resolveModelId(mc[feature]) }; } @@ -228,26 +237,11 @@ export function getFeatureModelConfig(feature: keyof ModelConfig): { /** * 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. + * OpenCode owns its authentication and reports auth/model errors from its + * subprocess, so settings resolution only applies the shared runtime gate. */ 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; + return resolveBackgroundAgentProviderId(getConfig()); } export function registerSettingsIpc(): void { @@ -349,6 +343,24 @@ export function registerSettingsIpc(): void { } }); + ipcMain.handle( + "settings:list-opencode-models", + async (): Promise> => { + try { + const config = getConfig(); + if (!config.opencode?.enabled) { + return { success: false, error: "Enable OpenCode in Settings → Extensions first" }; + } + return { success: true, data: await openCodeInferenceService.listModels() }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Could not load OpenCode models", + }; + } + }, + ); + // Update config ipcMain.handle("settings:set", async (_, config: Partial): Promise> => { try { @@ -376,6 +388,20 @@ export function registerSettingsIpc(): void { }; } } + if ("opencode" in config) { + const incoming = config.opencode; + const existing = currentConfig.opencode; + newConfig = { + ...newConfig, + opencode: incoming + ? { + enabled: incoming.enabled ?? existing?.enabled ?? false, + model: incoming.model ?? existing?.model, + featureModels: incoming.featureModels ?? existing?.featureModels, + } + : undefined, + }; + } // 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 @@ -420,6 +446,10 @@ export function registerSettingsIpc(): void { } getStore().set("config", newConfig); + if ((currentConfig.opencode?.enabled ?? false) !== (newConfig.opencode?.enabled ?? false)) { + openCodeInferenceService.close(); + } + // If githubToken changed, propagate to auto-updater immediately if ("githubToken" in config) { autoUpdateService.setGitHubToken(newConfig.githubToken); @@ -492,11 +522,12 @@ export function registerSettingsIpc(): void { // Propagate OpenCode config to the agent framework. // The provider's updateConfig() will close the existing opencode server // so the next run picks up new model / enable state. - if ("opencode" in config) { + if ("opencode" in config || "featureProviders" in config) { agentCoordinator.updateConfig({ opencode: { enabled: newConfig.opencode?.enabled ?? false, model: newConfig.opencode?.model, + featureModels: newConfig.opencode?.featureModels, }, }); } diff --git a/src/main/services/analysis-edit-learner.ts b/src/main/services/analysis-edit-learner.ts index 1a4268bd..c87f0fb5 100644 --- a/src/main/services/analysis-edit-learner.ts +++ b/src/main/services/analysis-edit-learner.ts @@ -17,6 +17,7 @@ * Analysis memories are injected into the analysis prompt (not the draft prompt). */ import { randomUUID } from "crypto"; +import { z } from "zod"; import { createMessage } from "./llm-service"; import { getFeatureModelConfig } from "../ipc/settings.ipc"; import { @@ -59,6 +60,27 @@ interface AnalysisObservation { emailContext: string | null; } +const AnalysisObservationResponseSchema = z.array( + z.object({ + scope: z.string(), + scopeValue: z.string().nullable(), + content: z.string(), + emailContext: z.string().optional(), + }), +); + +const AnalysisMatchResponseSchema = z.array( + z.object({ + observationIndex: z.number().int().nonnegative(), + matchedDraftMemoryId: z.string().nullable(), + }), +); + +const AnalysisScopeResponseSchema = z.object({ + scope: z.string(), + scopeValue: z.string().nullable(), +}); + export interface AnalysisLearnResult { promoted: Memory[]; draftMemoriesCreated: number; @@ -290,17 +312,14 @@ async function analyzeOverride(override: AnalysisOverride): Promise ({ observationIndex: i, matchedDraftMemoryId: null })); } - const { provider, model: ollamaModel } = getFeatureModelConfig("analysis"); - const isOllama = provider === "ollama-cloud"; + const { provider, model: configuredModel } = getFeatureModelConfig("analysis"); + const model = provider === "anthropic" ? "claude-sonnet-4-5-20250929" : configuredModel; const response = await createMessage( { - model: isOllama ? ollamaModel : "claude-sonnet-4-5-20250929", + model, max_tokens: 1024, messages: [ { @@ -418,7 +438,11 @@ Respond with ONLY a JSON array: [{"observationIndex": 0, "matchedDraftMemoryId": }, ], }, - { caller: "analysis-edit-learner-match", provider }, + { + caller: "analysis-edit-learner-match", + provider, + outputSchema: AnalysisMatchResponseSchema, + }, ); // Find the first text block — Ollama-routed thinking models emit a @@ -458,12 +482,12 @@ async function classifyScope( return { scope: "person", scopeValue: senderEmail.toLowerCase() }; } - const { provider, model: ollamaModel } = getFeatureModelConfig("analysis"); - const isOllama = provider === "ollama-cloud"; + const { provider, model: configuredModel } = getFeatureModelConfig("analysis"); + const model = provider === "anthropic" ? "claude-haiku-4-5-20251001" : configuredModel; const response = await createMessage( { - model: isOllama ? ollamaModel : "claude-haiku-4-5-20251001", + model, max_tokens: 256, messages: [ { @@ -487,7 +511,11 @@ For global: scopeValue = null`, }, ], }, - { caller: "analysis-edit-learner-classify-scope", provider }, + { + caller: "analysis-edit-learner-classify-scope", + provider, + outputSchema: AnalysisScopeResponseSchema, + }, ); // Find the first text block — Ollama-routed thinking models emit a diff --git a/src/main/services/archive-ready-analyzer.ts b/src/main/services/archive-ready-analyzer.ts index a953ee9f..b77ec2fa 100644 --- a/src/main/services/archive-ready-analyzer.ts +++ b/src/main/services/archive-ready-analyzer.ts @@ -54,7 +54,11 @@ export class ArchiveReadyAnalyzer { }, ], }, - { caller: "archive-ready-analyzer", provider: this.provider }, + { + caller: "archive-ready-analyzer", + provider: this.provider, + outputSchema: ArchiveReadyResultSchema, + }, ); // Log cache performance diff --git a/src/main/services/calendaring-agent.ts b/src/main/services/calendaring-agent.ts index 43ae2fec..6ed4489d 100644 --- a/src/main/services/calendaring-agent.ts +++ b/src/main/services/calendaring-agent.ts @@ -1,6 +1,7 @@ import { createMessage } from "./llm-service"; import { stripJsonFences } from "../../shared/strip-json-fences"; import { + CalendaringResultSchema, DEFAULT_CALENDARING_PROMPT, DEFAULT_EA_DEFERRAL_TEMPLATE, type CalendaringResult, @@ -39,7 +40,12 @@ ${wrapUntrustedEmail(`From: ${email.from}\nTo: ${email.to}\nSubject: ${email.sub }, ], }, - { caller: "calendaring-agent", emailId: email.id, provider: this.provider }, + { + caller: "calendaring-agent", + emailId: email.id, + provider: this.provider, + outputSchema: CalendaringResultSchema, + }, ); const textBlock = response.content.find((block) => block.type === "text"); diff --git a/src/main/services/email-analyzer.ts b/src/main/services/email-analyzer.ts index 4fdff21c..41cd804e 100644 --- a/src/main/services/email-analyzer.ts +++ b/src/main/services/email-analyzer.ts @@ -193,7 +193,13 @@ ${userIdentityLine}${wrapUntrustedEmail(`From: ${email.from}\nTo: ${email.to}\nS }, ], }, - { caller: "email-analyzer", emailId: email.id, accountId, provider: this.provider }, + { + caller: "email-analyzer", + emailId: email.id, + accountId, + provider: this.provider, + outputSchema: AnalysisResultSchema, + }, ); // Log cache performance diff --git a/src/main/services/llm-service.ts b/src/main/services/llm-service.ts index a5d1ffa1..ff70feac 100644 --- a/src/main/services/llm-service.ts +++ b/src/main/services/llm-service.ts @@ -16,8 +16,13 @@ import type { MessageCreateParamsNonStreaming, Message, } from "@anthropic-ai/sdk/resources/messages"; +import { z } from "zod"; import type { LlmProvider } from "../../shared/types"; import { createLogger } from "./logger"; +import { + openCodeInferenceService, + type OpenCodeInferenceService, +} from "./opencode-inference-service"; import { randomUUID } from "crypto"; const log = createLogger("llm"); @@ -69,6 +74,9 @@ export interface LlmCallRecord { duration_ms: number; success: number; error_message: string | null; + provider: string; + usage_available: number; + cost_available: number; } export interface UsageStats { @@ -105,6 +113,14 @@ export interface CreateOptions { * Ignored for Anthropic. */ think?: boolean | "low" | "medium" | "high" | "max"; + outputSchema?: z.ZodType; +} + +type OpenCodeServiceLike = Pick; +let openCodeService: OpenCodeServiceLike = openCodeInferenceService; + +export function _setOpenCodeServiceForTesting(service?: OpenCodeServiceLike): void { + openCodeService = service ?? openCodeInferenceService; } // --- Anthropic client (api.anthropic.com) --- @@ -223,7 +239,9 @@ export function setAnthropicServiceDb(db: DatabaseInstance): void { duration_ms INTEGER NOT NULL, success INTEGER NOT NULL DEFAULT 1, error_message TEXT, - provider TEXT DEFAULT 'anthropic' + provider TEXT DEFAULT 'anthropic', + usage_available INTEGER NOT NULL DEFAULT 1, + cost_available INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX IF NOT EXISTS idx_llm_calls_created ON llm_calls(created_at); CREATE INDEX IF NOT EXISTS idx_llm_calls_caller ON llm_calls(caller); @@ -231,8 +249,9 @@ export function setAnthropicServiceDb(db: DatabaseInstance): void { _insertStmt = db.prepare(` INSERT INTO llm_calls (id, model, caller, email_id, account_id, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, - cost_cents, duration_ms, success, error_message, provider) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + cost_cents, duration_ms, success, error_message, provider, + usage_available, cost_available) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); } @@ -266,17 +285,20 @@ function recordCall( success: boolean, errorMessage: string | null, provider?: LlmProvider, + costCentsOverride?: number, + usageAvailable = true, + costAvailable = true, ): void { if (!_insertStmt) { log.warn("LLM service: database not initialized, skipping call recording"); return; } - // Ollama Cloud is subscription-based — no per-token cost const costCents = - provider === "ollama-cloud" + costCentsOverride ?? + (provider === "ollama-cloud" || provider === "opencode" ? 0 - : calculateCostCents(model, inputTokens, outputTokens, cacheReadTokens, cacheCreateTokens); + : calculateCostCents(model, inputTokens, outputTokens, cacheReadTokens, cacheCreateTokens)); try { _insertStmt.run( @@ -294,6 +316,8 @@ function recordCall( success ? 1 : 0, errorMessage, provider ?? "anthropic", + usageAvailable ? 1 : 0, + costAvailable ? 1 : 0, ); } catch (err) { // Recording failure must never break the LLM call @@ -301,46 +325,51 @@ function recordCall( } } -/** - * Record a streaming call's cost after it completes. - * Use this for calls that bypass createMessage() (e.g., anthropic.messages.stream()). - */ -/** - * Record the start of an agent session: a single zero-token, zero-cost row - * stamping which harness was selected and which LLM backend it routes to. - * - * This is the only signal we have for OpenCode-driven sessions — their - * actual LLM calls happen inside the spawned `opencode` server (via its - * bundled AI SDK adapter) and bypass createMessage()'s recording path. - * - * Caller convention: `agent-session-start:` (e.g. "agent-session-start:opencode") - * so a `SELECT ... WHERE caller LIKE 'agent-session-start:%'` returns the - * session log; harness, provider, and model live in dedicated columns so - * filtering by any of them is trivial. - */ +/** Record an agent session through the existing llm_calls ledger. */ export function recordAgentSessionStart(args: { harness: string; provider: LlmProvider; model: string; accountId?: string; emailId?: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreateTokens?: number; + costDollars?: number; + durationMs?: number; + success?: boolean; + errorMessage?: string; }): void { + const usageAvailable = + args.inputTokens !== undefined && + args.outputTokens !== undefined && + args.cacheReadTokens !== undefined && + args.cacheCreateTokens !== undefined; + const costAvailable = args.costDollars !== undefined; recordCall( args.model, - `agent-session-start:${args.harness}`, + `agent-run:${args.harness}`, args.emailId ?? null, args.accountId ?? null, - 0, - 0, - 0, - 0, - 0, - true, - null, + args.inputTokens ?? 0, + args.outputTokens ?? 0, + args.cacheReadTokens ?? 0, + args.cacheCreateTokens ?? 0, + args.durationMs ?? 0, + args.success ?? true, + args.errorMessage ?? null, args.provider, + args.costDollars === undefined ? undefined : args.costDollars * 100, + usageAvailable, + costAvailable, ); } +/** + * Record a streaming call's cost after it completes. + * Use this for calls that bypass createMessage() (e.g., anthropic.messages.stream()). + */ export function recordStreamingCall( model: string, caller: string, @@ -614,6 +643,92 @@ export async function createMessage( const model = params.model; const startTime = Date.now(); + if (provider === "opencode") { + if (params.tools?.length) { + throw new Error("OpenCode feature inference does not support tools"); + } + const controller = timeoutMs ? new AbortController() : undefined; + const timer = timeoutMs ? setTimeout(() => controller?.abort(), timeoutMs) : undefined; + try { + const result = await openCodeService.complete({ + selector: params.model || undefined, + system: flattenSystemPrompt(params.system), + prompt: params.messages + .map( + (message) => + `${message.role.toUpperCase()}:\n${flattenMessageContent(message.content)}`, + ) + .join("\n\n"), + outputSchema: options.outputSchema + ? (z.toJSONSchema(options.outputSchema) as Record) + : undefined, + signal: controller?.signal, + }); + const resolvedModel = `${result.providerId}/${result.modelId}`; + const text = + result.structured === undefined ? result.text : JSON.stringify(result.structured); + const response = { + id: result.id, + type: "message", + role: "assistant", + model: resolvedModel, + content: [{ type: "text", text, citations: null }], + container: null, + stop_details: null, + stop_reason: result.finishReason === "length" ? "max_tokens" : "end_turn", + stop_sequence: null, + usage: { + input_tokens: result.inputTokens, + output_tokens: result.outputTokens, + cache_creation_input_tokens: result.cacheWriteTokens, + cache_read_input_tokens: result.cacheReadTokens, + server_tool_use: null, + service_tier: null, + }, + } as Message; + recordCall( + resolvedModel, + caller, + emailId ?? null, + accountId ?? null, + result.inputTokens, + result.outputTokens, + result.cacheReadTokens, + result.cacheWriteTokens, + Date.now() - startTime, + true, + null, + "opencode", + result.costDollars === undefined ? undefined : result.costDollars * 100, + true, + result.costDollars !== undefined, + ); + return response; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + recordCall( + params.model || "opencode-default", + caller, + emailId ?? null, + accountId ?? null, + 0, + 0, + 0, + 0, + Date.now() - startTime, + false, + errorMessage, + "opencode", + undefined, + false, + false, + ); + throw error; + } finally { + if (timer) clearTimeout(timer); + } + } + // Strip cache_control for Ollama (unsupported) const effectiveParams = isOllama ? adjustParamsForOllama(params) : params; @@ -738,6 +853,9 @@ export async function createMessage( false, errMsg, provider, + undefined, + false, + false, ); throw lastError; diff --git a/src/main/services/opencode-inference-service.ts b/src/main/services/opencode-inference-service.ts new file mode 100644 index 00000000..f26cf929 --- /dev/null +++ b/src/main/services/opencode-inference-service.ts @@ -0,0 +1,227 @@ +import { randomUUID } from "node:crypto"; +import type * as OpenCodeV2Client from "@opencode-ai/sdk/v2/client"; + +import { resolveOpenCodeRoute, type OpenCodeModelOption } from "../../shared/types"; +import { resolveOpencodeBinary } from "../agents/providers/opencode/opencode-agent-provider"; +import { createLogger } from "./logger"; +import { launchOpenCodeServer } from "./opencode-server"; + +const log = createLogger("opencode-inference"); + +const importDynamic = new Function("s", "return import(s)") as ( + specifier: string, +) => Promise; + +export type OpenCodeInferenceRequest = { + selector?: string; + system?: string; + prompt: string; + outputSchema?: Record; + signal?: AbortSignal; +}; + +export type OpenCodeInferenceResult = { + id: string; + text: string; + structured?: unknown; + providerId: string; + modelId: string; + finishReason: string | null; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + costDollars?: number; +}; + +type OpenCodeClient = ReturnType; +type OpenCodeClientLike = { + provider: Pick; + tool: Pick; + session: Pick; +}; + +type OpenCodeHandle = { + client: OpenCodeClientLike; + close: () => void; +}; + +export type OpenCodeLauncher = (signal?: AbortSignal) => Promise; + +async function launchOpenCode(signal?: AbortSignal): Promise { + const binPath = resolveOpencodeBinary(); + if (!binPath) throw new Error("Bundled OpenCode executable was not found"); + + const clientModule = (await importDynamic( + "@opencode-ai/sdk/v2/client", + )) as typeof OpenCodeV2Client; + const server = await launchOpenCodeServer({ + binaryPath: binPath, + hostname: "127.0.0.1", + port: 0, + timeout: 30_000, + config: { logLevel: "WARN" }, + signal, + }); + const client = clientModule.createOpencodeClient({ baseUrl: server.url }); + return { client, close: () => server.close() }; +} + +export class OpenCodeInferenceService { + private handle: OpenCodeHandle | null = null; + private startupPromise: Promise | null = null; + private startupAbortController: AbortController | null = null; + private configGeneration = 0; + + constructor(private readonly launcher: OpenCodeLauncher = launchOpenCode) {} + + async listModels(): Promise { + const { client } = await this.ensureHandle(); + return this.listModelsFrom(client); + } + + async complete(request: OpenCodeInferenceRequest): Promise { + const { client } = await this.ensureHandle(); + const models = await this.listModelsFrom(client); + const route = resolveOpenCodeRoute(request.selector, models); + + const toolResponse = await client.tool.ids(); + const toolIds = toolResponse.data; + if (!toolIds) throw new Error("OpenCode tool catalog could not be loaded"); + const tools = Object.fromEntries(toolIds.map((id) => [id, false])); + + const created = await client.session.create({ + title: `exo-feature:${randomUUID()}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }); + const sessionId = created.data?.id; + if (!sessionId) throw new Error("OpenCode session.create returned no id"); + + let promptError: unknown; + try { + const prompted = await client.session.prompt( + { + sessionID: sessionId, + ...(route ? { model: route } : {}), + system: request.system, + tools, + format: request.outputSchema + ? { type: "json_schema", schema: request.outputSchema } + : { type: "text" }, + parts: [{ type: "text", text: request.prompt }], + }, + { signal: request.signal }, + ); + if (!prompted.data) throw new Error("OpenCode session.prompt returned no response"); + + const { info, parts } = prompted.data; + if (info.error) throw info.error; + return { + id: info.id, + text: parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""), + ...(info.structured === undefined ? {} : { structured: info.structured }), + providerId: info.providerID, + modelId: info.modelID, + finishReason: info.finish ?? null, + inputTokens: info.tokens.input, + outputTokens: info.tokens.output, + cacheReadTokens: info.tokens.cache.read, + cacheWriteTokens: info.tokens.cache.write, + reasoningTokens: info.tokens.reasoning, + costDollars: info.cost, + }; + } catch (error) { + promptError = error; + throw error; + } finally { + try { + const deleted = await client.session.delete({ sessionID: sessionId }); + if (!deleted.data) throw new Error("OpenCode session.delete failed"); + } catch (cleanupError) { + if (!promptError) throw cleanupError; + log.warn( + `OpenCode session cleanup failed after prompt failure: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }`, + ); + } + } + } + + close(): void { + this.configGeneration += 1; + this.startupAbortController?.abort(); + this.startupAbortController = null; + const handle = this.handle; + this.handle = null; + this.startupPromise = null; + handle?.close(); + } + + private async listModelsFrom(client: OpenCodeClientLike): Promise { + const response = await client.provider.list(); + if (!response.data) throw new Error("OpenCode provider catalog could not be loaded"); + + const connected = new Set(response.data.connected); + return response.data.all + .filter((provider) => connected.has(provider.id)) + .flatMap((provider) => + Object.values(provider.models).map((model) => ({ + providerId: provider.id, + providerName: provider.name, + modelId: model.id, + modelName: model.name, + })), + ) + .sort( + (left, right) => + left.providerName.localeCompare(right.providerName) || + left.modelName.localeCompare(right.modelName), + ); + } + + private ensureHandle(): Promise { + if (this.handle) return Promise.resolve(this.handle); + if (this.startupPromise) return this.startupPromise; + + const startGeneration = this.configGeneration; + const startupAbortController = new AbortController(); + this.startupAbortController = startupAbortController; + const startup = this.launcher(startupAbortController.signal) + .then((handle) => { + if (this.startupAbortController === startupAbortController) { + this.startupAbortController = null; + } + if (this.configGeneration !== startGeneration) { + try { + handle.close(); + } catch (error) { + log.warn( + `Closing stale OpenCode server failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + throw new Error("OpenCode server changed during startup"); + } + this.handle = handle; + if (this.startupPromise === startup) this.startupPromise = null; + return handle; + }) + .catch((error) => { + if (this.startupAbortController === startupAbortController) { + this.startupAbortController = null; + } + if (this.startupPromise === startup) this.startupPromise = null; + throw error; + }); + this.startupPromise = startup; + return startup; + } +} + +export const openCodeInferenceService = new OpenCodeInferenceService(); diff --git a/src/main/services/opencode-server.ts b/src/main/services/opencode-server.ts new file mode 100644 index 00000000..a7798429 --- /dev/null +++ b/src/main/services/opencode-server.ts @@ -0,0 +1,144 @@ +import { spawn, spawnSync, type ChildProcess, type SpawnOptions } from "node:child_process"; + +export type OpenCodeServerHandle = { + url: string; + close: () => void; +}; + +type SpawnProcess = (command: string, args: string[], options: SpawnOptions) => ChildProcess; + +export function buildOpenCodeChildEnv( + parentEnv: NodeJS.ProcessEnv, + config: unknown, +): NodeJS.ProcessEnv { + const childEnv = { ...parentEnv }; + delete childEnv.ANTHROPIC_API_KEY; + childEnv.OPENCODE_CONFIG_CONTENT = JSON.stringify(config ?? {}); + return childEnv; +} + +function stopChild(child: ChildProcess): void { + if (child.exitCode !== null || child.signalCode !== null) return; + if (process.platform === "win32" && child.pid) { + const stopped = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + windowsHide: true, + }); + if (!stopped.error && stopped.status === 0) return; + } + // Failed kills emit "error". Startup errors have already been handled + // before shutdown reaches this path, so keep cleanup from becoming fatal. + const onKillError = (): void => {}; + child.once("error", onKillError); + if (child.kill()) child.off("error", onKillError); + else setImmediate(() => child.off("error", onKillError)); +} + +export async function launchOpenCodeServer({ + binaryPath, + config, + hostname = "127.0.0.1", + port = 0, + timeout = 30_000, + signal, + spawnProcess = spawn, +}: { + binaryPath: string; + config?: unknown; + hostname?: string; + port?: number; + timeout?: number; + signal?: AbortSignal; + spawnProcess?: SpawnProcess; +}): Promise { + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + if (typeof config === "object" && config !== null && "logLevel" in config) { + const logLevel = config.logLevel; + if (typeof logLevel === "string" && logLevel) args.push(`--log-level=${logLevel}`); + } + + const child = spawnProcess(binaryPath, args, { + env: buildOpenCodeChildEnv(process.env, config), + windowsHide: true, + }); + + let stdout = ""; + let stderr = ""; + const url = await new Promise((resolve, reject) => { + let settled = false; + + const diagnostics = (): string => { + const parts: string[] = []; + if (stdout.trim()) parts.push(`stdout: ${stdout.trim()}`); + if (stderr.trim()) parts.push(`stderr: ${stderr.trim()}`); + return parts.length > 0 ? `\nServer output:\n${parts.join("\n")}` : ""; + }; + const cleanup = (): void => { + clearTimeout(timer); + child.stdout?.off("data", onStdout); + child.stderr?.off("data", onStderr); + child.off("error", onError); + child.off("exit", onExit); + signal?.removeEventListener("abort", onAbort); + }; + const finish = (action: () => void): void => { + if (settled) return; + settled = true; + cleanup(); + action(); + }; + function onAbort(): void { + finish(() => { + stopChild(child); + reject(new Error("OpenCode server startup aborted")); + }); + } + const onStdout = (chunk: Buffer): void => { + stdout += chunk.toString(); + for (const line of stdout.split("\n")) { + if (!line.startsWith("opencode server listening")) continue; + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + if (match) { + finish(() => resolve(match[1])); + } else { + finish(() => { + stopChild(child); + reject(new Error(`Failed to parse server url from output: ${line}`)); + }); + } + return; + } + }; + const onStderr = (chunk: Buffer): void => { + stderr += chunk.toString(); + }; + const onError = (error: Error): void => { + finish(() => reject(error)); + }; + const onExit = (code: number | null): void => { + finish(() => reject(new Error(`Server exited with code ${code}${diagnostics()}`))); + }; + const timer = setTimeout(() => { + finish(() => { + stopChild(child); + reject(new Error(`Timeout waiting for server to start after ${timeout}ms${diagnostics()}`)); + }); + }, timeout); + + child.stdout?.on("data", onStdout); + child.stderr?.on("data", onStderr); + child.once("error", onError); + child.once("exit", onExit); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); + + let closed = false; + return { + url, + close: () => { + if (closed) return; + closed = true; + stopChild(child); + }, + }; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c106ea64..1c15eeee 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -229,6 +229,7 @@ const api = { get: (): Promise => ipcRenderer.invoke("settings:get"), set: (config: Record): Promise => ipcRenderer.invoke("settings:set", config), + listOpenCodeModels: (): Promise => ipcRenderer.invoke("settings:list-opencode-models"), validateApiKey: (apiKey: string): Promise => ipcRenderer.invoke("settings:validate-api-key", { apiKey }), validateOllamaKey: (apiKey: string): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9fff4a44..db4c22d0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -701,6 +701,7 @@ export default function App() { const addSentEmails = useAppStore((s) => s.addSentEmails); const setSplits = useAppStore((s) => s.setSplits); const setSnippets = useAppStore((s) => s.setSnippets); + const setDefaultAgentIds = useAppStore((s) => s.setDefaultAgentIds); // Initialize keyboard shortcuts useKeyboardShortcuts({ @@ -743,9 +744,16 @@ export default function App() { keyboardBindings?: "superhuman" | "gmail"; posthog?: { enabled: boolean; sessionReplay?: boolean }; lastSelectedAccountId?: string | null; + featureProviders?: Record; + opencode?: { enabled?: boolean }; }; }) => { if (result.success && result.data) { + setDefaultAgentIds([ + result.data.featureProviders?.agentChat === "opencode" && result.data.opencode?.enabled + ? "opencode" + : "claude", + ]); if (result.data.inboxDensity) { setInboxDensity(result.data.inboxDensity); } @@ -794,6 +802,7 @@ export default function App() { setKeyboardBindings, setUndoSendDelay, setSendAndArchive, + setDefaultAgentIds, ]); // Toggle dark class on document.documentElement when resolvedTheme changes diff --git a/src/renderer/components/AgentCommandPalette.tsx b/src/renderer/components/AgentCommandPalette.tsx index da65e27c..9b43d8f4 100644 --- a/src/renderer/components/AgentCommandPalette.tsx +++ b/src/renderer/components/AgentCommandPalette.tsx @@ -161,9 +161,9 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp const { selectedAgentIds, + defaultAgentIds, availableProviders, setSelectedAgentIds, - setAvailableProviders, selectedEmailId, selectedThreadId, selectedDraftId, @@ -212,27 +212,22 @@ export function AgentCommandPalette({ isOpen, onClose }: AgentCommandPaletteProp return allActions.filter((a) => fuzzyMatch(a.label, query)); }, [query, suggestedActions, quickActions]); - // When the palette opens, fetch real provider list from the backend if we don't have one yet. - // Also auto-select "claude" when nothing is selected. + // Every conversation starts from the configured default. Users can still + // override it for the current palette session. useEffect(() => { if (!isOpen) return; + setSelectedAgentIds(defaultAgentIds.length ? defaultAgentIds : ["claude"]); + }, [isOpen, defaultAgentIds, setSelectedAgentIds]); - if (selectedAgentIds.length === 0) { - setSelectedAgentIds(["claude"]); - } - + // Fetch the real provider list from the backend if we don't have one yet. + useEffect(() => { + if (!isOpen) return; if (availableProviders.length === 0) { // Request provider list from backend; the onProviders listener in App.tsx // will update the store when the response arrives. window.api?.agent?.providers?.(); } - }, [ - isOpen, - selectedAgentIds.length, - availableProviders.length, - setSelectedAgentIds, - setAvailableProviders, - ]); + }, [isOpen, availableProviders.length]); // Reset state when opened/closed useEffect(() => { diff --git a/src/renderer/components/OpenCodeModelInput.tsx b/src/renderer/components/OpenCodeModelInput.tsx new file mode 100644 index 00000000..a1550bbe --- /dev/null +++ b/src/renderer/components/OpenCodeModelInput.tsx @@ -0,0 +1,173 @@ +import { useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import type { OpenCodeModelOption } from "../../shared/types"; + +interface OpenCodeModelInputProps { + value: string; + onChange: (value: string) => void; + models: OpenCodeModelOption[]; + loading: boolean; + error?: string; + onRefresh: () => void; + ariaLabel: string; +} + +export function OpenCodeModelInput({ + value, + onChange, + models, + loading, + error, + onRefresh, + ariaLabel, +}: OpenCodeModelInputProps) { + const listId = useId(); + const containerRef = useRef(null); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const available = models.some((model) => `${model.providerId}/${model.modelId}` === value); + const filteredModels = useMemo(() => { + const query = value.trim().toLowerCase(); + if (!query) return models; + return models.filter((model) => + [`${model.providerId}/${model.modelId}`, model.providerName, model.modelName].some( + (candidate) => candidate.toLowerCase().includes(query), + ), + ); + }, [models, value]); + const listOpen = suggestionsOpen && filteredModels.length > 0; + const inputClassName = + "w-48 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-500 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"; + + const selectModel = (model: OpenCodeModelOption) => { + onChange(`${model.providerId}/${model.modelId}`); + setSuggestionsOpen(false); + setActiveIndex(-1); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + if (!listOpen) return; + event.preventDefault(); + event.stopPropagation(); + setSuggestionsOpen(false); + setActiveIndex(-1); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + if (filteredModels.length === 0) return; + event.preventDefault(); + setSuggestionsOpen(true); + setActiveIndex((current) => { + if (event.key === "ArrowDown") { + return (current + 1) % filteredModels.length; + } + return current > 0 && current < filteredModels.length + ? current - 1 + : filteredModels.length - 1; + }); + return; + } + if ( + event.key === "Enter" && + listOpen && + activeIndex >= 0 && + activeIndex < filteredModels.length + ) { + event.preventDefault(); + selectModel(filteredModels[activeIndex]); + } + }; + + return ( +
{ + if (!containerRef.current?.contains(event.relatedTarget)) { + setSuggestionsOpen(false); + setActiveIndex(-1); + } + }} + > +
+
+ { + onChange(event.target.value); + setSuggestionsOpen(true); + setActiveIndex(-1); + }} + onFocus={() => setSuggestionsOpen(true)} + onClick={() => setSuggestionsOpen(true)} + onKeyDown={handleKeyDown} + placeholder="OpenCode default" + aria-label={ariaLabel} + role="combobox" + aria-autocomplete="list" + aria-expanded={listOpen} + aria-controls={listId} + aria-activedescendant={ + listOpen && activeIndex >= 0 && activeIndex < filteredModels.length + ? `${listId}-option-${activeIndex}` + : undefined + } + className={inputClassName} + /> + {listOpen && ( +
+ {filteredModels.map((model, index) => { + const selector = `${model.providerId}/${model.modelId}`; + return ( + + ); + })} +
+ )} +
+ +
+ {error && ( +

+ {error} +

+ )} + {value && !loading && !error && !available && ( +

+ Saved model is unavailable in OpenCode. +

+ )} +
+ ); +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index a9867b74..e43bda19 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -28,6 +28,8 @@ import { applyAgentDrafterSelection, isAgentRuntimeAvailable, type BlockedSender, + type IpcResponse, + type OpenCodeModelOption, } from "../../shared/types"; import { useAppStore, type Account, type SettingsTab } from "../store"; import { reconfigurePostHog, trackEvent } from "../services/posthog"; @@ -36,6 +38,7 @@ import { SnippetsEditor } from "./SnippetsEditor"; import { MemoriesTab } from "./MemoriesTab"; import { ExtensionsTab } from "./ExtensionsTab"; import { OllamaModelSelect } from "./OllamaModelSelect"; +import { OpenCodeModelInput } from "./OpenCodeModelInput"; interface SettingsPanelProps { onClose: () => void; @@ -65,6 +68,7 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { setSendAndArchive, currentAccountId, highlightMemoryIds, + setDefaultAgentIds, } = useAppStore(); const [isAddingAccount, setIsAddingAccount] = useState(false); const [addAccountPhase, setAddAccountPhase] = useState("Connecting..."); @@ -105,6 +109,10 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { const [modelConfig, setModelConfig] = useState(DEFAULT_MODEL_CONFIG); const [featureProviders, setFeatureProviders] = useState>({}); const [ollamaModels, setOllamaModels] = useState>({}); + const [openCodeModels, setOpenCodeModels] = useState>({}); + const featureProvidersDirty = useRef(false); + const openCodeModelsDirty = useRef(false); + const backgroundAgentProviderDirty = useRef(false); const [isSavingGeneral, setIsSavingGeneral] = useState(false); // "saved" for transient success feedback, any other string is an error message const [generalSaveResult, setGeneralSaveResult] = useState(null); @@ -231,6 +239,18 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { refetchOnMount: "always", }); + const openCodeCatalog = useQuery({ + queryKey: ["opencode-models"], + enabled: generalConfig?.opencode?.enabled === true, + queryFn: async () => { + const result = (await window.api.settings.listOpenCodeModels()) as IpcResponse< + OpenCodeModelOption[] + >; + if (!result.success) throw new Error(result.error); + return result.data; + }, + }); + // 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 under the Agent Drafter row can't drift from real behavior. @@ -289,11 +309,16 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { setExaApiKey(generalConfig.exaApiKey ?? ""); setSyncDraftsToGmail(generalConfig.syncDraftsToGmail ?? false); setModelConfig({ ...DEFAULT_MODEL_CONFIG, ...generalConfig.modelConfig }); - setFeatureProviders(generalConfig.featureProviders ?? {}); + if (!featureProvidersDirty.current) { + setFeatureProviders(generalConfig.featureProviders ?? {}); + } const ollamaFeatureModels = generalConfig.ollamaCloud?.featureModels; if (ollamaFeatureModels) { setOllamaModels(ollamaFeatureModels); } + if (!openCodeModelsDirty.current) { + setOpenCodeModels(generalConfig.opencode?.featureModels ?? {}); + } setGithubToken(generalConfig.githubToken ?? ""); setAllowPrereleaseUpdates(generalConfig.allowPrereleaseUpdates ?? false); setAnthropicApiKey(generalConfig.anthropicApiKey ?? ""); @@ -306,9 +331,11 @@ 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, - ); + if (!backgroundAgentProviderDirty.current) { + setBackgroundAgentProvider( + generalConfig.backgroundAgentProvider || DEFAULT_BACKGROUND_AGENT_PROVIDER, + ); + } const ph = generalConfig.posthog; if (ph) { setPosthogEnabled(ph.enabled); @@ -469,11 +496,17 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { // overwrite the freshly-saved key. By omitting apiKey/defaultModel, the // deep-merge falls through to the existing values for those fields. ollamaCloud: { featureModels: ollamaModels }, + opencode: { featureModels: openCodeModels }, githubToken: githubToken || undefined, allowPrereleaseUpdates, })) as { success: boolean; error?: string } | undefined; if (result?.success) { setGeneralSaveResult("saved"); + setDefaultAgentIds([ + featureProviders.agentChat === "opencode" && generalConfig?.opencode?.enabled + ? "opencode" + : "claude", + ]); // Functional clear so a later save's error can't be wiped by this timer setTimeout(() => setGeneralSaveResult((v) => (v === "saved" ? null : v)), 2000); } else { @@ -1282,11 +1315,16 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { const v = e.target.value; if ((SENDER_LOOKUP_PROVIDERS as readonly string[]).includes(v)) { setSenderLookupProvider(v as SenderLookupProvider); - // Switching away from Exa hides the Ollama Cloud option + // Switching away from Exa hides the non-Anthropic options // in the senderLookup model dropdown — reset to anthropic - // so a stale "ollama-cloud" value isn't silently persisted + // so a stale value isn't silently persisted // and then re-activated when the user switches back to Exa. - if (v !== "exa" && featureProviders.senderLookup === "ollama-cloud") { + if ( + v !== "exa" && + (featureProviders.senderLookup === "ollama-cloud" || + featureProviders.senderLookup === "opencode") + ) { + featureProvidersDirty.current = true; setFeatureProviders((prev) => ({ ...prev, senderLookup: "anthropic", @@ -1389,18 +1427,19 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { }, ].map(({ key, label, description }) => { const provider = featureProviders[key] ?? "anthropic"; - // The Agent Drafter row doubles as the background-agent runtime - // picker: OpenCode/Hostler route background drafts to that agent - // provider (backgroundAgentProvider, model configured in the - // Extensions tab), while Anthropic/Ollama keep the built-in - // Claude agent and pick which model it uses. While an external - // runtime is selected, featureProviders.agentDrafter is hidden - // but still saved — it keeps gating resolveAgentOllamaConfig - // (the shared agent worker's Ollama routing) alongside agentChat. + // The Agent Drafter row also selects the background runtime. + // OpenCode has its own per-feature model here; Hostler keeps its + // model in Extensions. Anthropic/Ollama use the built-in agent + // with this row's model route. const isBackgroundAgentRow = key === "agentDrafter"; - const externalRuntime = + const selectedProvider = isBackgroundAgentRow && - backgroundAgentProvider !== DEFAULT_BACKGROUND_AGENT_PROVIDER; + backgroundAgentProvider !== DEFAULT_BACKGROUND_AGENT_PROVIDER + ? backgroundAgentProvider + : provider; + const extensionModel = selectedProvider === "hostler"; + const openCodeEligible = + key !== "senderLookup" || senderLookupProvider === "exa"; return (
- {externalRuntime ? ( + {extensionModel ? ( - ) : provider === "anthropic" ? ( + ) : selectedProvider === "anthropic" ? ( - ) : ( + ) : selectedProvider === "ollama-cloud" ? ( setOllamaModels((prev) => ({ ...prev, [key]: v }))} @@ -1508,6 +1553,32 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { selectClassName="w-48 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-500 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" inputClassName="w-48 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-500 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> + ) : selectedProvider === "opencode" ? ( + { + openCodeModelsDirty.current = true; + setOpenCodeModels((prev) => ({ ...prev, [key]: value })); + }} + models={openCodeCatalog.data ?? []} + loading={openCodeCatalog.isFetching} + error={ + openCodeCatalog.error instanceof Error + ? openCodeCatalog.error.message + : undefined + } + onRefresh={() => void openCodeCatalog.refetch()} + ariaLabel={`OpenCode model for ${label}`} + /> + ) : null} + {selectedProvider === "opencode" && !opencodeRuntimeAvailable && ( + )}
@@ -3582,6 +3653,7 @@ export function SettingsPanel({ onClose, initialTab }: SettingsPanelProps) { // Mirror the persisted featureProviders reset in the staged copy // (hydrated once per session) so Save Changes can't republish // ollama-cloud routes whose API key was just cleared. + featureProvidersDirty.current = true; setFeatureProviders((prev) => Object.fromEntries( Object.entries(prev).map(([feature, provider]) => [ diff --git a/src/shared/types.ts b/src/shared/types.ts index 074d8939..c28ae405 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -352,10 +352,59 @@ export function resolveModelId(tier: ModelTier): string { } // LLM Provider types — supports routing features to different backends -export const LLM_PROVIDERS = ["anthropic", "ollama-cloud"] as const; -export const LlmProviderSchema = z.enum(["anthropic", "ollama-cloud"]); +export const LLM_PROVIDERS = ["anthropic", "ollama-cloud", "opencode"] as const; +export const LlmProviderSchema = z.enum(LLM_PROVIDERS); export type LlmProvider = z.infer; +export type OpenCodeModelOption = { + providerId: string; + providerName: string; + modelId: string; + modelName: string; +}; + +export type OpenCodeRoute = { + providerID: string; + modelID: string; +}; + +export function parseOpenCodeModelSelector( + selector: string | undefined, +): OpenCodeRoute | undefined { + const value = selector?.trim(); + if (!value) return undefined; + const slash = value.indexOf("/"); + if (slash <= 0 || slash === value.length - 1) return undefined; + return { + providerID: value.slice(0, slash), + modelID: value.slice(slash + 1), + }; +} + +export function resolveOpenCodeRoute( + selector: string | undefined, + models: OpenCodeModelOption[], +): OpenCodeRoute | undefined { + const value = selector?.trim(); + if (!value) return undefined; + const exact = parseOpenCodeModelSelector(value); + if (exact) { + const available = models.some( + (model) => model.providerId === exact.providerID && model.modelId === exact.modelID, + ); + if (available) return exact; + throw new Error(`OpenCode model "${value}" is not available from a connected provider`); + } + const matches = models.filter((model) => model.modelId === value); + if (matches.length === 1) { + return { providerID: matches[0].providerId, modelID: matches[0].modelId }; + } + if (matches.length === 0) { + throw new Error(`OpenCode model "${value}" is not available from a connected provider`); + } + throw new Error(`OpenCode model "${value}" is ambiguous; select an exact provider/model`); +} + // Search backends for sender lookup. "anthropic" uses Claude's built-in // web_search tool (search + extraction in one LLM call). "exa" hits Exa's // /search REST endpoint and then sends the results to the configured @@ -493,6 +542,7 @@ export const ConfigSchema = z.object({ // Anthropic resolution produces. Stored as "provider/model" or just the // model name — see OpenCodeAgentProvider.resolveModel(). model: z.string().optional(), + featureModels: z.record(z.string(), z.string()).optional(), }) .optional(), // Hostler provider settings — hosted cloud agent backend (hostler.dev). @@ -654,7 +704,9 @@ export function applyAgentDrafterSelection( selected: string, ): { backgroundAgentProvider: string; agentDrafterProvider?: LlmProvider } | null { if ((EXTERNAL_AGENT_RUNTIMES as readonly string[]).includes(selected)) { - return { backgroundAgentProvider: selected }; + return selected === "opencode" + ? { backgroundAgentProvider: selected, agentDrafterProvider: "opencode" } + : { backgroundAgentProvider: selected }; } const llmParse = LlmProviderSchema.safeParse(selected); if (llmParse.success) { @@ -979,6 +1031,7 @@ export type IpcChannels = { // Settings operations "settings:get": void; "settings:set": Partial; + "settings:list-opencode-models": void; "settings:get-prompts": void; "settings:set-prompts": { analysisPrompt?: string; draftPrompt?: string; stylePrompt?: string }; "settings:get-ea": void; diff --git a/tests/e2e/launch-helpers.ts b/tests/e2e/launch-helpers.ts index ea50093f..d612e84a 100644 --- a/tests/e2e/launch-helpers.ts +++ b/tests/e2e/launch-helpers.ts @@ -10,6 +10,7 @@ export type LaunchOptions = { workerIndex?: number; extraEnv?: Record; waitAfterLoad?: number; + userDataDir?: string; }; /** @@ -21,7 +22,7 @@ export type LaunchOptions = { export async function launchElectronApp( options: LaunchOptions = {}, ): Promise<{ app: ElectronApplication; page: Page }> { - const { workerIndex = 0, extraEnv = {}, waitAfterLoad } = options; + const { workerIndex = 0, extraEnv = {}, waitAfterLoad, userDataDir } = options; const env: Record = { ...(process.env as Record), @@ -32,9 +33,12 @@ export async function launchElectronApp( }; // A leftover `export EXO_USER_DATA_DIR` (e.g. from a manual packaged run) // would make every parallel e2e worker share one data dir — concurrent - // electron-store writes and a shared Chromium profile. E2E isolation comes - // from .dev-data + per-worker DBs, never from the override. + // electron-store writes and a shared Chromium profile. Ignore ambient + // overrides; a test may opt into an explicit isolated directory. delete env.EXO_USER_DATA_DIR; + if (userDataDir) { + env.EXO_USER_DATA_DIR = userDataDir; + } const app = await electron.launch({ args: [path.join(__dirname, "../../out/main/index.js")], diff --git a/tests/e2e/sender-profile.spec.ts b/tests/e2e/sender-profile.spec.ts index aa4c0072..c1fe86bd 100644 --- a/tests/e2e/sender-profile.spec.ts +++ b/tests/e2e/sender-profile.spec.ts @@ -1,5 +1,8 @@ import { test, expect, Page, ElectronApplication } from "@playwright/test"; -import { launchElectronApp, pressKeyUntilVisible , closeApp } from "./launch-helpers"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { launchElectronApp, closeApp } from "./launch-helpers"; /** * E2E Tests for the sender profile panel. @@ -15,9 +18,18 @@ test.describe("Sender Profile - Display", () => { test.describe.configure({ mode: "serial" }); let electronApp: ElectronApplication; let page: Page; + let userDataDir: string; test.beforeAll(async ({}, testInfo) => { - const result = await launchElectronApp({ workerIndex: testInfo.workerIndex }); + // The full E2E matrix reuses one demo database per Playwright worker. + // Earlier archive/trash/snooze suites can therefore change the inbox after + // this serial describe starts. Give this stateful flow its own data dir so + // every assertion observes the same deterministic demo inbox. + userDataDir = mkdtempSync(join(tmpdir(), "exo-sender-profile-")); + const result = await launchElectronApp({ + workerIndex: testInfo.workerIndex, + userDataDir, + }); electronApp = result.app; page = result.page; @@ -32,6 +44,9 @@ test.describe("Sender Profile - Display", () => { if (electronApp) { await closeApp(electronApp); } + if (userDataDir) { + rmSync(userDataDir, { recursive: true, force: true }); + } }); test("selecting an email shows the detail view with sender info", async () => { @@ -92,16 +107,73 @@ test.describe("Sender Profile - Display", () => { // Return to split view await page.keyboard.press("Escape"); - await page.waitForTimeout(500); + await expect(replyButton).toBeHidden({ timeout: 5000 }); + await expect(page.locator("div[data-thread-id] > button").first()).toBeVisible({ + timeout: 15000, + }); }); test("leaving full view preserves row selection and sender sidebar", async () => { + const firstThreadButton = page.locator("div[data-thread-id] > button").first(); + await expect(firstThreadButton).toBeVisible({ timeout: 15000 }); + const firstThreadId = await firstThreadButton.evaluate( + (button) => button.parentElement?.dataset.threadId ?? null, + ); + expect(firstThreadId).toBeTruthy(); + + // Establish this test's own selection directly. Keyboard navigation has + // separate coverage, and synthetic key events can precede its global + // listener registration when the full E2E matrix is under load. + await page.evaluate((threadId) => { + const store = (window as unknown as Record).__ZUSTAND_STORE__ as { + getState: () => { + emails: Array<{ id: string; threadId: string; date: string }>; + setSelectedEmailId: (id: string | null) => void; + setSelectedThreadId: (id: string | null) => void; + setViewMode: (mode: "split" | "full") => void; + }; + }; + const state = store.getState(); + const latestEmail = state.emails + .filter((email) => email.threadId === threadId) + .sort((left, right) => Date.parse(right.date) - Date.parse(left.date))[0]; + if (!latestEmail) throw new Error(`No email found for thread ${threadId}`); + state.setSelectedThreadId(threadId); + state.setSelectedEmailId(latestEmail.id); + state.setViewMode("split"); + }, firstThreadId); + const selectedRow = page.locator("div[data-thread-id][data-selected='true']"); - await pressKeyUntilVisible(page, "j", selectedRow, { timeout: 15000 }); + await expect(selectedRow).toBeVisible({ timeout: 15000 }); const selectedThreadIdBefore = await selectedRow.getAttribute("data-thread-id"); + const selectedEmailIdBefore = await page.evaluate(() => { + const store = (window as unknown as Record).__ZUSTAND_STORE__ as { + getState: () => { selectedEmailId: string | null }; + }; + return store.getState().selectedEmailId; + }); + expect(selectedEmailIdBefore).toBeTruthy(); + + const readSelectionState = () => + page.evaluate(() => { + const store = (window as unknown as Record).__ZUSTAND_STORE__ as { + getState: () => { + selectedEmailId: string | null; + selectedThreadId: string | null; + viewMode: "split" | "full"; + }; + }; + const state = store.getState(); + return { + selectedEmailId: state.selectedEmailId, + selectedThreadId: state.selectedThreadId, + viewMode: state.viewMode, + }; + }); const replyButton = page.locator("button[title='Reply All']").first(); - await pressKeyUntilVisible(page, "Enter", replyButton, { timeout: 10000 }); + await selectedRow.locator("> button").click(); + await expect(replyButton).toBeVisible({ timeout: 10000 }); const senderName = page.locator("[data-testid='sidebar-sender-name']"); await expect(senderName).toBeVisible({ timeout: 5000 }); @@ -113,6 +185,20 @@ test.describe("Sender Profile - Display", () => { // just viewing so j/k resume from there. The preview sidebar keeps showing // that email's sender. await expect(replyButton).toBeHidden({ timeout: 5000 }); + await expect + .poll(readSelectionState, { + message: "Escape should preserve the selected email and thread in split view", + }) + .toEqual({ + selectedEmailId: selectedEmailIdBefore, + selectedThreadId: selectedThreadIdBefore, + viewMode: "split", + }); + + // The email list unmounts in full view and remounts on return to split + // view. Wait on that lifecycle condition before checking the virtualized + // row, so an empty transitional DOM cannot be mistaken for lost state. + await expect(page.locator("div[data-thread-id]").first()).toBeVisible(); await expect(selectedRow).toHaveCount(1); expect(await selectedRow.getAttribute("data-thread-id")).toBe(selectedThreadIdBefore); await expect(senderName).toBeVisible({ timeout: 5000 }); @@ -258,13 +344,15 @@ test.describe("Sender Profile - Full View", () => { test("full view shows sender name for the selected email", async () => { await expect(page.locator("text=Inbox").first()).toBeVisible({ timeout: 10000 }); - // Navigate to first email and enter full view - const selectedRow = page.locator("div[data-thread-id][data-selected='true']"); - await pressKeyUntilVisible(page, "j", selectedRow, { timeout: 15000 }); + // Open the first email directly; this test covers full-view rendering, + // not keyboard navigation. + const firstThread = page.locator("div[data-thread-id] > button").first(); + await expect(firstThread).toBeVisible({ timeout: 15000 }); + await firstThread.click(); // Should be in full view const replyButton = page.locator("button[title='Reply All']").first(); - await pressKeyUntilVisible(page, "Enter", replyButton, { timeout: 10000 }); + await expect(replyButton).toBeVisible({ timeout: 10000 }); // The email header area should show sender name const bodyText = await page.textContent("body"); diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts index fdbee3d6..8afde374 100644 --- a/tests/e2e/settings.spec.ts +++ b/tests/e2e/settings.spec.ts @@ -1,4 +1,7 @@ import { test, expect, Page, ElectronApplication } from "@playwright/test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { launchElectronApp, closeApp } from "./launch-helpers"; /** @@ -429,120 +432,454 @@ test.describe("Settings Panel - Persistence", () => { }); }); -// These tests use ONLY the opencode + backgroundAgentProvider config keys. -// The electron-store config is shared across parallel e2e workers (only the -// DB is per-worker), and tests/e2e/hostler-settings.spec.ts owns the hostler -// key — touching it here would let fullyParallel workers race on it. -test.describe("Settings Panel - Agent Drafter runtime picker", () => { +// This flow gets an isolated electron-store directory because other settings +// suites save complete config snapshots in parallel. Sharing the default test +// directory would let one suite overwrite this flow's OpenCode enablement. +test.describe("Settings Panel - per-feature OpenCode models", () => { test.describe.configure({ mode: "serial" }); let electronApp: ElectronApplication; let page: Page; + let userDataDir: string; test.beforeAll(async ({}, testInfo) => { - const result = await launchElectronApp({ workerIndex: testInfo.workerIndex }); + userDataDir = mkdtempSync(join(tmpdir(), "exo-opencode-settings-")); + const result = await launchElectronApp({ + workerIndex: testInfo.workerIndex, + userDataDir, + }); electronApp = result.app; page = result.page; - // Gate state must exist before Settings first mounts — the General tab - // reads it from the general-config query fetched on mount. + + await electronApp.evaluate(({ ipcMain }) => { + ipcMain.removeHandler("settings:list-opencode-models"); + ipcMain.handle("settings:list-opencode-models", () => ({ + success: true, + data: [ + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.2", + modelName: "GPT-5.2", + }, + { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }, + ], + })); + }); + await page.evaluate(() => window.api.settings.set({ + senderLookupProvider: "exa", + featureProviders: {}, backgroundAgentProvider: "claude", - opencode: { enabled: true }, + opencode: { enabled: true, model: "legacy-model", featureModels: {} }, }), ); }); test.afterAll(async () => { - if (page) { - await page.evaluate(() => - window.api.settings.set({ - backgroundAgentProvider: "claude", - opencode: { enabled: false }, - }), - ); - } - if (electronApp) { - await closeApp(electronApp); + try { + if (page && !page.isClosed()) { + await page.evaluate(() => + window.api.settings.set({ + senderLookupProvider: "anthropic", + featureProviders: {}, + backgroundAgentProvider: "claude", + opencode: { enabled: false, model: "", featureModels: {} }, + }), + ); + } + } finally { + if (electronApp) { + await closeApp(electronApp); + } + rmSync(userDataDir, { recursive: true, force: true }); } }); - test("an enabled runtime is selectable in the Agent Drafter row", async () => { + test("Escape dismisses OpenCode suggestions before closing Settings", async () => { await page.locator("button[title='Settings']").click(); await expect(page.locator("h1:has-text('Settings')")).toBeVisible({ timeout: 5000 }); - const select = page.getByLabel("Provider for Agent Drafter"); - await select.scrollIntoViewIfNeeded(); - await expect(select.locator("option[value='opencode']")).toBeEnabled(); + await page.getByLabel("Provider for Email Analysis").selectOption("opencode"); + const modelInput = page.getByLabel("OpenCode model for Email Analysis"); + await modelInput.fill("openai/gpt-5.2"); + await expect(page.getByRole("listbox", { name: "OpenCode model suggestions" })).toBeVisible(); + + await modelInput.press("Escape"); + await expect(page.getByRole("listbox", { name: "OpenCode model suggestions" })).toBeHidden(); + await expect(page.locator("h1:has-text('Settings')")).toBeVisible(); + await expect(modelInput).toHaveValue("openai/gpt-5.2"); + + await page.keyboard.press("Escape"); + await expect(page.locator("h1:has-text('Settings')")).toBeHidden(); }); - test("selecting an external runtime persists only after Save Changes", async () => { - const select = page.getByLabel("Provider for Agent Drafter"); - await select.selectOption("opencode"); + test("Tab leaves the OpenCode combobox without traversing suggestions", async () => { + await page.locator("button[title='Settings']").click(); + await expect(page.locator("h1:has-text('Settings')")).toBeVisible({ timeout: 5000 }); - // The model column is replaced by the Extensions hint while an external - // runtime is selected. - await expect(page.getByRole("button", { name: "Model set in Extensions" })).toBeVisible(); + await page.getByLabel("Provider for Email Analysis").selectOption("opencode"); + const modelInput = page.getByLabel("OpenCode model for Email Analysis"); + await modelInput.click(); + const firstOption = page + .getByRole("listbox", { name: "OpenCode model suggestions" }) + .getByRole("option") + .first(); + await expect(firstOption).toBeVisible(); - // Old UI persisted on change; the consolidated row stages until Save. + await modelInput.press("Tab"); + await expect(firstOption).not.toBeFocused(); + await expect(page.getByRole("button", { name: "Refresh models" })).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(page.locator("h1:has-text('Settings')")).toBeHidden(); + }); + + test("stages and persists per-feature OpenCode models and Agent Chat defaults", async () => { + await page.locator("button[title='Settings']").click(); + await expect(page.locator("h1:has-text('Settings')")).toBeVisible({ timeout: 5000 }); + + const labels = [ + "Email Analysis", + "Draft Generation", + "Draft Refinement", + "Scheduling Detection", + "Archive-Ready Analysis", + "Sender Lookup", + "Agent Drafter", + "Agent Chat", + ]; + for (const label of labels) { + const select = page.getByLabel(`Provider for ${label}`); + await select.scrollIntoViewIfNeeded(); + await expect(select.locator("option[value='opencode']")).toBeEnabled(); + } + + await page.getByLabel("Provider for Email Analysis").selectOption("opencode"); + const analysisModel = page.getByLabel("OpenCode model for Email Analysis"); + await expect(analysisModel).toHaveAttribute("role", "combobox"); + await expect(analysisModel).toHaveAttribute("aria-autocomplete", "list"); + await analysisModel.click(); + await expect(page.getByRole("listbox", { name: "OpenCode model suggestions" })).toBeVisible(); + await analysisModel.press("ArrowDown"); + await analysisModel.press("Enter"); + await expect(analysisModel).toHaveValue("openai/gpt-5.2"); + + await page.getByLabel("Provider for Draft Generation").selectOption("opencode"); + await page + .getByLabel("OpenCode model for Draft Generation") + .fill("anthropic/claude-sonnet-4-5"); + + // Provider and model changes remain staged until the existing Save flow. const before = (await page.evaluate(() => window.api.settings.get())) as { - data?: { backgroundAgentProvider?: string }; + data?: { + featureProviders?: Record; + opencode?: { featureModels?: Record }; + }; }; - expect(before.data?.backgroundAgentProvider ?? "claude").toBe("claude"); + expect(before.data?.featureProviders?.analysis).toBeUndefined(); + expect(before.data?.opencode?.featureModels?.analysis).toBeUndefined(); await page.getByRole("button", { name: "Save Changes" }).click(); await expect .poll(async () => { const cfg = (await page.evaluate(() => window.api.settings.get())) as { - data?: { backgroundAgentProvider?: string }; + data?: { + opencode?: { + enabled?: boolean; + model?: string; + featureModels?: Record; + }; + }; }; - return cfg.data?.backgroundAgentProvider; + return cfg.data?.opencode; }) - .toBe("opencode"); - }); - - test("selecting an LLM provider returns the runtime to Claude and restores the model select", async () => { - const select = page.getByLabel("Provider for Agent Drafter"); - await select.selectOption("anthropic"); + .toMatchObject({ + enabled: true, + model: "legacy-model", + featureModels: { + analysis: "openai/gpt-5.2", + drafts: "anthropic/claude-sonnet-4-5", + }, + }); - await expect(page.getByLabel("Model tier for Agent Drafter")).toBeVisible(); + await page.keyboard.press("Escape"); + await page.keyboard.press("ControlOrMeta+,"); + await expect(page.getByLabel("OpenCode model for Email Analysis")).toHaveValue( + "openai/gpt-5.2", + ); + await expect(page.getByLabel("OpenCode model for Draft Generation")).toHaveValue( + "anthropic/claude-sonnet-4-5", + ); + // A selector removed from the live catalog remains visible and explicitly warned. + await page.keyboard.press("Escape"); + await page.evaluate(() => + window.api.settings.set({ + opencode: { featureModels: { analysis: "openai/removed-model" } }, + }), + ); + await page.keyboard.press("ControlOrMeta+,"); + await expect(page.getByLabel("OpenCode model for Email Analysis")).toHaveValue( + "openai/removed-model", + ); + await expect(page.getByText("Saved model is unavailable in OpenCode.")).toBeVisible(); + + // Catalog refresh failures stay visible and a later successful refresh recovers. + await electronApp.evaluate(({ ipcMain }) => { + ipcMain.removeHandler("settings:list-opencode-models"); + ipcMain.handle("settings:list-opencode-models", () => ({ + success: false, + error: "OpenCode model catalog unavailable", + })); + }); + await page.getByRole("button", { name: "Refresh models" }).first().click(); + await expect(page.getByRole("alert").first()).toHaveText("OpenCode model catalog unavailable"); + + await electronApp.evaluate(({ ipcMain }) => { + ipcMain.removeHandler("settings:list-opencode-models"); + ipcMain.handle("settings:list-opencode-models", () => ({ + success: true, + data: [ + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.2", + modelName: "GPT-5.2", + }, + { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }, + ], + })); + }); + await page.getByRole("button", { name: "Refresh models" }).first().click(); + await expect(page.getByRole("alert")).toHaveCount(0); + + // OpenCode parsing is only eligible with Exa. Leaving Exa resets the staged route. + await page.getByLabel("Provider for Sender Lookup").selectOption("opencode"); + await page.getByLabel("OpenCode model for Sender Lookup").fill("openai/gpt-5.2"); + await page.getByLabel("Backend").selectOption("anthropic"); + await expect(page.getByLabel("Provider for Sender Lookup")).toHaveValue("anthropic"); + + // Agent Drafter owns both the background runtime and per-feature model route. + await page.getByLabel("Provider for Agent Drafter").selectOption("opencode"); + await page.getByLabel("OpenCode model for Agent Drafter").fill("openai/gpt-5.2"); + await page.getByLabel("Provider for Agent Chat").selectOption("opencode"); + await page.getByLabel("OpenCode model for Agent Chat").fill("openai/gpt-5.2"); await page.getByRole("button", { name: "Save Changes" }).click(); + await expect .poll(async () => { const cfg = (await page.evaluate(() => window.api.settings.get())) as { - data?: { backgroundAgentProvider?: string }; + data?: { + senderLookupProvider?: string; + backgroundAgentProvider?: string; + featureProviders?: Record; + }; + }; + return { + senderLookupProvider: cfg.data?.senderLookupProvider, + senderLookup: cfg.data?.featureProviders?.senderLookup, + backgroundAgentProvider: cfg.data?.backgroundAgentProvider, + agentDrafter: cfg.data?.featureProviders?.agentDrafter, + agentChat: cfg.data?.featureProviders?.agentChat, }; - return cfg.data?.backgroundAgentProvider; }) - .toBe("claude"); + .toEqual({ + senderLookupProvider: "anthropic", + senderLookup: "anthropic", + backgroundAgentProvider: "opencode", + agentDrafter: "opencode", + agentChat: "opencode", + }); + + await page.keyboard.press("Escape"); + await page.keyboard.press("ControlOrMeta+j"); + await expect(page.getByPlaceholder("Ask agent anything...")).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + const store = (window as unknown as Record).__ZUSTAND_STORE__ as { + getState: () => { selectedAgentIds: string[] }; + }; + return store.getState().selectedAgentIds; + }), + ) + .toEqual(["opencode"]); + + await page.getByRole("button", { name: "Claude" }).click(); + await expect + .poll(() => + page.evaluate(() => { + const store = (window as unknown as Record).__ZUSTAND_STORE__ as { + getState: () => { selectedAgentIds: string[] }; + }; + return store.getState().selectedAgentIds; + }), + ) + .toEqual(["claude"]); + + await page.keyboard.press("Escape"); + await page.keyboard.press("ControlOrMeta+j"); + await expect + .poll(() => + page.evaluate(() => { + const store = (window as unknown as Record).__ZUSTAND_STORE__ as { + getState: () => { selectedAgentIds: string[] }; + }; + return store.getState().selectedAgentIds; + }), + ) + .toEqual(["opencode"]); + await page.keyboard.press("Escape"); }); - // Same serial describe (same worker) as the tests above: they all mutate - // the shared backgroundAgentProvider/opencode keys. - test("shows the fallback warning and keeps the saved runtime selected when gated off", async ({}, testInfo) => { - // A saved runtime whose Extensions gates are no longer met — the row must - // surface the fallback instead of silently misrepresenting where drafts - // run. The state must exist before Settings first mounts, so relaunch. + test("retains a saved OpenCode selection when the extension is disabled", async ({}, testInfo) => { await page.evaluate(() => window.api.settings.set({ - backgroundAgentProvider: "opencode", - opencode: { enabled: false }, + featureProviders: { analysis: "opencode" }, + backgroundAgentProvider: "claude", + opencode: { + enabled: false, + featureModels: { analysis: "openai/gpt-5.2" }, + }, }), ); await closeApp(electronApp); - const result = await launchElectronApp({ workerIndex: testInfo.workerIndex }); + const result = await launchElectronApp({ + workerIndex: testInfo.workerIndex, + userDataDir, + }); electronApp = result.app; page = result.page; await page.locator("button[title='Settings']").click(); - await expect(page.locator("h1:has-text('Settings')")).toBeVisible({ timeout: 5000 }); - - const select = page.getByLabel("Provider for Agent Drafter"); + const select = page.getByLabel("Provider for Email Analysis"); await select.scrollIntoViewIfNeeded(); await expect(select).toHaveValue("opencode"); await expect(select.locator("option[value='opencode']")).toBeDisabled(); - await expect( - page.getByText(/OpenCode is disabled — background drafts fall back to the built-in agent/), - ).toBeVisible(); + await expect(page.getByRole("button", { name: "Enable OpenCode in Extensions" })).toBeVisible(); + await expect(page.getByLabel("OpenCode model for Email Analysis")).toHaveValue( + "openai/gpt-5.2", + ); + }); + + test("keeps staged OpenCode edits when fresh settings resolve late", async () => { + const heading = page.locator("h1:has-text('Settings')"); + if (await heading.isVisible()) { + await page.keyboard.press("Escape"); + await expect(heading).toBeHidden(); + } + + await page.evaluate(() => + window.api.settings.set({ + featureProviders: {}, + backgroundAgentProvider: "claude", + allowPrereleaseUpdates: true, + opencode: { enabled: true, model: "legacy-model", featureModels: {} }, + }), + ); + + // Prime the query cache, then hold the required fresh fetch on the next + // panel mount so user interaction deterministically wins the race. + await page.locator("button[title='Settings']").click(); + await expect(page.getByRole("button", { name: "Save Changes" })).toBeEnabled(); + await expect(page.getByRole("switch", { name: "Pre-release updates" })).toBeChecked(); + await page.keyboard.press("Escape"); + const stored = await page.evaluate(() => window.api.settings.get()); + + await electronApp.evaluate(({ ipcMain }, response) => { + const state = globalThis as typeof globalThis & { + releaseDelayedSettingsGet?: () => void; + delayedSettingsGetPending?: boolean; + }; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + state.releaseDelayedSettingsGet = release; + ipcMain.removeHandler("settings:get"); + ipcMain.handle("settings:get", async () => { + state.delayedSettingsGetPending = true; + await gate; + state.delayedSettingsGetPending = false; + return response; + }); + }, stored); + + await page.locator("button[title='Settings']").click(); + await expect + .poll(() => + electronApp.evaluate( + () => + (globalThis as typeof globalThis & { delayedSettingsGetPending?: boolean }) + .delayedSettingsGetPending, + ), + ) + .toBe(true); + + const provider = page.getByLabel("Provider for Email Analysis"); + await provider.selectOption("opencode"); + const model = page.getByLabel("OpenCode model for Email Analysis"); + await model.fill("openai/gpt-5.2"); + const agentDrafter = page.getByLabel("Provider for Agent Drafter"); + await agentDrafter.selectOption("opencode"); + await expect(agentDrafter).toHaveValue("opencode"); + + await electronApp.evaluate(() => { + ( + globalThis as typeof globalThis & { + releaseDelayedSettingsGet?: () => void; + } + ).releaseDelayedSettingsGet?.(); + }); + await expect + .poll(() => + electronApp.evaluate( + () => + (globalThis as typeof globalThis & { delayedSettingsGetPending?: boolean }) + .delayedSettingsGetPending, + ), + ) + .toBe(false); + + // This untouched value starts false in the newly-mounted component and + // changes only when the delayed React Query response hydrates staged state. + await expect(page.getByRole("switch", { name: "Pre-release updates" })).toBeChecked(); + await expect(provider).toHaveValue("opencode"); + await expect(model).toHaveValue("openai/gpt-5.2"); + await expect(agentDrafter).toHaveValue("opencode"); + + await page.getByRole("button", { name: "Save Changes" }).click(); + await expect(page.getByRole("button", { name: "Saved" })).toBeVisible(); + + // Restart to restore the real settings:get handler, then verify the store + // rather than the delayed test response. + await closeApp(electronApp); + const result = await launchElectronApp({ userDataDir }); + electronApp = result.app; + page = result.page; + const persisted = (await page.evaluate(() => window.api.settings.get())) as { + data?: { + featureProviders?: Record; + backgroundAgentProvider?: string; + opencode?: { featureModels?: Record }; + }; + }; + expect(persisted.data?.featureProviders).toMatchObject({ + analysis: "opencode", + agentDrafter: "opencode", + }); + expect(persisted.data?.backgroundAgentProvider).toBe("opencode"); + expect(persisted.data?.opencode?.featureModels?.analysis).toBe("openai/gpt-5.2"); }); }); diff --git a/tests/migrations/replay.spec.ts b/tests/migrations/replay.spec.ts index 5d2d8344..5d477ef0 100644 --- a/tests/migrations/replay.spec.ts +++ b/tests/migrations/replay.spec.ts @@ -27,9 +27,8 @@ import { SCHEMA } from "../../src/main/db/schema"; const require = createRequire(import.meta.url); type DB = BetterSqlite3.Database; -let DatabaseCtor: - | (new (filename: string | Buffer, options?: BetterSqlite3.Options) => DB) - | null = null; +let DatabaseCtor: (new (filename: string | Buffer, options?: BetterSqlite3.Options) => DB) | null = + null; let nativeModuleError: string | null = null; try { DatabaseCtor = require("better-sqlite3"); @@ -106,16 +105,14 @@ test.describe("Migration replay + symmetry", () => { } const draftCols = listTableColumns(db, "drafts"); - for (const col of [ - "agent_task_id", - "cc", - "bcc", - "compose_mode", - "to_recipients", - ]) { + for (const col of ["agent_task_id", "cc", "bcc", "compose_mode", "to_recipients"]) { expect(draftCols.has(col), `drafts should have column ${col}`).toBe(true); } + const llmCallCols = listTableColumns(db, "llm_calls"); + expect(llmCallCols.has("usage_available")).toBe(true); + expect(llmCallCols.has("cost_available")).toBe(true); + // All numbered migrations should be recorded as applied. const appliedVersions = ( db.prepare("SELECT version FROM schema_version ORDER BY version").all() as Array<{ @@ -181,9 +178,8 @@ test.describe("Migration replay + symmetry", () => { expect(tables.has("schema_version")).toBe(true); // Original data should still be present and intact. - const accountCount = ( - db.prepare("SELECT COUNT(*) as c FROM accounts").get() as { c: number } - ).c; + const accountCount = (db.prepare("SELECT COUNT(*) as c FROM accounts").get() as { c: number }) + .c; expect(accountCount).toBe(1); // All numbered migrations should be applied. @@ -199,6 +195,104 @@ test.describe("Migration replay + symmetry", () => { db.close(); }); + test("replay: v8 marks only zero-valued legacy agent approximations unavailable", () => { + const db = freshDb(); + const v1 = NUMBERED_MIGRATIONS.find((migration) => migration.version === 1); + const v7 = NUMBERED_MIGRATIONS.find((migration) => migration.version === 7); + const v8 = NUMBERED_MIGRATIONS.find((migration) => migration.version === 8); + if (!v1 || !v7 || !v8) throw new Error("Expected migrations v1, v7, and v8"); + + v1.up(db); + v7.up(db); + db.exec(` + CREATE TABLE schema_version ( + version INTEGER NOT NULL UNIQUE, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO schema_version (version) VALUES (7); + `); + + const insertLegacyCall = db.prepare(` + INSERT INTO llm_calls ( + id, model, caller, input_tokens, output_tokens, + cache_read_tokens, cache_create_tokens, cost_cents, + duration_ms, success, provider + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insertLegacyCall.run( + "approximation", + "openai/gpt-5.2", + "agent-session-start:opencode", + 0, + 0, + 0, + 0, + 0, + 0, + 1, + "opencode", + ); + insertLegacyCall.run( + "measured-agent", + "claude-sonnet-4", + "agent-session-start:claude", + 120, + 40, + 10, + 5, + 0.25, + 800, + 1, + "anthropic", + ); + insertLegacyCall.run( + "ordinary-zero", + "claude-sonnet-4", + "email-analyzer", + 0, + 0, + 0, + 0, + 0, + 25, + 0, + "anthropic", + ); + + runMigrations(db); + + const availability = () => + db + .prepare( + `SELECT id, usage_available, cost_available + FROM llm_calls + ORDER BY id`, + ) + .all(); + expect(availability()).toEqual([ + { id: "approximation", usage_available: 0, cost_available: 0 }, + { id: "measured-agent", usage_available: 1, cost_available: 1 }, + { id: "ordinary-zero", usage_available: 1, cost_available: 1 }, + ]); + + // The migration body is safe to replay independently, and the runner + // remains a no-op once v8 is recorded. + v8.up(db); + v8.up(db); + runMigrations(db); + expect(availability()).toEqual([ + { id: "approximation", usage_available: 0, cost_available: 0 }, + { id: "measured-agent", usage_available: 1, cost_available: 1 }, + { id: "ordinary-zero", usage_available: 1, cost_available: 1 }, + ]); + const v8Count = db + .prepare("SELECT COUNT(*) AS count FROM schema_version WHERE version = 8") + .get() as { count: number }; + expect(v8Count.count).toBe(1); + + db.close(); + }); + test("numbered migration versions are sequential starting from 1", () => { // Catches accidental re-numbering or gaps that would break the // forward-only invariant. diff --git a/tests/packaged/smoke.spec.ts b/tests/packaged/smoke.spec.ts index 09a12dc1..c551d8e1 100644 --- a/tests/packaged/smoke.spec.ts +++ b/tests/packaged/smoke.spec.ts @@ -19,8 +19,15 @@ * EXO_USER_DATA_DIR or the smoke test writes into — and can corrupt — the * user's production config and database. */ -import { test, expect, _electron as electron, type Page, type ElectronApplication } from "@playwright/test"; -import { existsSync, mkdirSync, rmSync } from "fs"; +import { + test, + expect, + _electron as electron, + type Page, + type ElectronApplication, +} from "@playwright/test"; +import { spawnSync } from "child_process"; +import { existsSync, mkdirSync, rmSync, statSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -49,12 +56,8 @@ test.describe("Packaged app smoke", () => { let app: ElectronApplication; let page: Page; - test.beforeAll(async () => { - // Start from a clean slate — stale Chromium profile state or config from - // a previous run would make the smoke test non-deterministic. - rmSync(USER_DATA_DIR, { recursive: true, force: true }); - mkdirSync(USER_DATA_DIR, { recursive: true }); - app = await electron.launch({ + const launchPackagedApp = async (): Promise => + electron.launch({ executablePath: BINARY, env: { ...process.env, @@ -70,25 +73,49 @@ test.describe("Packaged app smoke", () => { }, timeout: 30_000, }); + + const closePackagedApp = async (target: ElectronApplication): Promise => { + const proc = target.process(); + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + target.close(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("packaged app shutdown timed out")), 5_000); + }), + ]); + } catch { + if (proc.pid) { + try { + process.kill(proc.pid, "SIGKILL"); + } catch { + /* already gone */ + } + } + if (proc.exitCode === null && proc.signalCode === null) { + await Promise.race([ + new Promise((resolve) => proc.once("exit", () => resolve())), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + } + } finally { + if (timeout) clearTimeout(timeout); + } + }; + + test.beforeAll(async () => { + // Start from a clean slate — stale Chromium profile state or config from + // a previous run would make the smoke test non-deterministic. + rmSync(USER_DATA_DIR, { recursive: true, force: true }); + mkdirSync(USER_DATA_DIR, { recursive: true }); + app = await launchPackagedApp(); page = await app.firstWindow(); await page.waitForLoadState("domcontentloaded"); }); test.afterAll(async () => { if (app) { - try { - await app.close(); - } catch { - // Packaged app shutdown can hang; force-kill is fine for smoke - const proc = app.process(); - if (proc.pid) { - try { - process.kill(proc.pid, "SIGKILL"); - } catch { - /* already gone */ - } - } - } + await closePackagedApp(app); } }); @@ -105,6 +132,41 @@ test.describe("Packaged app smoke", () => { await expect(page.locator("text=Exo").first()).toBeVisible({ timeout: 30_000 }); }); + test("bundles an executable compatible OpenCode platform binary", () => { + const resourcesDir = + process.platform === "darwin" + ? path.resolve(path.dirname(BINARY), "../Resources") + : path.resolve(path.dirname(BINARY), "resources"); + const sdkCommand = process.platform === "win32" ? "opencode.exe" : "opencode"; + const normalizedPlatform = process.platform === "win32" ? "windows" : process.platform; + const packageName = `opencode-${normalizedPlatform}-${process.arch}${ + process.arch === "x64" ? "-baseline" : "" + }`; + const sdkCommandPath = path.join( + resourcesDir, + "app.asar.unpacked/node_modules", + packageName, + "bin", + sdkCommand, + ); + const opencodeBinDir = path.dirname(sdkCommandPath); + + expect(existsSync(sdkCommandPath)).toBe(true); + if (process.platform !== "win32") { + expect(statSync(sdkCommandPath).mode & 0o111).not.toBe(0); + } + const version = spawnSync("opencode", ["--version"], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${opencodeBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + timeout: 10_000, + windowsHide: true, + }); + expect(version.status, version.stderr || version.error?.message).toBe(0); + }); + test("no main-process crash in the first 10s", async () => { const proc = app.process(); // If the main process had crashed, electron.launch would have failed @@ -128,7 +190,7 @@ test.describe("Packaged app smoke", () => { if (await settingsBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await settingsBtn.click(); } else { - await page.keyboard.press("Meta+,"); + await page.keyboard.press("ControlOrMeta+,"); } await expect(page.locator("text=Settings").first()).toBeVisible({ timeout: 5000 }); await page.keyboard.press("Escape"); @@ -154,4 +216,93 @@ test.describe("Packaged app smoke", () => { } expect(real).toHaveLength(0); }); + + test("shows an enabled OpenCode provider after a packaged-app restart", async () => { + await page.evaluate(async () => { + await window.api.settings.set({ + anthropicApiKey: "packaged-smoke-placeholder", + featureProviders: { + analysis: "opencode", + agentChat: "opencode", + }, + opencode: { + enabled: true, + featureModels: { + analysis: "openai/gpt-5.2", + }, + }, + }); + }); + + await closePackagedApp(app); + app = await launchPackagedApp(); + page = await app.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + + // Opening via the keyboard here races React's global key listener on slower + // packaged CI starts. This smoke is about the packaged provider surface, so + // wait for the renderer store and open that surface directly. + await page.waitForFunction( + () => + typeof ( + window as unknown as { + __ZUSTAND_STORE__?: { getState?: () => Record }; + } + ).__ZUSTAND_STORE__?.getState === "function", + undefined, + { timeout: 30_000 }, + ); + + await page.evaluate(() => { + const store = ( + window as unknown as { + __ZUSTAND_STORE__: { + getState: () => { + setShowSettings: (open: boolean) => void; + }; + }; + } + ).__ZUSTAND_STORE__; + store.getState().setShowSettings(true); + }); + await expect(page.locator("h1:has-text('Settings')")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByLabel("Provider for Email Analysis")).toHaveValue("opencode"); + await expect(page.getByLabel("OpenCode model for Email Analysis")).toHaveValue( + "openai/gpt-5.2", + ); + await expect(page.getByLabel("Provider for Agent Chat")).toHaveValue("opencode"); + await page.keyboard.press("Escape"); + + await page.evaluate(() => { + const store = ( + window as unknown as { + __ZUSTAND_STORE__: { + getState: () => { + setAgentPaletteOpen: (open: boolean) => void; + }; + }; + } + ).__ZUSTAND_STORE__; + store.getState().setAgentPaletteOpen(true); + }); + + await expect(page.getByPlaceholder("Ask agent anything...")).toBeVisible({ + timeout: 10_000, + }); + await expect + .poll( + async () => { + await page.evaluate(async () => { + await window.api.agent.providers(); + }); + return page.getByRole("button", { name: "OpenCode" }).count(); + }, + { timeout: 30_000 }, + ) + .toBe(1); + + await expect(page.getByRole("button", { name: "OpenCode" })).toBeVisible({ + timeout: 5_000, + }); + }); }); diff --git a/tests/unit/archive-ready.spec.ts b/tests/unit/archive-ready.spec.ts index 29d2d062..51c3537f 100644 --- a/tests/unit/archive-ready.spec.ts +++ b/tests/unit/archive-ready.spec.ts @@ -18,7 +18,11 @@ import { resetAnthropicMock, getCapturedRequests, } from "../mocks/anthropic-api-mock"; -import { _setClientForTesting } from "../../src/main/services/llm-service"; +import { + _setClientForTesting, + _setOpenCodeServiceForTesting, +} from "../../src/main/services/llm-service"; +import type { OpenCodeInferenceRequest } from "../../src/main/services/opencode-inference-service"; import { ArchiveReadyAnalyzer } from "../../src/main/services/archive-ready-analyzer"; import { ARCHIVE_READY_JSON_FORMAT, DEFAULT_ARCHIVE_READY_PROMPT } from "../../src/shared/types"; import type { DashboardEmail } from "../../src/shared/types"; @@ -300,6 +304,44 @@ test.describe("ArchiveReadyAnalyzer.analyzeThread", () => { test.afterEach(() => { _setClientForTesting(null as unknown); + _setOpenCodeServiceForTesting(); + }); + + test("constrains OpenCode output to the archive-ready schema", async () => { + const requests: OpenCodeInferenceRequest[] = []; + _setOpenCodeServiceForTesting({ + complete: async (request) => { + requests.push(request); + return { + id: "assistant-1", + text: "", + structured: { archive_ready: true, reason: "Conversation complete" }, + providerId: "openai", + modelId: "gpt-5.2", + finishReason: "stop", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }; + }, + }); + const analyzer = new ArchiveReadyAnalyzer("openai/gpt-5.2", undefined, "opencode"); + + await analyzer.analyzeThread([makeDashboardEmail()]); + + expect(requests).toHaveLength(1); + expect(requests[0].outputSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + archive_ready: { type: "boolean" }, + reason: { type: "string" }, + }, + required: ["archive_ready", "reason"], + additionalProperties: false, + }); }); test("returns archive_ready=true when Claude says so", async () => { diff --git a/tests/unit/background-agent-provider.spec.ts b/tests/unit/background-agent-provider.spec.ts index 89e3db4e..c410f696 100644 --- a/tests/unit/background-agent-provider.spec.ts +++ b/tests/unit/background-agent-provider.spec.ts @@ -14,6 +14,10 @@ import { } from "../../src/shared/types"; import { deriveTraceProviderIds } from "../../src/shared/agent-types"; import type { ScopedAgentEvent } from "../../src/shared/agent-types"; +import { + resolveAgentChatModelOverrides, + resolveAgentDrafterModelOverrides, +} from "../../src/main/agents/types"; test.describe("ConfigSchema backgroundAgentProvider", () => { test("parses config with backgroundAgentProvider set", () => { @@ -191,9 +195,10 @@ test.describe("deriveTraceProviderIds", () => { }); test.describe("applyAgentDrafterSelection", () => { - test("selecting an external runtime routes background drafts there and leaves the Claude-runtime model untouched", () => { + test("selecting OpenCode synchronizes its background and Agent Drafter routes", () => { expect(applyAgentDrafterSelection("opencode")).toEqual({ backgroundAgentProvider: "opencode", + agentDrafterProvider: "opencode", }); expect(applyAgentDrafterSelection("hostler")).toEqual({ backgroundAgentProvider: "hostler", @@ -238,3 +243,42 @@ test.describe("isAgentRuntimeAvailable", () => { expect(isAgentRuntimeAvailable(DEFAULT_BACKGROUND_AGENT_PROVIDER, {})).toBe(true); }); }); + +test.describe("agent model overrides", () => { + test("Agent Drafter defaults to its OpenCode selector for a sole OpenCode run", () => { + expect(resolveAgentDrafterModelOverrides(["opencode"], undefined, "openai/gpt-5.2")).toEqual({ + opencode: "openai/gpt-5.2", + }); + }); + + test("an explicit Agent Drafter override wins over its OpenCode selector", () => { + expect( + resolveAgentDrafterModelOverrides( + ["opencode"], + { opencode: "anthropic/claude-sonnet-4-5" }, + "openai/gpt-5.2", + ), + ).toEqual({ opencode: "anthropic/claude-sonnet-4-5" }); + }); + + test("Agent Drafter does not apply its OpenCode selector to multi-provider runs", () => { + expect( + resolveAgentDrafterModelOverrides(["opencode", "claude"], undefined, "openai/gpt-5.2"), + ).toBeUndefined(); + }); + + test("Agent Chat creates provider-specific overrides for multi-provider runs", () => { + expect( + resolveAgentChatModelOverrides(["opencode", "claude"], "openai/gpt-5.2", "claude-opus-4-6"), + ).toEqual({ + opencode: "openai/gpt-5.2", + claude: "claude-opus-4-6", + }); + expect( + resolveAgentChatModelOverrides(["claude", "opencode"], "openai/gpt-5.2", "claude-opus-4-6"), + ).toEqual({ + claude: "claude-opus-4-6", + opencode: "openai/gpt-5.2", + }); + }); +}); diff --git a/tests/unit/calendaring-agent.spec.ts b/tests/unit/calendaring-agent.spec.ts index 44b4e603..db8dba09 100644 --- a/tests/unit/calendaring-agent.spec.ts +++ b/tests/unit/calendaring-agent.spec.ts @@ -13,7 +13,11 @@ import { resetAnthropicMock, getCapturedRequests, } from "../mocks/anthropic-api-mock"; -import { _setClientForTesting } from "../../src/main/services/llm-service"; +import { + _setClientForTesting, + _setOpenCodeServiceForTesting, +} from "../../src/main/services/llm-service"; +import type { OpenCodeInferenceRequest } from "../../src/main/services/opencode-inference-service"; import type { Email, EAConfig } from "../../src/shared/types"; // --------------------------------------------------------------------------- @@ -47,6 +51,53 @@ test.describe("CalendaringAgent - analyze", () => { test.afterEach(() => { _setClientForTesting(null as unknown); + _setOpenCodeServiceForTesting(); + }); + + test("analyze() constrains OpenCode output to the calendaring schema", async () => { + const requests: OpenCodeInferenceRequest[] = []; + _setOpenCodeServiceForTesting({ + complete: async (request) => { + requests.push(request); + return { + id: "assistant-1", + text: "", + structured: { + hasSchedulingContext: true, + action: "defer_to_ea", + reason: "Meeting request", + }, + providerId: "openai", + modelId: "gpt-5.2", + finishReason: "stop", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }; + }, + }); + const agent = new CalendaringAgent("openai/gpt-5.2", undefined, "opencode"); + + await agent.analyze(makeEmail()); + + expect(requests).toHaveLength(1); + expect(requests[0].outputSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + hasSchedulingContext: { type: "boolean" }, + action: { + type: "string", + enum: ["defer_to_ea", "suggest_times", "none"], + }, + reason: { type: "string" }, + eaDeferralLanguage: { type: "string" }, + }, + required: ["hasSchedulingContext", "action", "reason"], + additionalProperties: false, + }); }); test("detects scheduling email", async () => { diff --git a/tests/unit/email-analyzer.spec.ts b/tests/unit/email-analyzer.spec.ts index bdd6429a..28fa9959 100644 --- a/tests/unit/email-analyzer.spec.ts +++ b/tests/unit/email-analyzer.spec.ts @@ -13,7 +13,11 @@ import { resetAnthropicMock, getCapturedRequests, } from "../mocks/anthropic-api-mock"; -import { _setClientForTesting } from "../../src/main/services/llm-service"; +import { + _setClientForTesting, + _setOpenCodeServiceForTesting, +} from "../../src/main/services/llm-service"; +import type { OpenCodeInferenceRequest } from "../../src/main/services/opencode-inference-service"; import type { Email } from "../../src/shared/types"; import { ANALYSIS_JSON_FORMAT } from "../../src/shared/types"; @@ -52,6 +56,44 @@ test.describe("EmailAnalyzer", () => { test.afterEach(() => { _setClientForTesting(null as unknown); + _setOpenCodeServiceForTesting(); + }); + + test("analyze() constrains OpenCode output to the analysis schema", async () => { + const requests: OpenCodeInferenceRequest[] = []; + _setOpenCodeServiceForTesting({ + complete: async (request) => { + requests.push(request); + return { + id: "assistant-1", + text: "", + structured: { needs_reply: true, reason: "Direct question" }, + providerId: "openai", + modelId: "gpt-5.2", + finishReason: "stop", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }; + }, + }); + const analyzer = new EmailAnalyzer("openai/gpt-5.2", undefined, "opencode"); + + await analyzer.analyze(makeEmail()); + + expect(requests).toHaveLength(1); + expect(requests[0].outputSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + needs_reply: { type: "boolean" }, + reason: { type: "string" }, + }, + required: ["needs_reply", "reason"], + additionalProperties: false, + }); }); test("analyze() returns correct AnalysisResult for a needs-reply email", async () => { diff --git a/tests/unit/llm-service.spec.ts b/tests/unit/llm-service.spec.ts index bfd47fc9..ed573bbf 100644 --- a/tests/unit/llm-service.spec.ts +++ b/tests/unit/llm-service.spec.ts @@ -11,14 +11,19 @@ import { test, expect } from "@playwright/test"; import { createRequire } from "module"; import type BetterSqlite3 from "better-sqlite3"; import Anthropic from "@anthropic-ai/sdk"; +import { z } from "zod"; import { createMessage, _setClientForTesting, + _setOllamaClientForTesting, + _setOpenCodeServiceForTesting, setAnthropicServiceDb, getUsageStats, getCallHistory, + recordAgentSessionStart, type LlmCallRecord, } from "../../src/main/services/llm-service"; +import type { OpenCodeInferenceRequest } from "../../src/main/services/opencode-inference-service"; const require = createRequire(import.meta.url); @@ -151,9 +156,242 @@ test.describe("AnthropicService", () => { test.afterEach(() => { _setClientForTesting(null); + _setOllamaClientForTesting(null); + _setOpenCodeServiceForTesting(); testDb?.close(); }); + test("routes one structured request through OpenCode and records its reported usage", async () => { + const anthropicMock = createMockClient("success"); + const ollamaMock = createMockClient("success"); + const requests: OpenCodeInferenceRequest[] = []; + _setClientForTesting(anthropicMock.client); + _setOllamaClientForTesting(ollamaMock.client); + _setOpenCodeServiceForTesting({ + complete: async (request) => { + requests.push(request); + return { + id: "assistant-1", + text: '{"needs_reply":true,"reason":"Direct question"}', + structured: { needs_reply: true, reason: "Direct question" }, + providerId: "openai", + modelId: "gpt-5.2", + finishReason: "stop", + inputTokens: 120, + outputTokens: 35, + cacheReadTokens: 10, + cacheWriteTokens: 4, + reasoningTokens: 7, + costDollars: 0.0123, + }; + }, + }); + const outputSchema = z.object({ + needs_reply: z.boolean(), + reason: z.string(), + }); + + const result = await createMessage( + { + model: "openai/gpt-5.2", + max_tokens: 256, + system: [ + { + type: "text", + text: "System one", + cache_control: { type: "ephemeral" }, + }, + { + type: "text", + text: "System two", + cache_control: { type: "ephemeral" }, + }, + ], + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "First", + cache_control: { type: "ephemeral" }, + }, + { type: "text", text: "Second" }, + ], + }, + { role: "assistant", content: "Prior reply" }, + ], + }, + { + caller: "test-opencode", + emailId: "email-123", + accountId: "acct-456", + provider: "opencode", + outputSchema, + }, + ); + + expect(requests).toEqual([ + { + selector: "openai/gpt-5.2", + system: "System one\n\nSystem two", + prompt: "USER:\nFirst\n\nSecond\n\nASSISTANT:\nPrior reply", + outputSchema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + needs_reply: { type: "boolean" }, + reason: { type: "string" }, + }, + required: ["needs_reply", "reason"], + additionalProperties: false, + }, + signal: undefined, + }, + ]); + expect(anthropicMock.calls).toHaveLength(0); + expect(ollamaMock.calls).toHaveLength(0); + expect(result.model).toBe("openai/gpt-5.2"); + expect(result.content).toEqual([ + { + type: "text", + text: '{"needs_reply":true,"reason":"Direct question"}', + citations: null, + }, + ]); + expect(result.usage).toEqual({ + input_tokens: 120, + output_tokens: 35, + cache_creation_input_tokens: 4, + cache_read_input_tokens: 10, + server_tool_use: null, + service_tier: null, + }); + + const row = testDb.prepare("SELECT * FROM llm_calls LIMIT 1").get() as LlmCallRecord; + expect(row.provider).toBe("opencode"); + expect(row.model).toBe("openai/gpt-5.2"); + expect(row.caller).toBe("test-opencode"); + expect(row.email_id).toBe("email-123"); + expect(row.account_id).toBe("acct-456"); + expect(row.input_tokens).toBe(120); + expect(row.output_tokens).toBe(35); + expect(row.cache_read_tokens).toBe(10); + expect(row.cache_create_tokens).toBe(4); + expect(row.cost_cents).toBeCloseTo(1.23, 8); + expect(row.success).toBe(1); + expect(row.error_message).toBeNull(); + expect(row.duration_ms).toBeGreaterThanOrEqual(0); + expect(row.usage_available).toBe(1); + expect(row.cost_available).toBe(1); + }); + + test("agent accounting flags distinguish missing usage and cost from observed zero", () => { + recordAgentSessionStart({ + harness: "opencode", + provider: "opencode", + model: "opencode-default", + durationMs: 15, + success: false, + errorMessage: "startup failed", + }); + + const row = testDb.prepare("SELECT * FROM llm_calls LIMIT 1").get() as LlmCallRecord; + expect(row.input_tokens).toBe(0); + expect(row.cost_cents).toBe(0); + expect(row.usage_available).toBe(0); + expect(row.cost_available).toBe(0); + }); + + test("records zero cost when OpenCode does not report cost", async () => { + _setOpenCodeServiceForTesting({ + complete: async () => ({ + id: "assistant-no-cost", + text: "Done", + providerId: "openai", + modelId: "gpt-5.2", + finishReason: "stop", + inputTokens: 120, + outputTokens: 35, + cacheReadTokens: 10, + cacheWriteTokens: 4, + reasoningTokens: 7, + }), + }); + + await createMessage(makeTestParams("openai/gpt-5.2"), { + caller: "test-opencode-no-cost", + provider: "opencode", + }); + + const row = testDb.prepare("SELECT * FROM llm_calls LIMIT 1").get() as LlmCallRecord; + expect(row.cost_cents).toBe(0); + expect(row.usage_available).toBe(1); + expect(row.cost_available).toBe(0); + }); + + test("records one failed OpenCode call and rethrows the service error", async () => { + const failure = new Error("OpenCode unavailable"); + let calls = 0; + _setOpenCodeServiceForTesting({ + complete: async () => { + calls += 1; + throw failure; + }, + }); + + let thrown: unknown; + try { + await createMessage(makeTestParams("openai/gpt-5.2"), { + caller: "test-opencode-error", + provider: "opencode", + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBe(failure); + expect(calls).toBe(1); + const rows = testDb.prepare("SELECT * FROM llm_calls").all() as LlmCallRecord[]; + expect(rows).toHaveLength(1); + expect(rows[0].provider).toBe("opencode"); + expect(rows[0].model).toBe("openai/gpt-5.2"); + expect(rows[0].success).toBe(0); + expect(rows[0].error_message).toBe("OpenCode unavailable"); + expect(rows[0].input_tokens).toBe(0); + expect(rows[0].output_tokens).toBe(0); + expect(rows[0].usage_available).toBe(0); + expect(rows[0].cost_available).toBe(0); + }); + + test("rejects OpenCode tools before inference", async () => { + let calls = 0; + _setOpenCodeServiceForTesting({ + complete: async () => { + calls += 1; + throw new Error("OpenCode should not be called"); + }, + }); + + await expect( + createMessage( + { + ...makeTestParams("openai/gpt-5.2"), + tools: [ + { + name: "lookup", + description: "Look something up", + input_schema: { type: "object", properties: {} }, + }, + ], + }, + { caller: "test-opencode-tools", provider: "opencode" }, + ), + ).rejects.toThrow("OpenCode feature inference does not support tools"); + + expect(calls).toBe(0); + }); + test("createMessage wraps SDK call and returns response", async () => { const { client } = createMockClient("success"); _setClientForTesting(client); @@ -186,6 +424,28 @@ test.describe("AnthropicService", () => { expect(calls.length).toBe(3); }); + test("marks usage and cost unavailable after exhausting retries", async () => { + const { client, calls } = createMockClient("server-error-then-success", 99); + _setClientForTesting(client); + const realSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => + realSetTimeout(handler, 0, ...args)) as typeof globalThis.setTimeout; + + try { + await expect( + createMessage(makeTestParams(), { caller: "test-exhausted-retries" }), + ).rejects.toThrow("Server error"); + } finally { + globalThis.setTimeout = realSetTimeout; + } + + expect(calls).toHaveLength(4); + const row = testDb.prepare("SELECT * FROM llm_calls LIMIT 1").get() as LlmCallRecord; + expect(row.success).toBe(0); + expect(row.usage_available).toBe(0); + expect(row.cost_available).toBe(0); + }); + test("does not retry on non-retryable API errors (fails immediately)", async () => { const { client, calls } = createMockClient("always-fail"); _setClientForTesting(client); @@ -284,6 +544,8 @@ test.describe("AnthropicService", () => { expect(row.error_message).toContain("Bad request"); expect(row.input_tokens).toBe(0); expect(row.output_tokens).toBe(0); + expect(row.usage_available).toBe(0); + expect(row.cost_available).toBe(0); }); test("getUsageStats returns correct aggregation", async () => { diff --git a/tests/unit/opencode-agent-usage.spec.ts b/tests/unit/opencode-agent-usage.spec.ts new file mode 100644 index 00000000..d62145e7 --- /dev/null +++ b/tests/unit/opencode-agent-usage.spec.ts @@ -0,0 +1,394 @@ +import { expect, test } from "@playwright/test"; +import type { AssistantMessage, Event } from "@opencode-ai/sdk"; + +import { + buildOpenCodeAgentConfig, + createOpenCodeRunUsageTracker, + OpenCodeAgentProvider, + requestedOpenCodeModelLabel, +} from "../../src/main/agents/providers/opencode/opencode-agent-provider"; +import type { + AgentContext, + AgentRunParams, + AgentSessionStartFn, +} from "../../src/main/agents/types"; +import { + recordAgentSessionStart, + setAnthropicServiceDb, +} from "../../src/main/services/llm-service"; + +type RecordedInsert = unknown[]; + +function useRecordingDb(): RecordedInsert[] { + const inserts: RecordedInsert[] = []; + setAnthropicServiceDb({ + exec: () => {}, + prepare: (sql: string) => ({ + run: (...args: unknown[]) => { + if (sql.includes("INSERT INTO llm_calls")) inserts.push(args); + }, + get: () => undefined, + all: () => [], + }), + transaction: (fn: () => T) => fn, + }); + return inserts; +} + +function assistantSnapshot( + id: string, + overrides: { + sessionID?: string; + created?: number; + completed?: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + }, +): AssistantMessage { + return { + id, + sessionID: overrides.sessionID ?? "session-1", + role: "assistant", + time: { + created: overrides.created ?? 100, + completed: overrides.completed ?? 200, + }, + parentID: "user-1", + modelID: "gpt-5.2", + providerID: "openai", + mode: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: overrides.cost, + tokens: { + input: overrides.input, + output: overrides.output, + reasoning: 0, + cache: { + read: overrides.cacheRead, + write: overrides.cacheWrite, + }, + }, + }; +} + +function messageUpdated(info: AssistantMessage): Event { + return { type: "message.updated", properties: { info } }; +} + +function baseRunParams(recordSessionStart: AgentSessionStartFn): AgentRunParams { + return { + taskId: "task-1", + prompt: "hello", + context: { accountId: "account-1" } as AgentContext, + tools: [], + toolExecutor: async () => undefined, + netFetch: async () => ({ status: 200, headers: {}, body: "" }), + recordSessionStart, + signal: new AbortController().signal, + }; +} + +async function exhaustRun( + provider: OpenCodeAgentProvider, + params: AgentRunParams, +): Promise<{ state: string; events: Array<{ type: string; message?: string }> }> { + const events: Array<{ type: string; message?: string }> = []; + const run = provider.run(params); + while (true) { + const next = await run.next(); + if (next.done) return { state: next.value.state, events }; + events.push(next.value); + } +} + +test("OpenCode agent config preserves the mail bridge and delegates providers to global config", () => { + expect(buildOpenCodeAgentConfig("http://127.0.0.1:4321/mcp")).toEqual({ + logLevel: "WARN", + mcp: { + "mail-app-tools": { + type: "remote", + url: "http://127.0.0.1:4321/mcp", + enabled: true, + }, + }, + permission: { edit: "allow", bash: "allow", webfetch: "allow" }, + }); +}); + +test("OpenCode availability needs no Exo-managed LLM credential", async () => { + const provider = new OpenCodeAgentProvider({ + model: "claude-sonnet-4-6", + opencode: { enabled: true }, + }); + + await expect(provider.isAvailable()).resolves.toBe(true); +}); + +test("latest assistant snapshots are deduplicated and successful usage is recorded once", () => { + const inserts = useRecordingDb(); + const tracker = createOpenCodeRunUsageTracker({ + sessionId: "session-1", + accountId: "account-1", + emailId: "email-1", + recordSessionStart: recordAgentSessionStart, + }); + + tracker.observe( + messageUpdated( + assistantSnapshot("assistant-1", { + input: 10, + output: 2, + cacheRead: 1, + cacheWrite: 0, + cost: 0.01, + }), + ), + ); + tracker.observe( + messageUpdated( + assistantSnapshot("assistant-1", { + input: 12, + output: 4, + cacheRead: 3, + cacheWrite: 1, + cost: 0.02, + }), + ), + ); + tracker.observe( + messageUpdated( + assistantSnapshot("assistant-2", { + created: 300, + input: 5, + output: 3, + cacheRead: 2, + cacheWrite: 4, + cost: 0.03, + }), + ), + ); + tracker.observe( + messageUpdated( + assistantSnapshot("other-session", { + sessionID: "session-2", + input: 999, + output: 999, + cacheRead: 999, + cacheWrite: 999, + cost: 99, + }), + ), + ); + + tracker.record({ durationMs: 125, success: true }); + tracker.record({ durationMs: 999, success: false, errorMessage: "duplicate" }); + + expect(inserts).toHaveLength(1); + expect(inserts[0].slice(1)).toEqual([ + "openai/gpt-5.2", + "agent-run:opencode", + "email-1", + "account-1", + 17, + 7, + 5, + 5, + 5, + 125, + 1, + null, + "opencode", + 1, + 1, + ]); +}); + +test("failed OpenCode runs include accumulated usage and the exact error", () => { + const inserts = useRecordingDb(); + const tracker = createOpenCodeRunUsageTracker({ + sessionId: "session-1", + recordSessionStart: recordAgentSessionStart, + }); + tracker.observe( + messageUpdated( + assistantSnapshot("assistant-1", { + input: 8, + output: 1, + cacheRead: 2, + cacheWrite: 3, + cost: 0.006, + }), + ), + ); + + tracker.record({ durationMs: 44, success: false, errorMessage: "quota exhausted" }); + + expect(inserts).toHaveLength(1); + expect(inserts[0].slice(1)).toEqual([ + "openai/gpt-5.2", + "agent-run:opencode", + null, + null, + 8, + 1, + 2, + 3, + 0.6, + 44, + 0, + "quota exhausted", + "opencode", + 1, + 1, + ]); +}); + +test("missing OpenCode usage and cost stay distinguishable from observed zero", () => { + const inserts = useRecordingDb(); + const tracker = createOpenCodeRunUsageTracker({ + requestedModel: "opencode-default", + recordSessionStart: recordAgentSessionStart, + }); + + tracker.record({ durationMs: 12, success: false, errorMessage: "startup failed" }); + tracker.record({ durationMs: 99, success: true }); + + expect(inserts).toHaveLength(1); + expect(inserts[0].slice(1)).toEqual([ + "opencode-default", + "agent-run:opencode", + null, + null, + 0, + 0, + 0, + 0, + 0, + 12, + 0, + "startup failed", + "opencode", + 0, + 0, + ]); +}); + +test("early OpenCode startup failure records exactly one terminal failure", async () => { + const provider = new OpenCodeAgentProvider({ + model: "claude-sonnet-4-6", + opencode: { enabled: true }, + }); + Object.defineProperty(provider, "ensureServer", { + value: async () => { + throw new Error("server unavailable"); + }, + }); + const records: Parameters[0][] = []; + + const result = await exhaustRun( + provider, + baseRunParams((record) => { + records.push(record); + }), + ); + + expect(result.state).toBe("failed"); + expect(records).toEqual([ + expect.objectContaining({ + model: "opencode-default", + success: false, + errorMessage: "Failed to start OpenCode server: server unavailable", + }), + ]); +}); + +test("event stream ending without session idle records a failure with partial usage", async () => { + const provider = new OpenCodeAgentProvider({ + model: "claude-sonnet-4-6", + opencode: { enabled: true }, + }); + const stream = (async function* (): AsyncGenerator { + yield messageUpdated( + assistantSnapshot("assistant-1", { + input: 8, + output: 2, + cacheRead: 1, + cacheWrite: 0, + cost: 0.004, + }), + ); + })(); + Object.defineProperty(provider, "ensureServer", { + value: async () => ({ + client: { + provider: { + list: async () => ({ + data: { + all: [ + { + id: "openai", + name: "OpenAI", + models: { "gpt-5.2": { id: "gpt-5.2", name: "GPT-5.2" } }, + }, + ], + connected: ["openai"], + default: {}, + }, + }), + }, + session: { + create: async () => ({ data: { id: "session-1" } }), + promptAsync: async () => ({ data: undefined }), + messages: async () => ({ data: [] }), + abort: async () => ({ data: true }), + }, + event: { + subscribe: async () => ({ stream }), + }, + }, + close: () => {}, + bridgeUrl: "http://127.0.0.1:4321/mcp", + }), + }); + const records: Parameters[0][] = []; + + const result = await exhaustRun(provider, { + ...baseRunParams((record) => { + records.push(record); + }), + modelOverride: "openai/gpt-5.2", + }); + + expect(result.state).toBe("failed"); + expect(result.events).toContainEqual( + expect.objectContaining({ + type: "error", + message: "OpenCode event stream ended before session completion", + }), + ); + expect(records).toEqual([ + expect.objectContaining({ + model: "openai/gpt-5.2", + inputTokens: 8, + outputTokens: 2, + success: false, + errorMessage: "OpenCode event stream ended before session completion", + }), + ]); +}); + +test("early route labels use exact requests and never invent a default model", () => { + const config = { + model: "claude-sonnet-4-6", + opencode: { enabled: true, model: "anthropic/claude-sonnet-4-5" }, + }; + + expect(requestedOpenCodeModelLabel(config, "openai/gpt-5.2")).toBe("openai/gpt-5.2"); + expect(requestedOpenCodeModelLabel(config, "bare-model")).toBe("opencode-default"); + expect(requestedOpenCodeModelLabel({ ...config, opencode: { enabled: true } }, " ")).toBe( + "opencode-default", + ); +}); diff --git a/tests/unit/opencode-binary-resolution.spec.ts b/tests/unit/opencode-binary-resolution.spec.ts new file mode 100644 index 00000000..c6f3d01f --- /dev/null +++ b/tests/unit/opencode-binary-resolution.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; + +import { resolveOpencodePlatformBinary } from "../../src/main/agents/providers/opencode/opencode-agent-provider"; + +test.describe("resolveOpencodePlatformBinary", () => { + test("finds the unpacked Darwin ARM64 executable from its platform package", () => { + const packagedPackageJson = + "/Applications/Exo.app/Contents/Resources/app.asar/node_modules/opencode-darwin-arm64/package.json"; + const packagedExecutable = + "/Applications/Exo.app/Contents/Resources/app.asar.unpacked/node_modules/opencode-darwin-arm64/bin/opencode"; + const requestedPackages: string[] = []; + + const resolved = resolveOpencodePlatformBinary({ + platform: "darwin", + arch: "arm64", + resourcesPath: "", + resolvePackageJson: (specifier) => { + requestedPackages.push(specifier); + return packagedPackageJson; + }, + fileExists: (candidate) => candidate === packagedExecutable, + }); + + expect(requestedPackages).toEqual(["opencode-darwin-arm64/package.json"]); + expect(resolved).toBe(packagedExecutable); + }); + + test("uses the Windows package and executable names", () => { + const packagedPackageJson = + "C:\\Program Files\\Exo\\resources\\app.asar\\node_modules\\opencode-windows-x64-baseline\\package.json"; + const packagedExecutable = + "C:\\Program Files\\Exo\\resources\\app.asar.unpacked\\node_modules\\opencode-windows-x64-baseline\\bin\\opencode.exe"; + const requestedPackages: string[] = []; + + const resolved = resolveOpencodePlatformBinary({ + platform: "win32", + arch: "x64", + resourcesPath: "", + resolvePackageJson: (specifier) => { + requestedPackages.push(specifier); + return packagedPackageJson; + }, + fileExists: (candidate) => candidate === packagedExecutable, + }); + + expect(requestedPackages).toEqual(["opencode-windows-x64-baseline/package.json"]); + expect(resolved).toBe(packagedExecutable); + }); + + test("resolves packaged workers directly from Electron's resources path", () => { + const packagedExecutable = + "/Applications/Exo.app/Contents/Resources/app.asar.unpacked/node_modules/opencode-darwin-arm64/bin/opencode"; + + const resolved = resolveOpencodePlatformBinary({ + platform: "darwin", + arch: "arm64", + resourcesPath: "/Applications/Exo.app/Contents/Resources", + resolvePackageJson: () => { + throw new Error("packaged resolution must not require Node module lookup"); + }, + fileExists: (candidate) => candidate === packagedExecutable, + }); + + expect(resolved).toBe(packagedExecutable); + }); +}); diff --git a/tests/unit/opencode-config.spec.ts b/tests/unit/opencode-config.spec.ts new file mode 100644 index 00000000..f864d794 --- /dev/null +++ b/tests/unit/opencode-config.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + ConfigSchema, + LlmProviderSchema, + parseOpenCodeModelSelector, + resolveOpenCodeRoute, + type OpenCodeModelOption, +} from "../../src/shared/types"; + +const models: OpenCodeModelOption[] = [ + { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }, + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.2", + modelName: "GPT-5.2", + }, +]; + +test("preload exposes the OpenCode model catalog through the typed settings channel", () => { + const preload = readFileSync(resolve(import.meta.dirname, "../../src/preload/index.ts"), "utf8"); + const types = readFileSync(resolve(import.meta.dirname, "../../src/shared/types.ts"), "utf8"); + + expect(preload).toContain( + 'listOpenCodeModels: (): Promise => ipcRenderer.invoke("settings:list-opencode-models"),', + ); + expect(types).toContain('"settings:list-opencode-models": void'); +}); + +test("opencode is a valid LLM provider", () => { + expect(LlmProviderSchema.parse("opencode")).toBe("opencode"); +}); + +test("ConfigSchema preserves legacy and per-feature OpenCode models", () => { + const cfg = ConfigSchema.parse({ + opencode: { + enabled: true, + model: "anthropic/claude-sonnet-4-5", + featureModels: { + analysis: "openai/gpt-5.2", + drafts: "anthropic/claude-sonnet-4-5", + }, + }, + }); + expect(cfg.opencode?.featureModels?.analysis).toBe("openai/gpt-5.2"); + expect(cfg.opencode?.model).toBe("anthropic/claude-sonnet-4-5"); +}); + +test("exact selectors split on the first slash", () => { + expect(parseOpenCodeModelSelector("openrouter/openai/gpt-5.2")).toEqual({ + providerID: "openrouter", + modelID: "openai/gpt-5.2", + }); +}); + +test("blank selector delegates to the OpenCode default", () => { + expect(resolveOpenCodeRoute("", models)).toBeUndefined(); +}); + +test("legacy bare selector resolves only when unique", () => { + expect(resolveOpenCodeRoute("gpt-5.2", models)).toEqual({ + providerID: "openai", + modelID: "gpt-5.2", + }); + expect(() => + resolveOpenCodeRoute("same-id", [ + { ...models[0], modelId: "same-id" }, + { ...models[1], modelId: "same-id" }, + ]), + ).toThrow(/ambiguous/i); +}); + +test("missing legacy selector fails visibly", () => { + expect(() => resolveOpenCodeRoute("removed-model", models)).toThrow(/not available/i); +}); + +test("missing exact selector fails visibly", () => { + expect(() => resolveOpenCodeRoute("openai/removed-model", models)).toThrow(/not available/i); +}); diff --git a/tests/unit/opencode-inference-service.spec.ts b/tests/unit/opencode-inference-service.spec.ts new file mode 100644 index 00000000..d388b1ea --- /dev/null +++ b/tests/unit/opencode-inference-service.spec.ts @@ -0,0 +1,421 @@ +import { expect, test } from "@playwright/test"; + +import { + OpenCodeInferenceService, + type OpenCodeLauncher, +} from "../../src/main/services/opencode-inference-service"; + +type PromptInput = { + sessionID: string; + model?: { providerID: string; modelID: string }; + system?: string; + tools: Record; + format: { type: "text" } | { type: "json_schema"; schema: Record }; + parts: Array<{ type: "text"; text: string }>; +}; + +type CreateInput = { + title: string; + permission: Array<{ + permission: string; + pattern: string; + action: "deny"; + }>; +}; + +type FakeOptions = { + providerFailure?: boolean; + toolFailure?: boolean; + promptFailure?: Error; + assistantError?: { + name: "ProviderAuthError"; + data: { + providerID: string; + message: string; + }; + }; + deleteFailure?: Error; + deleteResponseFailure?: boolean; + structured?: unknown; + waitForAbort?: boolean; + startup?: Promise; + startupFailures?: number; +}; + +function deferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve = (): void => { + throw new Error("deferred promise was not initialized"); + }; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function createFake(options: FakeOptions = {}) { + const calls = { + launches: 0, + closes: 0, + providerLists: 0, + toolLists: 0, + creates: [] as CreateInput[], + prompts: [] as PromptInput[], + deletes: [] as string[], + }; + + const response = { + info: { + id: "assistant-1", + role: "assistant" as const, + providerID: "openai", + modelID: "gpt-5.2", + cost: 0.0123, + tokens: { + input: 120, + output: 35, + reasoning: 7, + cache: { read: 10, write: 4 }, + }, + finish: "stop", + ...(options.assistantError === undefined ? {} : { error: options.assistantError }), + ...(options.structured === undefined ? {} : { structured: options.structured }), + }, + parts: [{ type: "text" as const, text: "hello" }], + }; + + const client = { + provider: { + list: async () => { + calls.providerLists += 1; + if (options.providerFailure) return { error: new Error("provider failure") }; + return { + data: { + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.2": { id: "gpt-5.2", name: "GPT 5.2" }, + "gpt-5.1": { id: "gpt-5.1", name: "GPT 5.1" }, + }, + }, + { + id: "anthropic", + name: "Anthropic", + models: { + "claude-sonnet": { id: "claude-sonnet", name: "Claude Sonnet" }, + }, + }, + { + id: "ollama", + name: "Ollama", + models: { + "local-only": { id: "local-only", name: "Local Only" }, + }, + }, + ], + connected: ["openai", "anthropic"], + default: {}, + }, + }; + }, + }, + tool: { + ids: async () => { + calls.toolLists += 1; + return options.toolFailure + ? { error: new Error("tool failure") } + : { data: ["bash", "read", "write"] }; + }, + }, + session: { + create: async (input: CreateInput) => { + calls.creates.push(input); + return { data: { id: `session-${calls.creates.length}` } }; + }, + prompt: async (input: PromptInput, requestOptions?: { signal?: AbortSignal }) => { + calls.prompts.push(input); + if (options.promptFailure) throw options.promptFailure; + if (options.waitForAbort) { + await new Promise((_resolve, reject) => { + const signal = requestOptions?.signal; + const abort = () => reject(signal?.reason ?? new Error("aborted")); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + } + return { data: response }; + }, + delete: async ({ sessionID }: { sessionID: string }) => { + calls.deletes.push(sessionID); + if (options.deleteFailure) throw options.deleteFailure; + if (options.deleteResponseFailure) { + return { error: new Error("cleanup response failure") }; + } + return { data: true }; + }, + }, + }; + + const launcher = (async () => { + calls.launches += 1; + if (calls.launches <= (options.startupFailures ?? 0)) { + throw new Error("startup failed"); + } + await options.startup; + return { + client, + close: () => { + calls.closes += 1; + }, + }; + }) as unknown as OpenCodeLauncher; + + return { + calls, + service: new OpenCodeInferenceService(launcher), + }; +} + +test("simultaneous first calls share one lazy launch", async () => { + const startup = deferred(); + const { calls, service } = createFake({ startup: startup.promise }); + + const first = service.listModels(); + const second = service.listModels(); + await expect.poll(() => calls.launches).toBe(1); + startup.resolve(); + + await Promise.all([first, second]); + expect(calls.launches).toBe(1); +}); + +test("close during startup closes and rejects the stale handle", async () => { + const startup = deferred(); + const { calls, service } = createFake({ startup: startup.promise }); + + const stale = service.listModels(); + await expect.poll(() => calls.launches).toBe(1); + service.close(); + startup.resolve(); + + await expect(stale).rejects.toThrow(/changed during startup/); + expect(calls.closes).toBe(1); + await service.listModels(); + expect(calls.launches).toBe(2); +}); + +test("close aborts a launcher that has not finished starting", async () => { + let startupSignal: AbortSignal | undefined; + const launcher = ((signal?: AbortSignal) => { + startupSignal = signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener( + "abort", + () => reject(new Error("OpenCode server startup aborted")), + { once: true }, + ); + }); + }) as OpenCodeLauncher; + const service = new OpenCodeInferenceService(launcher); + + const startup = service.listModels(); + expect(startupSignal).toBeDefined(); + service.close(); + + expect(startupSignal?.aborted).toBe(true); + await expect(startup).rejects.toThrow("OpenCode server startup aborted"); +}); + +test("startup failure clears the shared promise so the next call retries", async () => { + const { calls, service } = createFake({ startupFailures: 1 }); + + await expect(service.listModels()).rejects.toThrow("startup failed"); + await service.listModels(); + + expect(calls.launches).toBe(2); +}); + +test("listModels returns only connected providers sorted by provider and model name", async () => { + const { service } = createFake(); + + await expect(service.listModels()).resolves.toEqual([ + { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-sonnet", + modelName: "Claude Sonnet", + }, + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.1", + modelName: "GPT 5.1", + }, + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.2", + modelName: "GPT 5.2", + }, + ]); +}); + +test("provider catalog failure is visible instead of becoming an empty catalog", async () => { + const { service } = createFake({ providerFailure: true }); + + await expect(service.listModels()).rejects.toThrow(/provider catalog/); +}); + +test("exact and unique bare selectors route through the shared catalog resolver", async () => { + const { calls, service } = createFake(); + + await service.complete({ selector: "openai/gpt-5.2", prompt: "exact" }); + await service.complete({ selector: "claude-sonnet", prompt: "bare" }); + + expect(calls.prompts.map((prompt) => prompt.model)).toEqual([ + { providerID: "openai", modelID: "gpt-5.2" }, + { providerID: "anthropic", modelID: "claude-sonnet" }, + ]); +}); + +test("missing selectors fail visibly without cross-fallback or prompting", async () => { + const { calls, service } = createFake(); + + await expect( + service.complete({ selector: "missing-model", prompt: "must not run" }), + ).rejects.toThrow('OpenCode model "missing-model" is not available'); + expect(calls.toolLists).toBe(0); + expect(calls.creates).toHaveLength(0); + expect(calls.prompts).toHaveLength(0); +}); + +test("completion disables every discovered tool and denies every permission", async () => { + const { calls, service } = createFake(); + + await service.complete({ prompt: "hello" }); + + expect(calls.creates).toHaveLength(1); + expect(calls.creates[0].permission).toEqual([{ permission: "*", pattern: "*", action: "deny" }]); + expect(calls.prompts[0].tools).toEqual({ + bash: false, + read: false, + write: false, + }); + expect(calls.prompts[0].model).toBeUndefined(); +}); + +test("tool catalog failure fails closed before session creation or prompting", async () => { + const { calls, service } = createFake({ toolFailure: true }); + + await expect(service.complete({ prompt: "must not run" })).rejects.toThrow( + "OpenCode tool catalog could not be loaded", + ); + expect(calls.creates).toHaveLength(0); + expect(calls.prompts).toHaveLength(0); +}); + +test("JSON schema output and complete response accounting are preserved", async () => { + const schema = { + type: "object", + properties: { answer: { type: "string" } }, + required: ["answer"], + }; + const structured = { answer: "hello" }; + const { calls, service } = createFake({ structured }); + + await expect( + service.complete({ + selector: "openai/gpt-5.2", + system: "Return JSON", + prompt: "hello", + outputSchema: schema, + }), + ).resolves.toEqual({ + id: "assistant-1", + text: "hello", + structured, + providerId: "openai", + modelId: "gpt-5.2", + finishReason: "stop", + inputTokens: 120, + outputTokens: 35, + cacheReadTokens: 10, + cacheWriteTokens: 4, + reasoningTokens: 7, + costDollars: 0.0123, + }); + expect(calls.prompts[0]).toMatchObject({ + system: "Return JSON", + format: { type: "json_schema", schema }, + parts: [{ type: "text", text: "hello" }], + }); + expect(calls.deletes).toEqual(["session-1"]); +}); + +test("prompt failure still deletes the created session", async () => { + const { calls, service } = createFake({ promptFailure: new Error("prompt failed") }); + + await expect(service.complete({ prompt: "hello" })).rejects.toThrow("prompt failed"); + expect(calls.deletes).toEqual(["session-1"]); +}); + +test("abort still deletes the created session", async () => { + const controller = new AbortController(); + const { calls, service } = createFake({ waitForAbort: true }); + + const completion = service.complete({ prompt: "hello", signal: controller.signal }); + await expect.poll(() => calls.prompts.length).toBe(1); + controller.abort(new Error("cancelled")); + + await expect(completion).rejects.toThrow("cancelled"); + expect(calls.deletes).toEqual(["session-1"]); +}); + +test("cleanup failure does not replace the original prompt failure", async () => { + const { service } = createFake({ + promptFailure: new Error("original prompt failure"), + deleteFailure: new Error("cleanup failure"), + }); + + await expect(service.complete({ prompt: "hello" })).rejects.toThrow("original prompt failure"); +}); + +test("cleanup failure does not replace an assistant message error", async () => { + const assistantError = { + name: "ProviderAuthError" as const, + data: { + providerID: "openai", + message: "Missing credentials", + }, + }; + const { calls, service } = createFake({ + assistantError, + deleteFailure: new Error("cleanup failure"), + }); + + await expect(service.complete({ prompt: "hello" })).rejects.toEqual(assistantError); + expect(calls.deletes).toEqual(["session-1"]); +}); + +test("cleanup response failure is visible after a successful prompt", async () => { + const { service } = createFake({ deleteResponseFailure: true }); + + await expect(service.complete({ prompt: "hello" })).rejects.toThrow( + "OpenCode session.delete failed", + ); +}); + +test("close shuts down the current server and forces the next call to relaunch", async () => { + const { calls, service } = createFake(); + + await service.listModels(); + service.close(); + await service.listModels(); + + expect(calls.closes).toBe(1); + expect(calls.launches).toBe(2); +}); diff --git a/tests/unit/opencode-resolve-route.spec.ts b/tests/unit/opencode-resolve-route.spec.ts index 1926ac00..ffc16c4c 100644 --- a/tests/unit/opencode-resolve-route.spec.ts +++ b/tests/unit/opencode-resolve-route.spec.ts @@ -1,134 +1,87 @@ -/** - * Unit tests for OpenCodeAgentProvider's route resolution. - * - * Greptile flagged a priority inversion in an earlier version of resolveRoute() - * where a bare-name runtimeOverride lost to a parsed settings override because - * the implementation collapsed both inputs into a single ?? chain on the parsed - * form first. These tests lock in the intended priority: - * - * runtime override > settings.opencode.model > framework default - * - * Each tier accepts either `provider/model` (parsed) or bare `model` (paired - * with the active provider), and the priority is preserved regardless of which - * form each tier supplies. - */ -import { test, expect } from "@playwright/test"; +import { expect, test } from "@playwright/test"; + import { resolveRoute } from "../../src/main/agents/providers/opencode/opencode-agent-provider"; import type { AgentFrameworkConfig } from "../../src/main/agents/types"; +import type { OpenCodeModelOption } from "../../src/shared/types"; -const baseOllama: AgentFrameworkConfig = { - model: "claude-sonnet-4-6", - ollamaCloud: { enabled: true, apiKey: "ollama-key", model: "kimi-k2.6:cloud" }, - opencode: { enabled: true }, -}; +const models: OpenCodeModelOption[] = [ + { + providerId: "anthropic", + providerName: "Anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }, + { + providerId: "openai", + providerName: "OpenAI", + modelId: "gpt-5.2", + modelName: "GPT-5.2", + }, +]; -const baseAnthropic: AgentFrameworkConfig = { +const baseConfig: AgentFrameworkConfig = { model: "claude-sonnet-4-6", - anthropicApiKey: "sk-anthropic", opencode: { enabled: true }, }; -test.describe("resolveRoute - priority order", () => { - test("runtime parsed override wins over settings parsed", () => { - const cfg: AgentFrameworkConfig = { - ...baseOllama, - opencode: { enabled: true, model: "ollama-cloud/settings-model" }, - }; - expect(resolveRoute(cfg, "ollama-cloud/runtime-model")).toEqual({ - providerID: "ollama-cloud", - modelID: "runtime-model", - }); - }); +test("an exact runtime selector wins over the legacy OpenCode model", () => { + const config = { + ...baseConfig, + opencode: { enabled: true, model: "anthropic/claude-sonnet-4-5" }, + }; - test("runtime bare override wins over settings parsed (the inversion bug)", () => { - // This is the exact regression Greptile caught: bare runtime input, - // parsed settings input — runtime must still win. - const cfg: AgentFrameworkConfig = { - ...baseOllama, - opencode: { enabled: true, model: "ollama-cloud/settings-model" }, - }; - expect(resolveRoute(cfg, "qwen3:32b")).toEqual({ - providerID: "ollama-cloud", - modelID: "qwen3:32b", - }); + expect(resolveRoute(config, "openai/gpt-5.2", models)).toEqual({ + providerID: "openai", + modelID: "gpt-5.2", }); +}); - test("settings parsed override wins over framework default", () => { - const cfg: AgentFrameworkConfig = { - ...baseOllama, - opencode: { enabled: true, model: "ollama-cloud/settings-only" }, - }; - expect(resolveRoute(cfg, undefined)).toEqual({ - providerID: "ollama-cloud", - modelID: "settings-only", - }); - }); +test("a unique bare runtime selector wins over an exact legacy model", () => { + const config = { + ...baseConfig, + opencode: { enabled: true, model: "anthropic/claude-sonnet-4-5" }, + }; - test("settings bare override wins over framework default", () => { - const cfg: AgentFrameworkConfig = { - ...baseOllama, - opencode: { enabled: true, model: "bare-from-settings" }, - }; - expect(resolveRoute(cfg, undefined)).toEqual({ - providerID: "ollama-cloud", - modelID: "bare-from-settings", - }); + expect(resolveRoute(config, "gpt-5.2", models)).toEqual({ + providerID: "openai", + modelID: "gpt-5.2", }); +}); - test("falls through to Ollama default when neither override is set", () => { - expect(resolveRoute(baseOllama, undefined)).toEqual({ - providerID: "ollama-cloud", - modelID: "kimi-k2.6:cloud", - }); - }); +test("the legacy OpenCode model is used when no runtime selector is supplied", () => { + const config = { + ...baseConfig, + opencode: { enabled: true, model: "anthropic/claude-sonnet-4-5" }, + }; - test("falls through to Anthropic default when Ollama is not configured", () => { - expect(resolveRoute(baseAnthropic, undefined)).toEqual({ - providerID: "anthropic", - modelID: "claude-sonnet-4-6", - }); + expect(resolveRoute(config, undefined, models)).toEqual({ + providerID: "anthropic", + modelID: "claude-sonnet-4-5", }); +}); - test("returns undefined when no provider is configured", () => { - const cfg: AgentFrameworkConfig = { - model: "claude-sonnet-4-6", - opencode: { enabled: true }, - }; - expect(resolveRoute(cfg, undefined)).toBeUndefined(); - }); +test("blank selection omits the route so OpenCode chooses its default", () => { + expect(resolveRoute(baseConfig, undefined, models)).toBeUndefined(); }); -test.describe("resolveRoute - input shape parsing", () => { - test("ignores empty/whitespace runtime overrides", () => { - // Whitespace-only override should fall through to settings or defaults - // instead of being treated as a real override. - const cfg: AgentFrameworkConfig = { - ...baseOllama, - opencode: { enabled: true, model: "ollama-cloud/from-settings" }, - }; - expect(resolveRoute(cfg, " ")).toEqual({ - providerID: "ollama-cloud", - modelID: "from-settings", - }); - }); +test("a unique legacy bare selector resolves through the connected catalog", () => { + const config = { + ...baseConfig, + opencode: { enabled: true, model: "gpt-5.2" }, + }; - test("parsed runtime override uses its providerID even if it differs from active", () => { - // A user pinning "anthropic/claude-haiku-4-5" while Ollama is the active - // framework provider should get the anthropic route — the override is - // explicit, so we trust it. - expect(resolveRoute(baseOllama, "anthropic/claude-haiku-4-5")).toEqual({ - providerID: "anthropic", - modelID: "claude-haiku-4-5", - }); + expect(resolveRoute(config, undefined, models)).toEqual({ + providerID: "openai", + modelID: "gpt-5.2", }); +}); - test("bare-name override with no active provider returns undefined for that override", () => { - // If neither Ollama nor Anthropic is configured, a bare-name override - // can't be paired with anything. Falls through to undefined. - const cfg: AgentFrameworkConfig = { - model: "claude-sonnet-4-6", - opencode: { enabled: true }, - }; - expect(resolveRoute(cfg, "qwen3:32b")).toBeUndefined(); - }); +test("an ambiguous legacy bare selector fails visibly", () => { + const config = { + ...baseConfig, + opencode: { enabled: true, model: "shared-model" }, + }; + const ambiguous = models.map((model) => ({ ...model, modelId: "shared-model" })); + + expect(() => resolveRoute(config, undefined, ambiguous)).toThrow(/ambiguous/i); }); diff --git a/tests/unit/opencode-server.spec.ts b/tests/unit/opencode-server.spec.ts new file mode 100644 index 00000000..3f6eb975 --- /dev/null +++ b/tests/unit/opencode-server.spec.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { expect, test } from "@playwright/test"; + +import { + buildOpenCodeChildEnv, + launchOpenCodeServer, +} from "../../src/main/services/opencode-server"; + +function createFakeChild({ + killError, + killResult = killError === undefined, +}: { + killError?: Error; + killResult?: boolean; +} = {}) { + const child = new EventEmitter() as ChildProcess; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let killCount = 0; + + Object.defineProperties(child, { + stdout: { value: stdout }, + stderr: { value: stderr }, + exitCode: { value: null, writable: true }, + signalCode: { value: null, writable: true }, + pid: { value: undefined }, + }); + child.kill = () => { + killCount += 1; + if (killError) { + queueMicrotask(() => child.emit("error", killError)); + return false; + } + return killResult; + }; + + return { child, stdout, stderr, getKillCount: () => killCount }; +} + +function launchWithFakeChild(fake: ReturnType, timeout = 100) { + return launchOpenCodeServer({ + binaryPath: "/fake/opencode", + timeout, + spawnProcess: () => fake.child, + }); +} + +test("OpenCode child env removes Exo's Anthropic key without mutating or dropping safe env", () => { + const parentEnv = { + PATH: "/usr/local/bin:/usr/bin", + HOME: "/Users/test", + EXO_SAFE_SETTING: "kept", + ANTHROPIC_API_KEY: "exo-stored-secret", + }; + const config = { logLevel: "WARN", mcp: { mail: { enabled: true } } }; + + const childEnv = buildOpenCodeChildEnv(parentEnv, config); + + expect(childEnv).toEqual({ + PATH: "/usr/local/bin:/usr/bin", + HOME: "/Users/test", + EXO_SAFE_SETTING: "kept", + OPENCODE_CONFIG_CONTENT: JSON.stringify(config), + }); + expect(parentEnv.ANTHROPIC_API_KEY).toBe("exo-stored-secret"); +}); + +test("parses fragmented stdout even when stderr is interleaved", async () => { + const fake = createFakeChild(); + const launch = launchWithFakeChild(fake); + + fake.stdout.write("opencode server lis"); + fake.stderr.write("diagnostic warning\n"); + fake.stdout.write("tening on http://127.0.0.1:4321\n"); + + const handle = await launch; + expect(handle.url).toBe("http://127.0.0.1:4321"); + handle.close(); +}); + +test("stops the child when startup times out", async () => { + const fake = createFakeChild(); + + await expect(launchWithFakeChild(fake, 5)).rejects.toThrow( + "Timeout waiting for server to start after 5ms", + ); + expect(fake.getKillCount()).toBe(1); + expect(fake.stdout.listenerCount("data")).toBe(0); + expect(fake.stderr.listenerCount("data")).toBe(0); + expect(fake.child.listenerCount("error")).toBe(0); + expect(fake.child.listenerCount("exit")).toBe(0); +}); + +test("stops the child when startup is aborted", async () => { + const fake = createFakeChild(); + const controller = new AbortController(); + const launch = launchOpenCodeServer({ + binaryPath: "/fake/opencode", + timeout: 100, + signal: controller.signal, + spawnProcess: () => fake.child, + }); + + controller.abort(); + + await expect(launch).rejects.toThrow("OpenCode server startup aborted"); + expect(fake.getKillCount()).toBe(1); + expect(fake.stdout.listenerCount("data")).toBe(0); + expect(fake.stderr.listenerCount("data")).toBe(0); + expect(fake.child.listenerCount("error")).toBe(0); + expect(fake.child.listenerCount("exit")).toBe(0); +}); + +test("timeout cleanup survives an error emitted by kill", async () => { + const fake = createFakeChild({ killError: new Error("kill denied") }); + + await expect(launchWithFakeChild(fake, 5)).rejects.toThrow( + "Timeout waiting for server to start after 5ms", + ); + expect(fake.getKillCount()).toBe(1); + expect(fake.child.listenerCount("error")).toBe(0); +}); + +test("reports exit-before-ready with separate stdout and stderr diagnostics", async () => { + const fake = createFakeChild(); + const launch = launchWithFakeChild(fake); + fake.stdout.write("starting server\n"); + fake.stderr.write("address already in use\n"); + fake.child.exitCode = 23; + fake.child.emit("exit", 23, null); + + await expect(launch).rejects.toThrow( + /Server exited with code 23[\s\S]*starting server[\s\S]*address already in use/, + ); +}); + +test("close is idempotent", async () => { + const fake = createFakeChild(); + const launch = launchWithFakeChild(fake); + fake.stdout.write("opencode server listening on http://127.0.0.1:4321\n"); + const handle = await launch; + + handle.close(); + handle.close(); + + expect(fake.getKillCount()).toBe(1); +}); + +test("close remains idempotent when kill emits an error after startup", async () => { + const fake = createFakeChild({ killError: new Error("kill denied") }); + const launch = launchWithFakeChild(fake); + fake.stdout.write("opencode server listening on http://127.0.0.1:4321\n"); + const handle = await launch; + + expect(() => handle.close()).not.toThrow(); + expect(() => handle.close()).not.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(fake.getKillCount()).toBe(1); + expect(fake.child.listenerCount("error")).toBe(0); +}); + +test("close releases its kill error guard when kill returns false without an error", async () => { + const fake = createFakeChild({ killResult: false }); + const launch = launchWithFakeChild(fake); + fake.stdout.write("opencode server listening on http://127.0.0.1:4321\n"); + const handle = await launch; + + handle.close(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(fake.getKillCount()).toBe(1); + expect(fake.child.listenerCount("error")).toBe(0); +}); diff --git a/tests/unit/sender-lookup.spec.ts b/tests/unit/sender-lookup.spec.ts index db9f163f..1c331a78 100644 --- a/tests/unit/sender-lookup.spec.ts +++ b/tests/unit/sender-lookup.spec.ts @@ -3,9 +3,23 @@ * * The actual provider lives in src/extensions/mail-ext-web-search/src/web-search-provider.ts * and imports Anthropic SDK + extension types that transitively depend on electron. - * We re-implement the pure helper functions here and test the logic directly. + * We re-implement the pure helper functions here and exercise provider routing directly. */ import { test, expect } from "@playwright/test"; +import { createRequire } from "node:module"; +import type { ExtensionContext } from "../../src/shared/extension-types"; +import type { DashboardEmail } from "../../src/shared/types"; +import { _setClientForTesting } from "../../src/main/services/llm-service"; +import { + MockAnthropic, + getCapturedRequests, + mockAnthropicResponse, + resetAnthropicMock, +} from "../mocks/anthropic-api-mock"; + +const require = createRequire(import.meta.url); +const webSearchProviderModule: typeof import("../../src/extensions/mail-ext-web-search/src/web-search-provider") = require("../../src/extensions/mail-ext-web-search/src/web-search-provider.ts"); +const { createWebSearchProvider } = webSearchProviderModule; // ============================================================================= // Re-implemented pure functions from web-search-provider.ts @@ -550,3 +564,72 @@ test.describe("cache key computation", () => { expect(SEVEN_DAYS_MS).toBe(604_800_000); }); }); + +test.describe("sender lookup provider routing", () => { + test.beforeEach(() => { + resetAnthropicMock(); + _setClientForTesting(new MockAnthropic()); + }); + + test.afterEach(() => { + _setClientForTesting(null as unknown); + }); + + test("skips Exa without a key when OpenCode is the parsing provider", async () => { + mockAnthropicResponse({ + text: '{"name":"Alice","summary":"Anthropic fallback should not run"}', + }); + const errors: string[] = []; + const context: ExtensionContext = { + extensionId: "web-search", + extensionPath: "/test/web-search", + storage: { + get: async () => null, + set: async () => {}, + delete: async () => {}, + }, + secrets: { + get: async () => null, + set: async () => {}, + delete: async () => {}, + }, + logger: { + info: () => {}, + warn: () => {}, + error: (message) => errors.push(message), + debug: () => {}, + }, + }; + const provider = createWebSearchProvider(context, { + getModelId: () => "claude-sonnet-4-20250514", + getSearchConfig: () => ({ + provider: "exa", + exaApiKey: "", + anthropicConfigured: true, + }), + getParsingModelConfig: () => ({ + provider: "opencode", + model: "openai/gpt-5.2", + }), + }); + const email: DashboardEmail = { + id: "msg-1", + threadId: "thread-1", + subject: "Hello", + from: "Alice ", + to: "user@example.com", + date: "2026-07-29T12:00:00Z", + body: "Hello", + snippet: "Hello", + labelIds: ["INBOX"], + }; + + const result = await provider.enrich(email, [email]); + + expect(result).toBeNull(); + expect(getCapturedRequests()).toHaveLength(0); + expect(errors).toContain( + "Sender Lookup requires an Exa API key when its parsing model is not Anthropic", + ); + }); +});