Skip to content

feat: Cursor/OpenCode first-class provider support - #21

Merged
ranvier2d2 merged 4 commits into
mainfrom
feat/harness-provider-discovery
Mar 27, 2026
Merged

ranvier2d2 merged 4 commits into
mainfrom
feat/harness-provider-discovery

Conversation

@ranvier2d2

@ranvier2d2 ranvier2d2 commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Harness model discovery wired into ProviderRegistry — Cursor (83 models) and OpenCode (19 models) auto-detected via Elixir harness provider.listModels RPC
  • Settings page shows all 4 providers as first-class cards with enable/disable, model list, custom model management
  • Chat picker merges custom models from settings so harness-routed providers always have selectable models
  • Playwright config port default aligned to dev harness (5734)

Test plan

  • pixi run dev — verify all 4 providers appear in settings page with green status and model counts
  • Open provider picker in chat composer — Cursor and OpenCode submenus show discovered models
  • Add custom model via settings for Cursor — verify it appears in picker
  • Disable OpenCode via toggle — verify it shows "Disabled" status
  • Run bun run scripts/council.ts "test" — all 4 providers respond

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Discover and surface models from harness-connected providers (e.g., cursor, opencode).
    • Custom models now appear in model selection UI alongside discovered models.
    • Support for codex "plan delta" events as proposed-turn deltas in the chat flow.
  • Improvements

    • Automatic polling keeps provider model lists up to date.
    • Settings UI shows harness-routed providers with clearer connection/status messaging.
    • Provider/session binding and provider-preference resolution refined.
  • Tests

    • Stabilized tests by ensuring a consistent localStorage stub for store initialization.

…scovery, settings, picker

Wire harness model discovery into the provider registry so Cursor and
OpenCode models are automatically detected via the Elixir harness RPC
(provider.listModels) and pushed to the frontend via providersUpdated.

Changes:
- ProviderRegistry queries harness for Cursor/OpenCode model lists on
  startup and every 60s, merging with custom models from settings
- HarnessClientAdapter exposes listProviderModels() to the registry
- Settings page shows all 4 providers as first-class cards with
  enable/disable, model list, and custom model management
- Chat picker merges custom models from settings into the model list
  so harness-routed providers always have selectable models
- Playwright config port default aligned to dev harness (5734)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 27, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds harness-discovered model support: ProviderRegistry queries harness adapters for models (cursor, opencode), merges results with per-provider customModels from settings, polls every 60s to refresh, adds a new harness adapter method, adjusts layer wiring, and surfaces merged model options and provider status in the UI.

Changes

Cohort / File(s) Summary
Harness Client Adapter
apps/server/src/provider/Services/HarnessClientAdapter.ts, apps/server/src/provider/Layers/HarnessClientAdapter.ts
Added listProviderModels(provider: string) to the harness adapter interface and live adapter; delegates to manager.listProviderModels.
Provider Registry & Syncing
apps/server/src/provider/Layers/ProviderRegistry.ts
Split provider loading into node vs harness flows; query harness for models (cursor/opencode), convert to ServerProvider snapshots (minimal models metadata), merge customModels (dedupe by slug), changed syncProviders to use combined load, added 60s polling effect to re-run sync, and adjusted refresh behavior for harness-routed providers.
Server Layer Composition
apps/server/src/serverLayers.ts
Switched harness adapter composition from Layer.provide() to Layer.provideMerge() when wiring the ProviderAdapterRegistry.
Web UI — Chat View
apps/web/src/components/ChatView.tsx
modelOptionsByProvider now merges discovered server models with settings.providers[p].customModels, converts custom slugs into option objects, deduplicates by slug, and memoizes on both providerStatuses and settings.
Web UI — Settings
apps/web/src/routes/_chat.settings.tsx
Made binary UI fields optional, added harnessDescription and entries for cursor/opencode; treat missing live provider with harnessDescription as checking; hide binary-path UI when binaryPlaceholder absent.
Tests — LocalStorage Stub
apps/web/src/terminalStateStore.test.ts
Added vi.hoisted localStorage stub to ensure storage exists before import-time initialization and simplified beforeEach to always clear localStorage.
Orchestration / Session Logic
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts, apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Adjusted session/provider binding: test startSession stub honors explicit input.provider; ensureSessionForThread now compares requested provider to currentProvider and prefers currentProvider when resolving preferredProvider.
Codex Adapter Events
apps/server/src/provider/Layers/CodexAdapter.ts
Added mapping for codex/event/plan_delta to emit a turn.proposed.delta runtime event when a delta is present (falls back to text/content.text).

Sequence Diagram(s)

sequenceDiagram
    participant UI as Client UI
    participant Server as ProviderRegistry
    participant Harness as Harness Client
    participant Settings as ServerSettingsService

    UI->>Server: Request providerStatuses / models
    Server->>Harness: listProviderModels("cursor"/"opencode")
    activate Harness
    Harness-->>Server: [ { slug, name }... ]
    deactivate Harness

    Server->>Settings: get settings.providers[p].customModels
    activate Settings
    Settings-->>Server: [ custom model slugs ]
    deactivate Settings

    Server->>Server: Merge discovered models + customModels (dedupe by slug)
    Server-->>UI: providerStatuses including merged models

    Note over Server: Poll every 60s -> re-run listProviderModels and syncProviders
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

size:XXL

Poem

🐰 I sniffed the harness, models in tow,
Cursor and Opencode now hop and show.
Custom slugs tucked in, no duplicates found,
Every sixty seconds they check the ground.
A rabbit cheers — merged models abound!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately and clearly summarizes the main change: adding first-class support for Cursor and OpenCode providers alongside existing providers.
Description check ✅ Passed Description provides a clear summary of changes, explains the why (harness model discovery), mentions UI changes (settings page), and includes a test plan, though before/after screenshots are not included.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/harness-provider-discovery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 27, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/src/provider/Layers/ProviderRegistry.ts`:
- Around line 44-50: The mapping that builds ServerProviderModel is overwriting
custom flags by hardcoding isCustom: false; change the models.map and the
similar block that flattens custom slugs to preserve the original property
(e.g., use isCustom: m.isCustom ?? false or isCustom: Boolean(m.isCustom)) so
settings-defined/custom models keep isCustom=true; ensure you reference the
ServerProviderModel construction (slug, name, isCustom, capabilities) and the
other flattening code that creates custom slugs so both places preserve
m.isCustom.
- Around line 36-43: The provider snapshot currently marks providers as
installed/ready/authenticated even when listProviderModels() fails; modify the
logic in ProviderRegistry (the code that builds the returned object with fields
provider, enabled, installed, version, status, authStatus, checkedAt) so that if
listProviderModels() rejects or returns an error you do not set installed:
true/status: "ready"/authStatus: "authenticated"; instead mark the provider as
not installed or unreachable (e.g., installed: false or status:
"unavailable"/"unreachable") and set authStatus to an appropriate failure state,
include the error context in checkedAt or an error field, and ensure the failure
path from listProviderModels() is propagated into the snapshot rather than
collapsing to an empty array.
- Around line 127-135: The polling fiber is terminated by an error because
Effect.orElseSucceed is applied outside Effect.repeat, so a failing
syncProviders() stops the repeating schedule; to keep polling alive wrap or
handle errors inside the repeated effect instead: apply error handling to
syncProviders() (e.g., syncProviders().pipe(Effect.orElseSucceed(() =>
undefined)) or syncProviders().pipe(Effect.catchAll(() =>
Effect.succeed(undefined))) before piping into Effect.delay and Effect.repeat
(keep Schedule.spaced and Effect.forkScoped unchanged) so each iteration
converts failures to a harmless success and the repeat schedule continues.

In `@apps/web/playwright.config.ts`:
- Line 12: The Playwright config's baseURL default currently uses 5734 (baseURL:
`http://localhost:${process.env.PORT ?? 5734}`) which is inconsistent with the
project's canonical port 5733 used in apps/web/vite.config.ts and
scripts/dev-runner.ts; update the fallback port in the Playwright config to 5733
(or, if 5734 is intended, update the fallback ports in vite.config.ts and
dev-runner.ts instead) so the baseURL construction in the Playwright config and
the PORT defaults across the project are consistent.

In `@apps/web/src/routes/_chat.settings.tsx`:
- Around line 524-536: The code marks enabled harness providers as "ready" even
when liveProvider is missing; change the logic in the statusKey and summary
computation so that isHarnessProvider requires a real liveProvider to be
"ready". Specifically, update the statusKey ternary (the expression computing
statusKey using providerSettings, liveProvider and providerConfig) to return a
non-ready state like "checking" or "warning" when isHarnessProvider &&
!liveProvider && providerConfig.enabled, and update the summary (the branch that
currently returns { headline: providerSettings.harnessDescription!, detail: null
} when isHarnessProvider && !liveProvider) to produce a checking/warning-style
summary instead of a success-style one (or defer to getProviderSummary only when
liveProvider exists), so ProviderStatusBanner will show the appropriate warning
until the server reports an actual status.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5f88de72-4d22-4cca-b17e-9e576e8026d8

📥 Commits

Reviewing files that changed from the base of the PR and between bbf49ad and 86fe9c0.

📒 Files selected for processing (7)
  • apps/server/src/provider/Layers/HarnessClientAdapter.ts
  • apps/server/src/provider/Layers/ProviderRegistry.ts
  • apps/server/src/provider/Services/HarnessClientAdapter.ts
  • apps/server/src/serverLayers.ts
  • apps/web/playwright.config.ts
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/routes/_chat.settings.tsx

Comment thread apps/server/src/provider/Layers/ProviderRegistry.ts
Comment thread apps/server/src/provider/Layers/ProviderRegistry.ts
Comment thread apps/server/src/provider/Layers/ProviderRegistry.ts Outdated
Comment thread apps/web/playwright.config.ts Outdated
Comment thread apps/web/src/routes/_chat.settings.tsx
ranvier2d2 and others added 2 commits March 27, 2026 19:08
…esilience, port alignment

- ProviderRegistry: propagate listProviderModels failures into snapshot
  (installed/status/authStatus reflect unreachable harness instead of
  unconditional ready/authenticated)
- ProviderRegistry: preserve isCustom on custom models from settings
  instead of hardcoding false
- ProviderRegistry: move orElseSucceed inside the repeated effect so a
  single syncProviders failure doesn't kill the 60s polling schedule
- Playwright config: align default port to 5733 (matches vite.config.ts
  and dev-runner.ts)
- Settings page: harness providers without liveProvider show "checking"
  status with pulsing dot instead of premature "ready"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ProviderRegistry: make HarnessClientAdapter an optional dependency
  via Effect.serviceOption so the layer composes without requiring the
  harness in index.ts, main.test.ts, or ProviderRegistry.test.ts
- ProviderRegistry: skip harness polling fiber when adapter is absent
- terminalStateStore.test: provide localStorage shim via vi.hoisted
  (Bun's stub lacks setItem/getItem/clear, breaking zustand persist)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
apps/server/src/provider/Layers/ProviderRegistry.ts (1)

85-99: ⚠️ Potential issue | 🟠 Major

Separate “no models discovered” from “harness unreachable.”

reachable currently means “the adapter returned a non-empty array”, not “the RPC succeeded”. The current HarnessClientManager.listProviderModels() implementation already converts transport failures into [], so a reachable provider with zero discovered models is emitted as installed: false / "warning" with a harness-unreachable message. Please have the adapter either throw on lookup failures or return an explicit { models, reachable } shape, and derive installed / status / authStatus from that signal instead of success.length > 0.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/provider/Layers/ProviderRegistry.ts` around lines 85 - 99,
The code treats a non-empty discoveredModels.success as the signal for
reachability; instead make reachability explicit by having
harnessAdapter.listProviderModels either throw on transport failures or return a
shape like { models, reachable } (update HarnessClientManager.listProviderModels
accordingly), then replace the current reachable calculation and the
installed/status/authStatus decisions (references: discoveredModels, reachable,
harnessAdapter.listProviderModels, HarnessClientManager.listProviderModels,
installed/status/authStatus) to derive those flags from the new reachable
boolean (or thrown error) instead of discoveredModels.success.length > 0 so that
“no models” and “harness unreachable” are distinct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/server/src/provider/Layers/ProviderRegistry.ts`:
- Around line 101-107: The merge logic in ProviderRegistry that builds allModels
from models and customModels loses custom provenance when a discovered model
slug collides with a saved custom slug and also doesn't update seen for accepted
custom slugs causing duplicates; modify the merge so slugs are deduplicated by
slug while preferring the saved custom entry (preserve isCustom: true) over the
discovered one — e.g., build a map keyed by slug starting from discovered
models, then iterate customModels and overwrite or insert entries with {slug,
name: slug, isCustom: true}, and ensure the seen set (or equivalent) is updated
when a custom slug is accepted so repeated settings don't produce duplicates
(touch symbols: models, customModels, seen, allModels, ProviderRegistry).

In `@apps/web/src/terminalStateStore.test.ts`:
- Around line 6-9: The guard that decides whether to shim
globalThis.localStorage only checks for setItem but later uses
localStorage.clear(), so update the eligibility check around
globalThis.localStorage (the if block) to verify all methods your test uses—at
minimum setItem, getItem, removeItem, and clear—exist and are functions before
replacing with a shim; adjust the conditional to check typeof
globalThis.localStorage.clear === "function" (and similarly for
getItem/removeItem) so a partial implementation can't bypass the guard and cause
a runtime error when terminalStateStore.test.ts calls localStorage.clear().

---

Duplicate comments:
In `@apps/server/src/provider/Layers/ProviderRegistry.ts`:
- Around line 85-99: The code treats a non-empty discoveredModels.success as the
signal for reachability; instead make reachability explicit by having
harnessAdapter.listProviderModels either throw on transport failures or return a
shape like { models, reachable } (update HarnessClientManager.listProviderModels
accordingly), then replace the current reachable calculation and the
installed/status/authStatus decisions (references: discoveredModels, reachable,
harnessAdapter.listProviderModels, HarnessClientManager.listProviderModels,
installed/status/authStatus) to derive those flags from the new reachable
boolean (or thrown error) instead of discoveredModels.success.length > 0 so that
“no models” and “harness unreachable” are distinct.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6b5c64f7-aa5b-4fcb-ae5f-76b8f5ce661a

📥 Commits

Reviewing files that changed from the base of the PR and between cc91804 and 799d84f.

📒 Files selected for processing (2)
  • apps/server/src/provider/Layers/ProviderRegistry.ts
  • apps/web/src/terminalStateStore.test.ts

Comment on lines +101 to +107
const customModels = settings?.providers[provider]?.customModels ?? [];
const seen = new Set(models.map((m) => m.slug));
const allModels = [
...models,
...customModels
.filter((slug: string) => !seen.has(slug))
.map((slug: string) => ({ slug, name: slug, isCustom: true as const })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Preserve custom provenance when a saved slug collides with discovery.

This merge only appends custom slugs that are not already in seen. If a user has a saved custom model and the harness later discovers the same slug, the merged snapshot keeps only the discovered entry with isCustom: false, which breaks custom-model management again. seen also is not updated for accepted custom slugs, so repeated values in settings still publish duplicates.

Possible fix
-            const customModels = settings?.providers[provider]?.customModels ?? [];
-            const seen = new Set(models.map((m) => m.slug));
-            const allModels = [
-              ...models,
-              ...customModels
-                .filter((slug: string) => !seen.has(slug))
-                .map((slug: string) => ({ slug, name: slug, isCustom: true as const })),
-            ];
+            const customModels = settings?.providers[provider]?.customModels ?? [];
+            const customSlugs = new Set(customModels);
+            const seen = new Set<string>();
+            const allModels = [
+              ...models.map((model) => {
+                seen.add(model.slug);
+                return {
+                  ...model,
+                  isCustom: customSlugs.has(model.slug),
+                };
+              }),
+              ...customModels
+                .filter((slug: string) => {
+                  if (seen.has(slug)) {
+                    return false;
+                  }
+                  seen.add(slug);
+                  return true;
+                })
+                .map((slug: string) => ({ slug, name: slug, isCustom: true as const })),
+            ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/provider/Layers/ProviderRegistry.ts` around lines 101 - 107,
The merge logic in ProviderRegistry that builds allModels from models and
customModels loses custom provenance when a discovered model slug collides with
a saved custom slug and also doesn't update seen for accepted custom slugs
causing duplicates; modify the merge so slugs are deduplicated by slug while
preferring the saved custom entry (preserve isCustom: true) over the discovered
one — e.g., build a map keyed by slug starting from discovered models, then
iterate customModels and overwrite or insert entries with {slug, name: slug,
isCustom: true}, and ensure the seen set (or equivalent) is updated when a
custom slug is accepted so repeated settings don't produce duplicates (touch
symbols: models, customModels, seen, allModels, ProviderRegistry).

Comment on lines +6 to +9
if (
typeof globalThis.localStorage === "undefined" ||
typeof globalThis.localStorage.setItem !== "function"
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Broaden the shim eligibility check to match required methods.

The guard only validates setItem, but Line 37 unconditionally calls localStorage.clear(). A partially implemented storage object can bypass this guard and still crash setup.

Suggested patch
 vi.hoisted(() => {
   if (
     typeof globalThis.localStorage === "undefined" ||
-    typeof globalThis.localStorage.setItem !== "function"
+    typeof globalThis.localStorage.getItem !== "function" ||
+    typeof globalThis.localStorage.setItem !== "function" ||
+    typeof globalThis.localStorage.removeItem !== "function" ||
+    typeof globalThis.localStorage.clear !== "function"
   ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
typeof globalThis.localStorage === "undefined" ||
typeof globalThis.localStorage.setItem !== "function"
) {
if (
typeof globalThis.localStorage === "undefined" ||
typeof globalThis.localStorage.getItem !== "function" ||
typeof globalThis.localStorage.setItem !== "function" ||
typeof globalThis.localStorage.removeItem !== "function" ||
typeof globalThis.localStorage.clear !== "function"
) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/terminalStateStore.test.ts` around lines 6 - 9, The guard that
decides whether to shim globalThis.localStorage only checks for setItem but
later uses localStorage.clear(), so update the eligibility check around
globalThis.localStorage (the if block) to verify all methods your test uses—at
minimum setItem, getItem, removeItem, and clear—exist and are functions before
replacing with a shim; adjust the conditional to check typeof
globalThis.localStorage.clear === "function" (and similarly for
getItem/removeItem) so a partial implementation can't bypass the guard and cause
a runtime error when terminalStateStore.test.ts calls localStorage.clear().

- ProviderCommandReactor: only enforce provider binding when there is
  an active session — a first turn with an explicit provider choice
  should be honoured, not rejected against the thread default
- ProviderCommandReactor: prefer the requested provider over the
  thread default when no active session exists
- ProviderCommandReactor test: mock startSession now respects the
  provider field from the caller instead of always using the harness
  default
- CodexAdapter: add codex/event/plan_delta → turn.proposed.delta
  mapping (was present in codexEventMapping.ts but missing from the
  adapter's own event handler)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant