12 KiB
Opencode Multi-Provider Architecture
Purpose
This document updates the older Codex-era multi-provider architecture for opencode and other structured providers.
The Codex-era architecture was good at adding providers that could be projected into a small set of message markers. Opencode needs the same explicit provider boundaries, but it also needs a richer shared event plane so provider adapters do not each reinvent streaming merge, tool lifecycle, permission, diff, and history logic.
Design Principles
- Keep provider boundaries explicit.
- Keep provider-specific runtime ownership in provider adapters.
- Normalize structured runtime events into a shared
chat_eventstream. - Capture streaming events in a replayable log format for debugging and test generation.
- Keep
ClaudeMessagecompatibility during migration. - Make live streaming and restored history use the same logical event model.
Architecture Overview
Java Layer
ClaudeSDKBridge
provider: claude
session identity: sessionId
runtime: Claude Agent SDK / Claude Code
CodexSDKBridge
provider: codex
session identity: thread/session id
runtime: Codex bridge
OpenCodeSDKBridge
provider: opencode
session identity: opencode sessionID
runtime: @opencode-ai/sdk + opencode serve
|
| stdin JSON / process output / callback protocol
v
Node ai-bridge
channel-manager.js
routes by provider: claude | codex | opencode
channels/
claude-channel.js
codex-channel.js
opencode-channel.js
services/
claude/
codex/
opencode/
|
| legacy markers + chat_event records
v
Java message handlers
legacy marker handling for existing UI paths
chat_event forwarding for structured render path
|
v
Webview
legacy ClaudeMessage renderer during migration
chat_event accumulator and structured renderer
Provider Responsibilities
Each provider adapter owns provider-specific details:
- SDK or CLI resolution
- runtime process lifecycle
- provider-specific auth/config discovery
- session creation, resume, abort, delete, and history lookup
- model and agent discovery where supported
- permission request/reply transport
- conversion from native provider events to normalized
chat_eventrecords
Provider adapters should not own shared UI merge policy. Once native events are normalized, ordering and grouping should be handled by the shared event accumulator.
The failed opencode support experiment showed the cost of violating this boundary: provider code, Java handlers, and webview streaming hooks all accumulated special-case merge and recovery logic for the same turn.
Shared Event Plane
The shared event plane uses chat_event records with stable identity fields.
Core fields:
providersessionIdturnIdrunIdmessageIdstepIdpartIdblockIdtoolCallIdsequenceparentIdphasekind
Core phases:
starteddeltaupdatedcompletedfailed
Core kinds:
textthinkingtooldiffstatuspermissionquestionplanterminaltasktodousagemode
The important change from the older marker-only architecture is that provider events keep their identity and lifecycle. The frontend should not infer ordering by merging assistant messages after the fact.
Minimum invariants for the shared event plane:
- assign
sequenceonce at the adapter boundary and replay bysequence, not timestamp - keep
turnId,stepId,partId,blockId, andtoolCallIdstable across deltas, snapshots, and restored history - use
parentIdto connect nested permissions, questions, diffs, terminals, and task/subagent sessions to the event that caused them - treat lifecycle
phaseas state for the same identity, not as permission to rebuild unrelated blocks - make legacy marker output derivable from
chat_eventduring migration instead of making markers the only source of truth
The event kinds above are intentionally broader than the first opencode slice. Provider Compatibility Matrix maps the same additions to ACP, Cursor, opencode, and Codex so the shared contract can be reviewed as provider-neutral infrastructure.
Compatibility Layer
chat_event records use the same stdout transport as the existing markers: one [CHAT_EVENT] <json> line per event, forwarded by Java to the webview without reshaping (see the Transport section in PREPARATION.md).
The current UI still depends on Claude-compatible messages and markers such as:
[MESSAGE_START][CONTENT_DELTA][THINKING_DELTA][MESSAGE][STREAM_END][MESSAGE_END]
During migration, providers may emit both:
- legacy markers for existing handlers
chat_eventrecords for the structured path
The long-term target is for rich provider output to be represented internally as chat_event first, with legacy messages derived only where compatibility requires them.
Streaming Event Capture
Every provider should be able to write replayable streaming event logs. These logs should capture enough information to reproduce rendering bugs without attaching a debugger to a live provider run.
Recommended capture stages:
native_in: raw provider event received by the bridgenormalized_out: emittedchat_eventlegacy_out: emitted compatibility marker while migration is in progresshandler_in: optional Java handler inputrender_in: optional frontend accumulator input
The capture utility should write newline-delimited JSON envelopes with stable sequence numbers, provider/session metadata, event type, redacted payload, and redaction metadata. Replay should use sequence, not timestamps.
Captured logs should support two important test paths:
- raw native events replayed through a provider normalizer
- normalized
chat_eventrecords replayed through the frontend accumulator
See Streaming Event Logs And Replay Fixtures for the envelope format and fixture-promotion workflow.
Permission Architecture
Permission mode mapping remains provider-specific, but the UI event shape should be shared.
Plugin mode
-> provider permission mapper
-> native provider request/reply transport
-> chat_event kind: permission
-> shared permission UI
-> reply correlated by provider request ID
Opencode-specific guidance:
plan: use opencode's planning agent or equivalent; deny or ask for edits, shell, external-directory, and network-like operations.default: ask for edits, shell, external-directory, and network-like operations.acceptEdits: allow workspace edits; keep shell and external actions on ask.autoEdit: allow reads and workspace edits needed for autonomous changes; keep dangerous shell and external actions on ask.bypassPermissions: allow broad workspace actions only when explicitly selected.
The normalized permission event must preserve the provider request ID, requested tool/action, and available choices.
Session And History Architecture
Each provider should use its own source of truth for history:
- Claude uses Claude session/history APIs or readers.
- Codex uses Codex session files and current Codex history normalization.
- Opencode should use opencode session APIs.
The shared target is that live and restored paths produce equivalent render groups:
live provider events -> chat_event stream -> accumulator -> render groups
restored history -> chat_event list -> accumulator -> render groups
Restored history is allowed to emit fewer intermediate deltas, but it should preserve the same logical identities for turns, blocks, tools, diffs, permissions, questions, and task links. Tests should compare accumulated render groups, not raw event counts.
This avoids the Codex failure mode where live streaming and history replay each need separate field mappings for the same logical tool call.
Opencode Service Layout
Expected Node service layout:
ai-bridge/services/opencode/
sdk-loader.js
server-manager.js
message-service.js
event-normalizer.js
permission-mapper.js
model-service.js
agent-service.js
history-service.js
error-normalizer.js
event-capture.js
Expected channel commands:
sendabortdeleteSessiongetSessionMessageslistSessionslistModelslistAgents
Opencode Message Flow
1. User sends a prompt with provider opencode.
2. Java OpenCodeSDKBridge serializes message, session ID, cwd, mode, model, agent, and attachments.
3. channel-manager.js routes to opencode-channel.js.
4. opencode-channel.js starts or connects to opencode serve.
5. message-service subscribes to /event before prompt submission.
6. message-service creates or resumes an opencode session.
7. message-service sends text/file parts to the session message endpoint.
8. event-normalizer filters events by sessionID.
9. event-normalizer emits chat_event records and migration compatibility markers.
10. Java/webview render from stable event identity.
Adding Future Providers
Future providers should follow the same split:
- Add a Java bridge only for provider runtime dispatch.
- Add a Node channel and service folder for provider-specific runtime logic.
- Normalize native events to
chat_eventrecords. - Add fixtures for live stream and restored history parity.
- Add compatibility markers only when existing UI paths still require them.
Simple providers can emit a tiny subset:
{
"type": "chat_event",
"provider": "example",
"sessionId": "session_1",
"turnId": "turn_1",
"blockId": "text_1",
"sequence": 1,
"phase": "delta",
"kind": "text",
"text": "Hello"
}
Structured providers should preserve richer identity instead of flattening into one assistant message.
Broad provider feature surfaces should be added as separate slices. The opencode support experiment mixed streaming, history, model discovery, agent discovery, slash commands, MCP display, usage statistics, context recovery, and menu UX changes. That made failures harder to isolate.
Recommended slice order:
- Shared
chat_eventschema, accumulator, and capture utility. - Replay fixtures for provider-normalizer and frontend-accumulator paths.
- Codex or another existing-provider proving adapter that keeps current behavior compatible.
- Minimal opencode send/stream/history adapter using the structured event path.
- Opencode model/agent discovery and optional provider-specific UX surfaces.
Testing Strategy
Tests should prove behavior at the contract boundary:
- captured streaming logs can be sanitized and replayed as fixtures
- provider event fixtures map to
chat_eventwithout losing identity - interleaved text, thinking, and tool events preserve order
- tool lifecycle states render as pending/running/completed/failed
- permission and question requests preserve provider request IDs
- diffs preserve file scope and current-turn scope
- task/subagent events preserve child session links when available
- restored history fixtures produce the same render groups as live fixtures
- legacy Claude/Codex rendering remains compatible during migration