Skip to content

feat: replace OpenCodeClient with @opencode-ai/sdk#2

Open
dibstern wants to merge 91 commits intomainfrom
feature/orchestrator-implementation
Open

feat: replace OpenCodeClient with @opencode-ai/sdk#2
dibstern wants to merge 91 commits intomainfrom
feature/orchestrator-implementation

Conversation

@dibstern
Copy link
Copy Markdown
Owner

@dibstern dibstern commented Apr 14, 2026

Summary

  • Replace hand-rolled OpenCodeClient (704 lines) and SSEConsumer (284 lines) with @opencode-ai/sdk v1.4.3
  • Introduce thin OpenCodeAPI adapter wrapping SDK + GapEndpoints (6 endpoints not in SDK) into unified namespaced API (client.session.list(), client.permission.reply(), etc.)
  • Migrate SSE consumption from manual stream parsing to SDK's event.subscribe() async generator via new SSEStream class
  • Replace hand-rolled types (SessionDetail, SessionStatus, PartType, ToolStatus, OpenCodeEvent) with SDK discriminated unions
  • Net result: -988 lines of hand-rolled client code deleted, replaced by SDK delegation

Architecture

Before:  Browser ←WS→ Relay ←OpenCodeClient (691 LOC, manual fetch+retry)→ OpenCode
After:   Browser ←WS→ Relay ←OpenCodeAPI→ { @opencode-ai/sdk, GapEndpoints, SSEStream }

New files:

  • src/lib/instance/retry-fetch.ts — Exponential backoff fetch adapter injected into SDK
  • src/lib/instance/sdk-factory.ts — SDK client creation with auth wiring (Basic Auth for REST + SSE)
  • src/lib/instance/gap-endpoints.ts — 6 endpoints not yet in SDK (permissions, questions, skills, paginated messages)
  • src/lib/instance/opencode-api.ts — Unified namespaced adapter with error translation (sdk() wrapper)
  • src/lib/instance/sdk-types.ts — SDK type re-export bridge
  • src/lib/relay/sse-stream.ts — SDK-backed SSE consumer with reconnection + health tracking

Deleted files:

  • src/lib/instance/opencode-client.ts (704 lines)
  • src/lib/relay/sse-consumer.ts (284 lines)

Key design decisions

  1. SDK types everywherePartType and ToolStatus now derive from SDK discriminated unions (Part["type"], ToolState["status"]). HistoryMessage/HistoryMessagePart kept as relay-specific transport types (carry renderedHtml, index signatures).

  2. Provider normalization — SDK returns { all, default, connected } with models as Record<string, Model>. Adapter normalizes to { providers, defaults, connected } with models as Array<Model> for caller compatibility.

  3. Message flattening — SDK returns { info: Message, parts: Part[] }. Adapter flattens to { ...info, parts } for backward compatibility with relay's message pipeline.

  4. Auth strategy (Audit v3)config.headers carries auth for both REST and SSE. authFetch handles SDK's single-Request calling convention (pass-through) and GapEndpoints' two-arg calls (injects auth).

  5. SSEEvent superset — SDK Event union doesn't cover 4 SSE-delivered events (message.part.delta, permission.asked, question.asked, server.heartbeat). Created SSEEvent = Event | GapEvents superset.

Test plan

  • pnpm check — 0 type errors (both server + frontend tsconfigs)
  • pnpm lint — 0 errors
  • pnpm test — 4273 unit tests passing
  • pnpm test:integration — 131 passing, 1 skipped
  • pnpm test:contract — 81 passing
  • E2E replay, daemon, multi-instance, subagent, visual tests — all passing
  • Storybook build + visual tests — 486 passing
  • pnpm test:allall 13 steps green

dibstern added 30 commits April 9, 2026 17:43
…cache, and transactions

Thin wrapper around Node 22+ node:sqlite DatabaseSync providing:
- WAL mode with tuned pragmas for file-backed databases
- LRU-evicted prepared statement cache (default capacity 200)
- Synchronous runInTransaction() with nested savepoint support
- Typed query<T>() and queryOne<T>() helpers
- Idempotent close()
… to both JSONL and SQLite

Refactor DualWriteHook constructor to accept an options object with
persistence, log, and enabled fields. Add dualWriteHook as optional
field on SSEWiringDeps and call it in handleSSEEvent() before any relay
processing, ensuring all SSE events (including early-return paths like
permission.asked) are captured. Add onReconnect() call in the SSE
connected handler to reset translator state on reconnection.
…bled feature flag

Add persistence and dualWriteEnabled fields to ProjectRelayConfig.
In createProjectRelay(), instantiate DualWriteHook when a persistence
layer is provided and dualWriteEnabled is not explicitly false (opt-out
default). Pass the hook into SSE wiring deps so events flow to SQLite.
Projects session.created, session.renamed, session.status,
session.provider_changed, turn.completed, turn.error, and
message.created events into the sessions read-model table.
Also adds ProjectionContext interface to the Projector contract
to support replay-aware projection.
…and tool events

Projects message lifecycle events into normalized messages and
message_parts tables. Uses SQL-native text concatenation for
streaming deltas, COALESCE subquery for sort_order, and
alreadyApplied() guard for replay safety.
Projects turn lifecycle events into the turns read-model table.
Tracks user-prompt to assistant-response cycles with pending,
running, completed, error, and interrupted states. Uses sub-select
pattern for portable UPDATE targeting.
… history

Projects session.created and session.provider_changed events into the
session_providers read-model table with deterministic IDs for idempotent
replay. 8 tests covering creation, deactivation, multiple changes, and
out-of-order replay safety.
…lifecycle

Projects permission.asked, permission.resolved, question.asked, and
question.resolved events into the pending_approvals table. Uses INSERT ON
CONFLICT DO NOTHING for idempotent replay. 11 tests covering both
approval types, resolution, and full lifecycle.
…imeline

Projects tool.started, tool.running, tool.completed, permission.asked,
permission.resolved, question.asked, question.resolved, and turn.error
events into the activities table. Uses deterministic IDs
(sessionId:sequence:kind) with INSERT OR IGNORE for replay idempotency.
13 tests covering all event types, payload storage, and chronological ordering.
…rtup recovery

Orchestrates all 6 projectors (session, message, turn, provider, approval,
activity) with per-projector fault isolation (A4), lazy cursor sync (P2),
SQL-level type-filtered recovery (P7), and batch projection for multi-event
SSE batches (S9). Includes sync recover(), async recoverAsync() with
setImmediate yielding (Perf-Fix-4), and recoverLagging() for targeted
reconnect recovery (Change 5b).
…o-project after append

PersistenceLayer now creates ProjectionRunner and ProjectorCursorRepository.
DualWriteHook calls projectEvent/projectBatch immediately after appendBatch,
with projection errors caught and logged (non-fatal). New end-to-end test
verifies SSE events flow through the full pipeline: translate → append →
project → read model tables.
…ntegrity queries

PersistenceDiagnostics provides two methods:
- health(): returns event count, session count, projector cursor positions,
  and event sequence range for monitoring dashboards.
- checkIntegrity(): runs PRAGMA foreign_key_check to detect FK violations.
…er for Phase 4

Encapsulates all SQLite read queries for the Phase 4 read switchover
in a single testable service. Methods cover tool content, fork metadata,
session list/status, paginated messages (composite cursor per Perf-Fix-6),
turns, pending approvals, and batch message+parts loading (CTE+JOIN per S10b).
All methods wrap queries in PersistenceError with PROJECTION_FAILED code.
…or read path switching

Implements three-state feature flags for Phase 4 read switchover with
helper functions isActive(), isSqlite(), isShadow(). Flags are mutable
for runtime toggling by DivergenceCircuitBreaker. Includes backward
compat mapping: boolean true -> "sqlite", false -> "legacy".
…nto relay stack

- ReadAdapter routes reads to SQLite or legacy based on ReadFlags
- Wire ReadQueryService, ReadFlags, ReadAdapter into relay-stack.ts
- Add readAdapter to HandlerDeps for Phase 4 handler consumption
- 51 new tests (adapter, switchover integration, relay wiring)
…-method contract)

Define the core provider abstraction: ProviderAdapter (7 methods), SendTurnInput,
TurnResult, EventSink, AdapterCapabilities, CommandInfo, and supporting types.
Adapters are execution-only — conduit owns all state.
EventSinkImpl bridges adapters to the event store with blocking
permission/question resolution via deferred promises.
… management

Map-based registry with register, get, list, remove, and shutdownAll.
Continues shutdown even when individual adapters fail.
…derAdapter

Implements all 7 ProviderAdapter methods: discover() maps REST API to
AdapterCapabilities, sendTurn() dispatches via REST with deferred completion
(notifyTurnCompleted bridge for SSE), interruptTurn/resolvePermission/
resolveQuestion are thin wrappers around OpenCodeClient.
dibstern added 24 commits April 10, 2026 17:48
… vitest e2e config

- Add settingSources: ["user", "project", "local"] to SDK options so
  query() loads OAuth credentials from ~/.claude/
- Switch E2E model to claude-haiku-4-5 (cheapest available)
- Create vitest.e2e.config.ts for test/e2e/ tests (main config only
  includes test/unit/ and test/fixture/)
- Update test:e2e:expensive-real-prompts script to use e2e config
- Fix pre-existing unused import in project-registry.test.ts

E2E test verified: passes in 6s against real Claude API.
…e-ai/sdk

5-phase layered migration: foundation (retry-fetch, sdk-factory, gap-endpoints),
client swap (OpenCodeAPI with SDK namespaced methods), type migration (SDK types
canonical), SSE migration (event.subscribe() with reconnect wrapper), cleanup.
Phase 1: Foundation (retryFetch, sdk-factory, gap-endpoints)
Phase 2: Client Swap (OpenCodeAPI adapter, caller migration)
Phase 3: Type Migration (SDK types canonical)
Phase 4: SSE Migration (SSEStream via sdk.event.subscribe())
Phase 5: Cleanup (delete 975+ lines of hand-rolled code)
1. GapEndpoints auth: sdk-factory returns { client, fetch } for reuse
2. Message normalization: documented SDK { info, parts } shape, caller update notes
3. Provider shape: use sdk.provider.list() + normalize models Record→Array
4. SSE event gap: SSEEvent superset type for 3 unmapped events
5. PTY auth: added getBaseUrl/getAuthHeaders to OpenCodeAPI
Extract retry logic from OpenCodeClient.request() into a standalone
fetch wrapper that can be injected into the SDK via
createOpencodeClient({ fetch: retryFetch }). Supports configurable
retries, exponential backoff, timeout via AbortController, and
pass-through of 4xx client errors without retrying.
Wraps ~6 OpenCode endpoints not yet in @opencode-ai/sdk: permissions,
questions, skills, and paginated messages. Constructor accepts fetch and
headers options so auth can be injected from the SDK factory.
Unified namespaced API adapter that wraps OpencodeClient and GapEndpoints
into a clean caller-facing interface (api.session.list(), api.permission.reply(), etc.).

Key design decisions:
- Private sdk<T>(fn) wrapper translates SDK error results into
  OpenCodeApiError/OpenCodeConnectionError
- SdkResult<T> type alias + call() helper avoids explicit `any` casts
  when bridging the SDK's complex RequestResult conditional types
- session.prompt() uses promptAsync (fire-and-forget)
- session.messages() preserves SDK shape: Array<{info, parts}>
- Exposes getBaseUrl()/getAuthHeaders() for PTY WebSocket connections
- 18 tests covering delegation, error translation, and gap endpoints
…spaced methods

Source files (22 changed):
- All handler files (agent, prompt, session, permissions, model, files, terminal, settings)
- Session management (session-manager, session-status-poller)
- Relay infrastructure (message-poller, message-poller-manager, monitoring-wiring,
  session-lifecycle-wiring, handler-deps-wiring, relay-stack)
- Provider layer (opencode-adapter, orchestration-wiring)
- Client init bridge

Method migration examples:
- client.listSessions() → client.session.list()
- client.getSession(id) → client.session.get(id)
- client.sendMessageAsync(id, opts) → client.session.prompt(id, opts)
- client.listProviders() → client.provider.list()
- client.listPendingPermissions() → client.permission.list()
- client.replyQuestion({id, answers}) → client.question.reply(id, answers)

Also adds return type annotations to OpenCodeAPI namespace methods for
backward compatibility with existing caller code (SessionDetail, Message,
ProviderListResult, etc.). These will be replaced by SDK types in Tasks 8-12.

Test migration: 46 test files updated. ~51 test failures remain from inline
mock constructions that need restructuring from flat to namespaced shape.
Source type check: 0 errors. 4242/4293 tests passing (98.8%).
Migrate 11 test files from old flat client method names to the new
namespaced OpenCodeAPI shape (e.g., client.listSessions -> client.session.list,
client.getSession -> client.session.get, client.getMessages -> client.session.messages).

Also update test assertions to match new positional argument signatures
(e.g., question.reply(id, answers) instead of question.reply({id, answers}),
permission.reply(sessionId, requestId, decision) instead of permission.reply({id, decision})).
Create sdk-types.ts as the single import point for SDK types. SessionStatus
is re-exported directly (structurally identical). SessionDetail is defined as
Session & { extra relay fields } to preserve backward compatibility for
handlers that access modelID/providerID/agentID on session objects.

Updated 10 source files and 1 test file to import from sdk-types.js instead
of opencode-client.js.
Derive PartType from SDK Part["type"] and ToolStatus from SDK
ToolState["status"] in sdk-types.ts. Remove hand-maintained string
unions from shared-types.ts and re-export the SDK-derived types so
all 30+ downstream consumers continue importing unchanged.

HistoryMessage and HistoryMessagePart are retained as relay-specific
transport types (they carry renderedHtml, index signatures, and
optional fields not present in SDK types).
Introduce SSEEvent as the canonical type for SSE stream events, replacing
the generic BaseOpenCodeEvent/OpenCodeEvent types. SSEEvent is a union of:

- SSEGapEvent: 5 event types the SSE stream delivers but the SDK Event
  union doesn't cover (message.part.delta, message.created,
  permission.asked, question.asked, server.heartbeat)
- Local relaxed interfaces for SDK-covered events (the SSE parser emits
  raw objects with optional fields)
- A structural fallback (SSEEventBase) for unknown/future events

Key changes:
- opencode-events.ts: Define SSEGapEvent, SSEEvent superset, and
  ServerHeartbeatEvent. Replace BaseOpenCodeEvent with local SSEEventBase.
- types.ts: Remove BaseOpenCodeEvent/GlobalEvent definitions. Re-export
  SSEEvent as OpenCodeEvent for backward compatibility.
- 11 source files updated to import SSEEvent from opencode-events.ts
- Test helpers updated to use SSEEvent type
- All type guards retained (needed until Tasks 13-14 replace SSE parser)
- KnownOpenCodeEvent/KnownOpenCodeEventType kept as deprecated aliases
Migrate SessionStatus imports (4 test files) and SessionDetail import
(1 test file) from legacy opencode-client.ts to sdk-types.ts. All
remaining opencode-client.ts types (Message, Agent, PromptOptions,
ProviderListResult, OpenCodeClient class) are relay-specific and will
be cleaned up when OpenCodeClient is removed (Task 15).
SSEStream replaces SSEConsumer's raw HTTP/SSE parsing with the typed
AsyncGenerator returned by OpenCode SDK's event.subscribe(). Extends
TrackedService for lifecycle management with exponential backoff
reconnection, heartbeat filtering, and health tracking.
Replace SSEConsumer with SSEStream (SDK-backed) in the relay pipeline:
- relay-stack.ts: construct SSEStream with `api` instead of SSEConsumer with baseUrl/authHeaders
- Remove legacy OpenCodeClient construction (no longer needed)
- Update ProjectRelay and RelayStack interfaces: sseConsumer -> sseStream
- sse-wiring.ts: accept SSEStream instead of SSEConsumer
- monitoring-wiring.ts: sseConsumer -> sseStream in deps and usage
- poller-wiring.ts: sseConsumer -> sseStream, cast unknown events to SSEEvent
- orchestration-wiring.ts: update wireSSEToAdapter handler type to unknown
- Update mock-factories, e2e and integration tests
Task 15: Remove opencode-client.ts (704 lines) and sse-consumer.ts
(284 lines), both fully replaced by SDK-backed OpenCodeAPI and
SSEStream. Migrate local type definitions (PromptOptions, Agent,
Provider, ProviderListResult, Message) to sdk-types.ts. Replace
daemon discovery's dynamic OpenCodeClient import with SDK factory.

Task 16: Trim sse-backoff.ts from 357 to 103 lines by removing 9
dead functions (parseSSEData, parseSSEDataAuto, parseGlobalSSEData,
isKnownEventType, classifyEventType, eventBelongsToSession,
filterEventsBySession, getSessionIds, getBackoffSequence). Rewrite
corresponding PBT/stateful tests to cover only the retained backoff
and health-tracker logic.

Net: -2,598 lines across 21 files. 235 test files, 4273 tests passing.
… adapter

The SDK returns different shapes than callers expect:
- provider.list() returns {all, default} but callers need {providers, defaults}
  with models converted from Record<string,Model> to Array<Model>
- session.messages/message/messagesPage return nested {info, parts} but callers
  need flat messages with .id, .role, .parts at the top level
1. SSEStream disconnect timeout: disconnect() now aborts the SDK's
   underlying fetch/reader via an AbortController passed to
   event.subscribe({ signal }). Previously the `for await` loop in
   consumeLoop blocked indefinitely because nothing cancelled the
   SSE connection.

2. Session switch history empty messages: MockOpenCodeServer now
   accumulates messages from SSE events (message.updated,
   message.part.updated) and serves them from GET /session/{id}/message.
   Previously the mock only had empty message arrays from the recording
   (captured before prompts were sent), so history was always empty.

3. Mid-stream switch test assertions: updated to check structural
   integrity (user/assistant roles, non-empty text) rather than specific
   prompt text, since the mock returns recorded content not the test's
   actual prompt text.
@dibstern dibstern force-pushed the feature/orchestrator-implementation branch from 2b6a191 to 2c72b4c Compare April 14, 2026 01:45
dibstern and others added 5 commits April 14, 2026 12:51
Register ClaudeAdapter alongside OpenCodeAdapter in the orchestration
layer. Model selection now binds sessions to the correct provider —
Anthropic/Claude models route through the in-process SDK, all others
through OpenCode. Claude models appear in the model_list via discover().
- Extract shared Deferred<T> from ClaudeAdapter to provider/deferred.ts
- Fix canUseTool: pass callback via SDK options (not just bridge storage)
- Fix stale session bindings: throwing sendTurn no longer leaves binding
- Fix translateError: failure no longer swallows original error
- Add processedCommands pruning (evict oldest at 10K threshold)
- Shutdown clears session bindings
- Track eventSink on session context (latest sink wins)
- Normalize error code to "provider_error" constant
- Add 16 tests: deferred, tool.completed emission, pruning, wireSSEToAdapter

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

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant