28 KiB
Opencode Preparation: Provider-Neutral Chat Events
Summary
Add a provider-neutral streaming and render event contract before treating opencode as a first-class provider in the JetBrains chat UI.
This is not an argument that the existing architecture was wrong. The current Claude-compatible message model and stdout marker protocol made it practical to add providers quickly when their output could be projected into assistant/user messages, text deltas, tool_use, and tool_result blocks. That was useful for the original Claude-first UI and for the first Codex integration.
The argument is narrower: opencode is not just another simple text provider. It exposes structured session, message, part, tool, permission, question, diff, and task/subagent events. Forcing those events into the existing Claude-shaped render contract may make the first demo faster, but it moves complexity into provider-specific merge, dedupe, history, permission, and diff-recovery code. A normalized chat_event layer should make the complete and maintainable opencode integration easier, not necessarily the first prototype smaller.
The existing ClaudeMessage compatibility path should remain. The proposed work adds a richer event path alongside it so opencode, Codex, ACP-backed agents, Cursor, and simpler future providers can share one structured render foundation.
Decision
Do not make opencode only another adapter that emits the existing Claude/Codex-compatible message markers.
Instead:
- keep explicit provider adapters and provider routing
- keep centralized permission-mode mapping where it is still useful
- introduce a provider-neutral
chat_eventstream for structured runtime events - add a shared streaming event capture log that can be promoted into replay fixtures
- let existing providers continue emitting current markers while the new path is introduced
- use opencode as the main design target, but prove the contract with fixtures and at least one existing provider surface such as Codex
Why This Is Not A Contradiction
The old multi-provider architecture optimized for easy onboarding of providers that fit a simple shape:
prompt -> assistant text -> done
That is why the previous Gemini example in docs/codex/MULTI-PROVIDER-ARCHITECTURE.md can be so small: create GeminiSDKBridge, create ai-bridge/services/gemini/message-service.js, add a permission mapper, route through channel-manager.js, and emit [MESSAGE_START], [CONTENT], and [MESSAGE_END].
Opencode has a different shape:
session -> turn -> message -> part -> tool lifecycle -> permission/question -> diff -> task/subagent -> restored history
For this shape, the easy path is deceptive. A compatibility-only bridge would still need to answer hard questions later:
- which text part belongs to which render block
- whether progress narration and final answer text should merge
- how pending, running, completed, and failed tool states are represented
- how permission and question requests stay tied to provider request IDs
- how diffs are scoped to the current turn
- how task/subagent activity links to child sessions
- how live streaming and restored history produce the same UI
The proposal changes the shared render contract so those questions are answered once, not separately in every structured provider adapter.
Existing Architecture To Preserve
The old docs still identify useful architectural boundaries:
- Java has provider-specific bridges such as
ClaudeSDKBridgeandCodexSDKBridge. - Node has provider-specific services under
ai-bridge/services/. channel-manager.jsroutes by provider.- Permission modes are translated into provider-specific settings.
- Session identity differences are hidden behind bridge-level mapping.
Those are good extension points and should remain. The proposed change is about the rendering and streaming contract, not about removing provider adapters.
Existing Architecture To Change
The current render contract mostly assumes:
- one assistant turn becomes one renderable assistant message
- text, thinking, and tool ordering can be recovered by frontend merge logic
- tool calls can be flattened into
tool_useplustool_result - restored history can be loaded as raw user/assistant messages and then merged
__turnIdcan be used for both streaming isolation and merge behavior
Those assumptions do not map cleanly to opencode, Codex, ACP agents, or Cursor.
They are also only a partial fit for Claude itself. Claude Code / Claude Agent SDK output can include stream events such as message starts, content block deltas, thinking deltas, message deltas, usage updates, assistant content blocks, tool use blocks, user tool result blocks, permission hooks, and result messages. The current model works where those events can be projected into message/content blocks, but later streaming and multi-provider support has required additional merge, replay, and boundary logic around that projection.
Expected Opencode Pressure Points
Opencode exposes structured session, message, and part events. If those events are immediately mapped into Claude/Codex-compatible markers, several rough edges are likely:
- live ordering may require synthetic block-reset style heuristics after tool or diff results
- restored history may contain multiple assistant step messages for one logical turn
- frontend merge logic may accidentally combine assistant progress text into one giant answer block
- accumulated session-level diffs may require baseline filtering to identify current-turn edits
- progress narration and final answer text may not be explicitly distinguished
- tool lifecycle state may be flattened too early into
tool_useandtool_result - live streaming and restored history may diverge
These are not opencode-specific UI problems. They are signs that the bridge and frontend need a richer provider-neutral event model.
The failed opencode support experiment confirmed these pressure points in practice. It required repeated fixes for role-gated text, stream-end recovery, post-tool text ordering, diff baseline filtering, restored-history normalization, task/subagent reconstruction, context-window recovery, and streaming debug capture. See Lessons From The Opencode Support Experiment for the detailed findings.
Codex Evidence
Codex already emits structured lifecycle events through runStreamed() and --json/JSONL output:
thread.startedturn.startedturn.completedturn.faileditem.starteditem.updateditem.completederror
Codex item types include:
agent_messagereasoningcommand_executionfile_changemcp_tool_callweb_searchtodo_list
Today the Codex bridge flattens these into Claude-compatible message/tool blocks. It also has special recovery paths that read the local JSONL session file during live streaming to recover missing function calls and patch details.
Historical context suggests this was a deliberate symmetry choice, not an accident. The first Codex bridge work introduced a shared ai-bridge/channel-manager.js and provider-specific adapters for Claude and Codex. The later Codex SDK adaptation documented the goal as using the "same elegant architecture as Claude", with "Symmetrical Design", a "Unified JSON Protocol", and provider services emitting unified console markers such as [MESSAGE_START], [CONTENT_DELTA], and [MESSAGE_END]. That was a practical way to add Codex quickly while reusing the existing UI and Java callback model.
The cost is visible in later fixes: Codex thinking, tool_use, and tool_result blocks had to be preserved through Claude-compatible JSON messages, and history replay needed separate field-mapping fixes for raw Codex JSONL because the live path and restored-history path normalized different shapes. No PR discussion or review comments were found for the original Codex SDK adaptation, so the strongest evidence is the committed docs, code comments, and follow-up fixes.
A provider-neutral turn/item model would let Codex preserve more native structure instead of reconstructing it through adapter-specific logic.
Relevant current code:
ai-bridge/services/codex/message-service.jsai-bridge/services/codex/codex-event-handler.jssrc/main/java/com/github/claudecodegui/session/CodexMessageHandler.javasrc/main/java/com/github/claudecodegui/handler/CodexMessageConverter.javasrc/main/java/com/github/claudecodegui/handler/history/HistoryMessageInjector.java
Prior Gemini Planning Docs
The existing docs contain Gemini as a future-provider example, not as an implemented integration plan. docs/codex/MULTI-PROVIDER-ARCHITECTURE.md describes adding Gemini by creating ai-bridge/services/gemini/message-service.js, adding a GeminiPermissionMapper, updating channel-manager.js, and adding GeminiSDKBridge. docs/codex/CODEX-INTEGRATION-QUICKSTART.md similarly frames Gemini as a future symmetrical provider and says it would follow the same pattern.
That prior plan is useful, but it also shows the limitation this proposal is trying to address. The old extension point was provider-oriented: Java bridge, Node service, permission mapper, and unified stdout markers. The example Gemini service emits only [MESSAGE_START], [CONTENT], and [MESSAGE_END]. That is enough for simple text providers, but not enough for providers or protocols with explicit turns, steps, tool lifecycles, permissions, diffs, plans, terminal output, usage updates, and subagent activity.
No ACP integration plan was found under docs/. The ACP section below is therefore a new protocol-driven design input, not a continuation of an existing project doc.
Generic ACP Compatibility
This foundation would also make it practical to support ACP-backed agents without building a new provider-specific merger for each one.
ACP is explicitly structured around the same concepts this proposal needs to represent:
session/promptstarts a prompt turnsession/updatestreams progress and outputagent_message_chunkcarries assistant text chunks with optional message identitytool_callcreates a tool call with a stabletoolCallIdtool_call_updatereports tool status and result content- tool statuses include
pending,in_progress,completed, andfailed - tool content can include regular content blocks, diffs, and terminal references
session/request_permissionasks the client to choose from explicit permission options- permission options include
allow_once,allow_always,reject_once, andreject_always - plan updates, session mode changes, usage updates, file-system requests, and terminal requests are first-class protocol concepts
Junie is a concrete example of why this matters. Junie CLI supports ACP mode through junie --acp true, and Junie in JetBrains IDEs exposes agent behavior that includes planning, ask/code modes, approval-gated execution, diffs, terminal commands, MCP tool execution, and action allowlists.
If this plugin can render ACP-shaped turns, chunks, tool calls, permissions, plans, diffs, and terminals through a normalized event contract, then Junie support can be implemented as an ACP adapter instead of a Junie-specific frontend model. The same work would also help any other ACP-capable agent.
Future Cursor Integration
This would also strongly benefit a potential Cursor provider.
Cursor exposes several integration surfaces that are already structured around runs, steps, deltas, tool lifecycle, and permissions.
Cursor TypeScript SDK:
Agent.create()creates a durable agentagent.send()starts aRunrun.stream()yields normalizedSDKMessageevents- events include
assistant,thinking,tool_call,status,task, andrequest - tool calls have stable lifecycle fields like
call_id,name, andstatus onDeltaexposes lower-level updates such astext-delta,thinking-delta,tool-call-started,partial-tool-call,tool-call-completed,step-started,step-completed,turn-ended, andshell-output-deltarun.conversation()returns structured turns with steps such asassistantMessage,thinkingMessage, andtoolCall
Cursor CLI:
--output-format stream-jsonemits structured NDJSON- events include
system,user,assistant,tool_call, andresult tool_callevents havestartedandcompletedsubtypes--stream-partial-outputadds character-level text streaming- assistant messages are emitted between tool calls, which directly maps to a step/block model
Cursor ACP:
agent acpexposes JSON-RPC over stdio- clients receive
session/updatenotifications - clients handle
session/request_permission - Cursor extensions include
cursor/ask_question,cursor/create_plan,cursor/update_todos,cursor/task, andcursor/generate_image
A provider-neutral event model would let a future Cursor integration map these surfaces directly instead of forcing Cursor into Claude-shaped raw messages.
See Provider Compatibility Matrix for a cross-provider view of which proposed event kinds and interface fields are useful for ACP, Cursor, opencode, and Codex.
Proposed Event Contract
Introduce a provider-neutral streaming/render event contract.
Suggested normalized concepts:
turnsteppartblocktool_calltool_resultdiffpermissionquestionplanstatusterminaltasktodo_updateusage_updatemode_change
Suggested event fields:
type:chat_eventschemaVersionprovidersessionIdturnIdrunIdmessageIdstepIdpartIdblockIdtoolCallIdsequenceparentIdphase:started | delta | updated | completed | failedkind:text | thinking | tool | diff | status | permission | question | plan | terminal | task | todo | usage | mode
The frontend should render from stable event and block identity instead of relying on post-hoc assistant-message merging.
Transport
chat_event records travel over the existing bridge stdout protocol as a new marker line beside the current markers:
[CHAT_EVENT] {"type":"chat_event","provider":"opencode","sessionId":"ses_123",...}
Rules:
- one event per marker line, JSON-encoded without embedded newlines
- Java forwards
[CHAT_EVENT]payloads to the webview without reshaping them; Java may additionally parse specific kinds (such aspermission) where native dialogs need them - the schema carries an explicit
schemaVersionfield so replay fixtures and the accumulator can reject or adapt incompatible events
Contract Invariants
The first implementation should treat these as acceptance rules, not later cleanup items:
sequenceis assigned at the adapter boundary, is monotonic within one provider session stream, and is the only ordering value used for replay.turnIdidentifies one user prompt or restored prompt turn; it must not also encode UI merge policy.stepIdidentifies a provider lifecycle unit such as an opencode message/part group, Codex item, ACP update group, or synthesized equivalent.blockIdidentifies one renderable text, thinking, diff, status, terminal, plan, or todo block and remains stable across deltas and snapshots.toolCallIdidentifies one tool lifecycle and correlates tool start, input updates, permission requests, result updates, diffs, and terminal output when the provider exposes that relationship.partIdandmessageIdpreserve native provider identity when available, but frontend grouping should not depend on provider-specific names.parentIdlinks nested entities such as task/subagent sessions, terminal streams, permission requests, or diffs back to the block, tool, step, or turn that created them.- A
completedorfailedevent can close a block/tool/turn, but it must not reorder earlier deltas or rewrite unrelated blocks. - Restored history should emit completed
chat_eventrecords that use the same identity hierarchy as live streaming wherever the provider exposes enough information.
Accumulator Rules
The shared frontend accumulator should be intentionally boring:
- Ingest ordered
chat_eventrecords and group bysessionId,turnId,stepId,blockId, andtoolCallId. - Append text and thinking deltas only to the block identified by
blockId. - Apply snapshots only to the matching identity and never use a cumulative provider snapshot to move older tool cards below later text.
- Render tools from lifecycle state (
started,updated,completed,failed) rather than from a pair of finishedtool_use/tool_resultmessages. - Render diffs, terminal streams, permissions, questions, tasks, plans, todos, usage, and mode updates as first-class blocks or side-channel records with explicit parent links.
- Derive legacy
ClaudeMessage-compatible blocks from accumulated events only where migration requires compatibility.
Streaming Event Capture
The preparation work should also add a shared streaming event capture utility for existing and new providers. This was useful during opencode implementation work because rendering issues could be diagnosed from the actual provider stream instead of from screenshots or final UI state.
The capture format should be replay-oriented, not just debug text. Prefer newline-delimited JSON envelopes with a version, provider, session ID, sequence number, capture stage, event type, redacted payload, and redaction metadata. Capture should include raw provider events, normalized chat_event records, and legacy markers while the compatibility path exists.
Required capture stages:
native_in: raw provider event received by the bridgenormalized_out: emittedchat_eventlegacy_out: emitted compatibility marker such as[CONTENT_DELTA]or[MESSAGE]
The logs should be safe to persist in debug mode and easy to convert into test fixtures. A captured rendering bug should be reducible into a fixture that replays either raw provider events through the normalizer or normalized chat_event records through the frontend accumulator.
See Streaming Event Logs And Replay Fixtures for the proposed log envelope and replay workflow.
Opencode Mapping Sketch
The exact mapping should be finalized against opencode's current HTTP event payloads, but the intended direction is:
Event names below come from opencode's newer API generation. Opencode is mid-migration between two API generations with different event vocabularies; see the API surface caveat in OPENCODE-INTEGRATION-QUICKSTART.md and re-verify names against the pinned SDK version at implementation start.
message.part.deltawith text fields maps tokind: text,phase: delta.message.part.deltawith reasoning fields maps tokind: thinking,phase: delta.message.updatedmaps to message or turn-levelphase: updatedsnapshots.message.part.updatedfor tool parts maps tokind: tool, withphasederived from the provider part state.- completed tool results map to
kind: tool,phase: completed, preservingtoolCallId. permission.askedmaps tokind: permission,phase: started, preserving the opencode request ID and available actions.permission.repliedmaps tokind: permission,phase: completed.question.askedmaps tokind: question,phase: started, preserving the opencode request ID.session.diffmaps tokind: diff, preserving file paths, hunks, and turn/session scope.- task or subagent tool metadata maps to
kind: task, preserving child session IDs when available. - restored
session.messagesoutput maps to completedchat_eventrecords using the same identity fields as the live stream wherever possible.
The adapter may still synthesize existing ClaudeMessage-compatible blocks during migration. That compatibility output should be derived from the structured events, not the only internal representation.
Possible Event Shape
Text delta example:
{
"type": "chat_event",
"provider": "opencode",
"sessionId": "ses_123",
"turnId": "turn_4",
"stepId": "step_2",
"blockId": "block_7",
"sequence": 42,
"phase": "delta",
"kind": "text",
"text": "I found the issue."
}
Tool lifecycle start example:
{
"type": "chat_event",
"provider": "codex",
"sessionId": "thread_123",
"turnId": "turn_1",
"stepId": "item_3",
"toolCallId": "call_abc",
"sequence": 17,
"phase": "started",
"kind": "tool",
"tool": {
"name": "command_execution",
"input": {
"command": "npm test"
}
}
}
Tool lifecycle completion example:
{
"type": "chat_event",
"provider": "cursor",
"sessionId": "agent_123",
"runId": "run_456",
"toolCallId": "call_abc",
"sequence": 18,
"phase": "completed",
"kind": "tool",
"tool": {
"name": "read_file",
"result": {
"path": "README.md",
"totalLines": 54
}
}
}
Permission example:
{
"type": "chat_event",
"provider": "opencode",
"sessionId": "ses_123",
"turnId": "turn_4",
"sequence": 51,
"phase": "started",
"kind": "permission",
"permission": {
"requestId": "perm_abc",
"tool": "bash",
"action": "run",
"choices": ["allow_once", "allow_always", "reject"]
}
}
Implementation Direction
Keep the existing ClaudeMessage path for compatibility, but add a richer normalized event path alongside it.
Suggested phases:
- Contract slice:
- Add a documented
chat_eventbridge marker, schema, and TypeScript type. - Add identity, sequencing, phase, and parent-link invariants to the schema tests.
- Add a frontend accumulator/store that groups events by
turnId,stepId,blockId, andtoolCallId. - Add shared streaming event capture for raw provider events, normalized
chat_eventrecords, and legacy markers.
- Add a documented
- Fixture slice:
- Add hand-authored fixtures for text, thinking, tool lifecycle, permission, question, diff, terminal, task, and restored-history events.
- Add at least one captured-log fixture path that can replay either raw provider events through a normalizer or normalized events through the accumulator.
- Prove live-stream and restored-history parity with the same accumulator.
- Existing-provider proving slice:
- Keep existing provider adapters emitting current markers while the new path is introduced.
- Prove the new path with at least one existing provider surface, preferably Codex, because Codex already has structured
thread,turn, anditemlifecycle events in the current codebase. - Keep Claude behavior unchanged initially; optionally wrap Claude stream events into the same model later.
- Opencode proving slice:
- Implement the minimal opencode send/stream/history path on top of
chat_event. - Cover opencode session/message/part identity, tool lifecycle, permission/question request IDs, diffs, and live/restored parity.
- Keep model discovery, agent discovery, slash commands, MCP display, usage statistics, and context recovery out of this slice unless explicitly required.
- Implement the minimal opencode send/stream/history path on top of
- Provider expansion slice:
- Add opencode model/agent discovery and optional UX surfaces after the event contract is proven.
- Use ACP and Cursor as design inputs for this preparatory work, not as required provider implementations in the infrastructure PR.
- Build later ACP-backed providers, such as Junie, as adapters onto this contract instead of adding a separate ACP-specific render model.
Implementation slices should stay narrow. The failed opencode support experiment bundled core streaming, model discovery, agent discovery, slash commands, MCP display, usage statistics, context recovery, and multiple UI menu changes into one broad effort. The next attempt should prove the event contract first, then add discovery and UX surfaces as separate slices.
UX Goals
- preserve text, thinking, and tool ordering without synthetic block-reset heuristics
- render live streaming and restored history using the same structure
- avoid provider-specific frontend merge hacks
- represent tool calls as lifecycle events, not only as finished blocks
- show pending, running, completed, and error tool states consistently
- represent diffs as first-class events with clear scope
- represent questions, permissions, plans, todos, usage updates, mode changes, terminal output, and subagent/task updates as structured UI events
- avoid using
__turnIdfor multiple unrelated concerns
Acceptance Criteria
- A documented
chat_eventcontract exists with required and optional fields, identity rules, sequencing rules, lifecycle phases, and supported event kinds. - Shared TypeScript types exist for the normalized event model and are covered by typecheck or schema tests.
- A frontend accumulator/store can ingest ordered
chat_eventfixtures and produce stable render groups byturnId,stepId,blockId, andtoolCallId. - Fixture tests cover text deltas, thinking deltas, interleaved tool lifecycle events, tool completion, diff/file-change events, permission/question events, plan updates, todo updates, terminal output references, usage updates, mode changes, and task/status updates.
- Fixture tests prove that block ordering is preserved by explicit identity and
sequence, not by assistant-message merge guesses. - Fixture tests prove that a live-style event stream and a restored-history-style event list can produce the same logical render groups.
- A shared streaming event capture format exists and records raw provider events, normalized
chat_eventrecords, and legacy markers with stable sequence numbers. - Captured event logs are redacted by default and can be sanitized into deterministic replay fixtures.
- At least one captured-log fixture can be replayed through a provider normalizer or frontend accumulator to reproduce a rendering issue.
- Codex structured events can be mapped into
chat_eventfixtures or an adapter-level test without losingthread,turn,item, and tool lifecycle identity. - Opencode session/message/part events are mapped in documentation or adapter-level fixtures without losing part identity, tool lifecycle state, permission/question request IDs, diff scope, task metadata, or restored-history parity.
- ACP
session/updateandsession/request_permissionexamples can be mapped intochat_eventfixtures or adapter-level tests without losing message chunk identity,toolCallId, tool status, diff content, terminal references, plan updates, mode changes, usage updates, or permission options. - The provider compatibility matrix identifies which event kinds and interface fields are needed by ACP, Cursor, opencode, and Codex, so additions are justified by concrete provider surfaces rather than opencode alone.
- Existing Claude and Codex rendering behavior remains compatible while the new event path is introduced alongside the current
ClaudeMessagepath. - The infrastructure PR does not need to implement the full opencode provider, a generic ACP provider, Junie provider support, or Cursor provider support.
- The implementation notes explain which opencode support experiment learnings are intentionally deferred, such as context recovery, MCP display, slash commands, and usage statistics.
Non-Goals
- Do not implement Cursor provider support in this work.
- Do not rewrite the entire chat UI at once.
- Do not remove the existing
ClaudeMessagecompatibility path immediately. - Do not change provider authentication or model-selection behavior.
- Do not require full opencode provider support in the same PR as the event infrastructure.
- Do not implement a generic ACP provider or Junie provider support in this work.
Risks And Tradeoffs
- This adds upfront infrastructure work before opencode's first complete integration.
- During migration, adapters may emit both legacy markers and
chat_eventrecords. - The event model needs clear identity and sequencing rules or it will recreate the same merge problems in a new shape.
- Simple providers will have slightly more structure than they strictly need, but they can emit a small subset of
chat_eventevents. - Pulling every opencode support experiment feature into the first retry would repeat the failed experiment's scope problem.
Conclusion
The previous architecture should be preserved where it helped: explicit provider adapters, provider routing, and permission translation. The render contract should change because opencode and other structured agents expose more than assistant text.
The goal is lower long-term integration cost and higher correctness: one shared event model for live streaming, restored history, tools, permissions, questions, diffs, tasks, and future structured providers.
The next opencode attempt should be treated as an event-contract proving slice first, not as a full provider-feature parity effort.