feat: replace OpenCodeClient with @opencode-ai/sdk#2
Open
Conversation
…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()
…ession stream versioning
…abase initialization
…g, and event store writes
… 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.
…or replay progress
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.
… 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.
2b6a191 to
2c72b4c
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
OpenCodeClient(704 lines) andSSEConsumer(284 lines) with@opencode-ai/sdkv1.4.3OpenCodeAPIadapter wrapping SDK +GapEndpoints(6 endpoints not in SDK) into unified namespaced API (client.session.list(),client.permission.reply(), etc.)event.subscribe()async generator via newSSEStreamclassSessionDetail,SessionStatus,PartType,ToolStatus,OpenCodeEvent) with SDK discriminated unionsArchitecture
New files:
src/lib/instance/retry-fetch.ts— Exponential backoff fetch adapter injected into SDKsrc/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 bridgesrc/lib/relay/sse-stream.ts— SDK-backed SSE consumer with reconnection + health trackingDeleted files:
src/lib/instance/opencode-client.ts(704 lines)src/lib/relay/sse-consumer.ts(284 lines)Key design decisions
SDK types everywhere —
PartTypeandToolStatusnow derive from SDK discriminated unions (Part["type"],ToolState["status"]).HistoryMessage/HistoryMessagePartkept as relay-specific transport types (carryrenderedHtml, index signatures).Provider normalization — SDK returns
{ all, default, connected }with models asRecord<string, Model>. Adapter normalizes to{ providers, defaults, connected }with models asArray<Model>for caller compatibility.Message flattening — SDK returns
{ info: Message, parts: Part[] }. Adapter flattens to{ ...info, parts }for backward compatibility with relay's message pipeline.Auth strategy (Audit v3) —
config.headerscarries auth for both REST and SSE.authFetchhandles SDK's single-Request calling convention (pass-through) and GapEndpoints' two-arg calls (injects auth).SSEEvent superset — SDK
Eventunion doesn't cover 4 SSE-delivered events (message.part.delta,permission.asked,question.asked,server.heartbeat). CreatedSSEEvent = Event | GapEventssuperset.Test plan
pnpm check— 0 type errors (both server + frontend tsconfigs)pnpm lint— 0 errorspnpm test— 4273 unit tests passingpnpm test:integration— 131 passing, 1 skippedpnpm test:contract— 81 passingpnpm test:all— all 13 steps green