Skip to content

feat(opencode-http): readable run logs + structured event trace#279

Merged
pbean merged 12 commits into
bmad-code-org:mainfrom
jackmcintyre:feat/opencode-readable-logs
Jul 25, 2026
Merged

feat(opencode-http): readable run logs + structured event trace#279
pbean merged 12 commits into
bmad-code-org:mainfrom
jackmcintyre:feat/opencode-readable-logs

Conversation

@jackmcintyre

@jackmcintyre jackmcintyre commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes #306

What

Adds a readable agent transcript and a structured event trace to the opencode-http adapter's run logs. Purely additive; no control-loop change.

Why

The opencode-http adapter's run logs only carry the server's INFO stdout (timestamps, "loading instance", and so on). The claude and tmux adapters get a full replay from the tmux pane, but the HTTP adapter has nothing to scrape, so a finished run is opaque - you can't see what the agent did or said. That makes finished runs hard to debug.

How

  • Per task, three files: the existing <task>.log now carries a readable, role-coloured inline transcript; a new <task>.events.jsonl holds the complete structured SSE trace for replay; and the server stdout is redirected to its own <task>.server.out so it stops mixing into the transcript.
  • Written off the existing SSE dispatch in adapters/opencode_http.py. No change to the deterministic control loop.
  • Role colours come from a small map with a default fallback, so a new role renders distinctly without a code change.

Testing

Unit tests cover the render and emit paths and the role lookup (messageID-keyed, survives out-of-order events). Full suite green (2840 passed). Verified end to end against a real opencode server.

Opening as a draft to check the shape is welcome before going further. Happy to file an issue to go with it.

Summary by CodeRabbit

  • New Features
    • Added readable per-task run logs for opencode-http, split into: logs/<task-id>.log, <task-id>.server.out, and optional <task-id>.sse.jsonl (structured SSE trace).
    • Updated transcript rendering and run-log viewing to rely on the per-task logs (and the TUI Log tab), not tmux.
  • Bug Fixes
    • Improved resilience so malformed/unexpected SSE frames don’t interrupt run processing.
    • Enhanced failure handling so diagnostic files are captured and sinks are safely closed.
  • Documentation
    • Updated changelog and adapter/feature/setup guides to describe the new log artifacts and transcript behavior.

The opencode-http adapter logged only server INFO stdout; a finished run
had no agent transcript. Add an inline readable transcript to <task>.log
(role-coloured), a structured JSONL trace to <task>.events.jsonl, and
redirect server stdout to its own <task>.server.out. Purely additive;
writes off the existing SSE dispatch, no control-loop change.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e5fb7a06-f28a-4b3a-9de2-cc41dbdee954

📥 Commits

Reviewing files that changed from the base of the PR and between d9dab61 and 47104a2.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • docs/FEATURES.md
  • docs/setup-guide.md
  • src/bmad_loop/adapters/opencode_http.py
  • tests/test_opencode_http.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/FEATURES.md
  • CHANGELOG.md
  • docs/setup-guide.md
  • src/bmad_loop/adapters/opencode_http.py

Walkthrough

The opencode HTTP adapter now produces curated transcript logs, separate server stdout logs, and optional structured SSE traces. SSE rendering handles message roles, tool activity, session-less events, malformed payloads, and trace sequencing, with expanded tests and documentation.

Changes

Opencode HTTP logging

Layer / File(s) Summary
Per-task sink setup
src/bmad_loop/adapters/opencode_http.py
Creates and wires transcript, server-output, and optional SSE trace sinks, with consistent cleanup during failures and teardown.
SSE tracing and transcript rendering
src/bmad_loop/adapters/opencode_http.py
Filters and records SSE frames, renders curated message and tool lines, tracks roles by message ID, and preserves control-queue signaling when logging encounters malformed data.
Behavior validation and documentation
tests/test_opencode_http.py, CHANGELOG.md, docs/...
Adds sink, rendering, dispatch, trace-toggle, and spawn-diagnostic coverage and documents the three per-task artifacts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OpencodeSSE
  participant OpencodeHttpAdapter
  participant TaskTranscript
  participant SSETrace
  participant ControlQueue
  OpencodeSSE->>OpencodeHttpAdapter: deliver /event frame
  OpencodeHttpAdapter->>TaskTranscript: render curated transcript line
  OpencodeHttpAdapter->>SSETrace: append structured SSE record
  OpencodeHttpAdapter->>ControlQueue: queue session.idle or session.error
Loading

Possibly related PRs

Suggested reviewers: pbean

Poem

I’m a rabbit, hopping through logs in a row,
.log tells the tale, .server.out shows the show.
SSE frames dance in .jsonl bright,
Tool calls and messages come into sight.
Three little sinks, neatly aligned—
A readable run is what we find!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: readable logs plus a structured event trace for opencode-http.
Linked Issues check ✅ Passed The changes match #306 by splitting server stdout, producing a readable transcript, adding an optional SSE trace, and guarding logging from breaking SSE flow.
Out of Scope Changes check ✅ Passed The diff appears focused on the opencode-http logging and trace work, with tests and docs supporting the same scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Thanks @jackmcintyre — this is a real gap, and the shape you landed on is the one we want: curate the transcript off the SSE dispatch, keep the server's own stdout out of it, and leave the control loop alone.

We're adopting the PR. Since maintainer edits are on, I'll push the revisions directly to your branch rather than asking you to chase them — authorship stays yours.

Planned revisions:

  • Repoint the spawn-failure error. It still ends with log: {log_path}, but the server's stdout now lands in <task>.server.out — as written, a failed spawn points at the file that no longer holds the diagnostics.
  • Best-effort guard around the new _render_inline / _emit_event calls in _dispatch_sse. An unexpected frame shape shouldn't be able to take out the SSE reader thread upstream of the idle / error queue puts.
  • Rename the trace to <task>.sse.jsonl, knob-gated with an off-switch, and drop the per-token message.part.delta records. The deltas dominate the file and re-carry text that message.part.updated already delivers complete, so they cost a lot of bytes for little replay value. The trace itself stays — it's genuinely useful.
  • Extend the module-docstring API pin list with the frame types this newly consumes (message.part.updated, message.part.delta, command.executed, file.edited, permission.updated, permission.replied). That docstring is where the adapter's opencode contract is pinned, so anything we read off /event has to be named there.
  • CHANGELOG entry + doc updates.

We'll take the companion issue too (CONTRIBUTING wants one for a feature) — no need for you to file it; I'll link it here.

One more thing coming to this thread: I'm live-probing 1.18.2's /event stream to confirm the message.part.updated semantics your render path assumes (complete text once, not progressively growing re-fires) plus the tool/permission surface. I'll post the evidence here so the docstring pin has a citation.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Live probe evidence — opencode 1.18.2 /event

Probed the real binary today so the docstring pin has a citation. opencode 1.18.2, two opencode serve instances (one default-permission, one permission.bash/edit = "ask"), 5 turns on anthropic/claude-haiku-4-5, 388 frames captured off GET /event. Cross-checked against the server's own OpenAPI (GET /doc), whose Event union has 89 variants — that union is the static contract.

✅ Your core assumption holds — render-on-part.updated, skip deltas, is correct

message.part.updated carries a part's complete text exactly once. It is not a progressively-growing re-fire stream. The pattern is: one fire at part creation with text: "", then exactly one fire carrying the full final text.

reply size part.updated fires fires w/ non-empty text part.delta frames
4 chars (done) 2 1 1
711 chars 2 1 8
5689 chars (~700 words) 2 1 50

At 5689 chars the 50 deltas concatenate byte-exactly to the single part.updated text (asserted equal). So both halves of your reasoning check out: the inline log gets one clean line per statement, and the deltas are pure redundancy in the trace — dropping them loses no information.

Also confirmed: no tool.call / tool.response anywhere — not in the 89-variant union, not live.

⚠️ Three corrections the probe turned up

These are on us for the adoption revisions, not asks for you — but they change the pin list I posted above, so recording the evidence here.

1. command.executed never fires for agent tool use — 0 frames across bash, write, read, and edit calls. Tool execution surfaces as message.part.updated where part.type == "tool":

{"type":"message.part.updated","properties":{"sessionID":"ses_…","part":{
  "id":"prt_…","type":"tool","tool":"bash","messageID":"msg_…",
  "state":{"status":"completed","input":{"command":"echo hi"},"output":"hi\n"}}}}

with state.status stepping pending → running → completed. command.executed does exist in the union (it has name/arguments/messageID), but it's the slash-command surface, not the tool surface.

This one matters beyond a dead branch: _inline_line reads part.get("text"), which is absent on tool parts, so it returns None — meaning the transcript currently renders no tool activity at all, only assistant prose. Rendering off part.type == "tool" (on the completed transition, so each tool logs once) is what actually delivers the "see what the agent did" half of the feature.

2. permission.updated does not exist in 1.18.2 — it's permission.asked, and the field names differ from what the render assumes:

{"type":"permission.asked","properties":{"id":"per_…","sessionID":"ses_…",
  "permission":"bash","patterns":["echo permcheck"],
  "metadata":{"command":"echo permcheck"},"always":["echo *"],
  "tool":{"messageID":"msg_…","callID":"toolu_…"}}}

{"type":"permission.replied","properties":{"sessionID":"ses_…",
  "requestID":"per_…","reply":"once"}}

So it's permission / patterns (not type / pattern), and permission.replied carries reply, not response — the current line would always print ?. (There are also permission.v2.asked / permission.v2.replied variants in the union we should decide about.)

3. file.edited carries no sessionID, so it's dropped before your render ever sees it. Live payload is exactly {"file": "/abs/path"} — nothing else. Since _dispatch_sse does if props.get("sessionID") != sess.session_id: return above the render call, the file: branch is unreachable. Same for file.watcher.updated. Fix is an explicit allowlist for the session-less types — safe here specifically because bmad-loop runs one server per session, so a server-global frame is still unambiguously this session's.

For reference, every type seen live, with whether it carries sessionID:

sessionID: message.part.updated(67) message.part.delta(64) message.updated(50)
           session.status(30) session.updated(22) session.diff(15) session.idle(5)
           session.created(2) permission.asked(1) permission.replied(1)
NO sessionID: plugin.added(90) server.heartbeat(27) catalog.updated(4)
           server.connected(2) reference.updated(2) integration.updated(2)
           file.edited(2) file.watcher.updated(2)

Net

The design is sound and the expensive assumption — complete-once part.updated — is confirmed, so no rework of your rendering approach. The corrections are frame-name and field-name fixes plus the tool-part render, which we'll fold into the revisions on your branch. Thanks again for finding this gap and building it in the right place.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Approved the fork CI run (first-time contributor, so it was parked on action_required — nothing you did wrong).

8/9 green — full test matrix passes on Linux py3.11–3.14 and both Windows legs, plus trunk lint and version-sync. One failure, typecheck (pyright):

src/bmad_loop/adapters/opencode_http.py:597:35 - error: Argument of type "Unknown | None"
  cannot be assigned to parameter "etype" of type "str" in function "_render_inline"
src/bmad_loop/adapters/opencode_http.py:598:32 - error: same, for "_emit_event"

etype comes from event.get("type"), so it's Unknown | None at the call site while both new helpers annotate etype: str. We run pyright in basic mode as a CI gate. It's a one-line narrowing — we'll fold it into the revisions on your branch, no action needed from you.

pbean added 7 commits July 25, 2026 10:48
Redirecting the server's stdout to <task>.server.out left the give-up
raise citing `log: {log_path}` — the transcript sink, which now holds
only SSE-rendered lines and is EMPTY when the spawn never got far enough
to stream any. So every failed spawn sent the operator to a blank file
while the diagnostics sat unread in the file next to it.

The give-up test asserted only that <task>.log exists, which it does
either way; it now pins the path named in the message and reads the
named file back, asserting all three attempts' stdout landed there.
_render_inline and _emit_event run inside _dispatch_sse, upstream of the
idle/error queue puts, and both walk nested .get chains into payloads the
server controls (props["part"], ["info"], ["permission"]). A frame that
ships a string where a dict is expected raises AttributeError there.

Unguarded that unwinds into the reader's connection-level `except` in
_sse_loop, which reads it as a dead connection: it tears the stream down,
puts a `gap`, sleeps and reconnects, dropping whatever the server had
already buffered. A logging bug would degrade completion signaling.

Wrap both calls in the reader's own never-die doctrine, with _emit_event
ordered first so a render bug still leaves the offending frame recorded
in the trace you would read to diagnose it. Also narrow the frame type
before the typed helpers: a non-string `type` can match no branch below,
and it cleared the two pyright errors CI flagged on the previous commit.

Ablation-checked: with the try/except removed, the four malformed-frame
cases and the trace-record test fail with AttributeError. The
unserializable-payload test is not guard-sensitive (it exercises
_emit_event's own json.dumps catch) and the block comment says so.
Three scope decisions on the structured trace:

- Rename <task>.events.jsonl to <task>.sse.jsonl. "events" is overloaded
  in this tree (the copilot token parser reads a different events.jsonl,
  and tasks/ already carries session-lifecycle.jsonl); the file is one
  specific thing — the frames off /event — so name it that.

- Gate it behind an SSE_TRACE module default plus an `sse_trace` instance
  attribute, alongside the timing knobs. Deliberately tier-1 and not a
  policy field in core.toml: it changes nothing about how a run behaves,
  only whether a debugging artifact is written, so it does not belong in
  the run contract every settings file has to carry. Off means the sink
  is never opened, not opened-and-empty.

- Exclude message.part.delta records. The live probe showed 50 deltas
  concatenating byte-exactly to the single message.part.updated text
  already in the file, so each one re-stores text the trace holds — and
  they dominate the frame count on any real turn while logs/ is never
  trimmed by retention, making the cost permanent. Deltas also no longer
  burn a seq, so the numbering matches the lines in the file.
The transcript had no line for session.error: a failing turn just stopped
mid-sentence while the queue put below ended the session. Render one, in
the same role-less hue as the other markers (the colour family is "not a
speaker", not severity), summarizing whatever the payload carries — the
error shape is not pinned on this surface. _emit_event also switches to
the existing _now_ms() helper rather than open-coding the same epoch-ms
conversion, and the sink-open site now records that seq is per session
while the file is per task across retries, so a seq dropping back to 1
mid-file reads as a new session and not a truncation.

Extend the module docstring's API pin list — the stated authority, "this
list wins over memory" — with every /event type the renderer reads, cited
to the 1.18.2 probe posted on PR bmad-code-org#279 (388 frames, cross-checked against
the 89-variant Event union at GET /doc). It records the semantics the
render path depends on (part.updated is complete-once, NOT cumulative;
message.updated re-emits out of order) and, just as importantly, the
three types the renderer names but never actually sees on 1.18.2:
command.executed does not fire for agent tool use, file.edited carries no
sessionID so the filter drops it, and permission.updated does not exist
(it is permission.asked, and replied carries `reply`, not `response`).
Those are live-fire corrections, not cleanups, and land separately.
The 1.18.2 probe found `command.executed` never fires for agent tool use —
0 frames across live bash/write/read/edit. Tool execution arrives on
`message.part.updated` as a `tool`-typed part (`part.tool`, `state.status`
stepping pending → running → terminal). `_inline_line` reads `part.text`,
which tool parts do not carry, so it returned None for every one of them:
the transcript rendered the assistant's prose and none of its actions.

Add the tool branch ABOVE the empty-text bail-out — below it the branch is
unreachable, which is the whole bug — and render on a TERMINAL status so one
tool call is one line. `ToolState` is a 4-variant union (pending / running /
completed / error), so the terminal pair is exhaustive: `error` is in it
because the probe never saw a tool fail and gating on `completed` alone would
drop failed calls silently, which is the one thing a reader goes to the
transcript for. `state.input` is summarized inline (reusing `_sum_args`);
`state.output` is left to the SSE trace, since on this surface it is a whole
command's stdout or a whole file.

`command.executed` KEEPS its branch: it is the slash-command surface, which
is real, and its payload is pinned. The comment now says so explicitly so the
next reader does not "fix" tool rendering back into it.

Once-per-call is a placement claim, so both ablation directions were run:
  (a) delete the tool branch → all five new tests fail, the count one at 0 != 1;
  (b) INVERSE: drop the terminal-status gate so it renders on every transition
      → the once-only test fails on its first intermediate assert (pending
      rendered a line), proving the pending/running frames really do reach the
      branch and the count is not passing vacuously; run to completion it is
      3 != 1, with running and completed rendering identical lines.
`permission.updated` does not exist in 1.18.2 — the branch could never fire.
The live ask frame is `permission.asked`, and its payload is not the shape the
render assumed: `permission` is a STRING and `patterns` a list, not a nested
object with `type` / `pattern`. `permission.replied` carries `reply`, not
`response`, so that line printed `?` on every reply it ever rendered.

Both are now keyed to the live names and fields, with tests pinned to the
payloads captured in the probe. `metadata` (the concrete command) and `always`
stay out of the line and live in the SSE trace; `patterns` is what the
permission actually matched on. The ask and reply lines are labelled
distinctly (`perm ask:` / `perm reply:`) so a reader can pair them.

`permission.v2.asked` / `permission.v2.replied`: NOT consumed, deliberately.
Neither was seen in 388 live frames, and the v2 ask is a different payload
(`action` / `resources` / `save` / `metadata` / `source`), so a branch for it
would be written from the OpenAPI schema alone — which is exactly the kind of
guess that made all three of these corrections necessary. The v2 reply IS
shape-identical to the v1 one, but consuming a reply without its ask logs a
decision with no request, so the two go in together or not at all. The
docstring records the v2 payload so adding them later is a small change.

Also swaps the malformed-frame fixture that used the dead `permission.updated`
name: post-correction the permission branches read straight off `props` (always
a dict), so that shape can no longer reach a `.get` on a non-dict. A tool part
with a non-dict `state` is the render path's live landmine now, so it takes the
slot. Re-ran the S2 guard ablation with the new set — unchanged verdict: the
same five render-path tests fail with AttributeError (the new fixture among
them, at `state.get("status")`) and test_unserializable_properties_ still
passes ablated, since it exercises _emit_event's own catch.
`file.edited` carries no `sessionID` — live payload is exactly
`{"file": "/abs/path"}`, and 1.18.2 pins it `additionalProperties: false` over
that one key. `_dispatch_sse` returns early on
`props.get("sessionID") != sess.session_id` ABOVE both sinks, so the `file:`
branch was unreachable no matter what it rendered.

Add an explicit allowlist of session-less types exempt from that filter. This
is sound ONLY because bmad-loop spawns one `opencode serve` per session: the
server is single-tenant, so a server-global frame is unambiguously this
session's. The comment says so, and says what to do if that ever changes.

Two decisions, both recorded at the code:

- The allowlisted frames enter the SSE TRACE as well as the transcript, since
  `_emit_event` sits under the same filter. Intended: the trace's contract is
  one record per frame the adapter acted on, and it is the file you read to
  explain a transcript line — a rendered line with no matching record would
  make the two sinks disagree. It is affordable precisely because the
  allowlist admits one low-rate type.

- NOT a blanket session-less exemption, and `file.watcher.updated`
  specifically stays out. The session-less traffic is mostly noise that says
  nothing about the run (`plugin.added` alone was 90 of 388 live frames, plus
  heartbeats, `catalog.updated`, `reference.updated`, `integration.updated`).
  The watcher fires for any change under the project — our own git
  operations, a build, an editor save — so it is not evidence the agent did
  anything, and for the changes the agent DID make it just double-reports
  `file.edited`.

The membership test sits outside the try/except that guards the two sinks, so
it narrows with isinstance first: `in` on a frozenset raises TypeError for an
unhashable value and `type` is server-controlled. Ablating that narrowing
fails the new unhashable-type test with exactly that TypeError.

ABLATION: reverting the filter to the plain sessionID comparison fails the two
tests that assert a session-less frame REACHES a sink. The negative tests
(non-allowlisted `plugin.added`, control-queue isolation) keep passing under
it — they assert absence, which holds however the frame was dropped — so the
comment marks them as scope documentation, not reachability evidence.
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Probe corrections landed on the branch (3 commits)

Following up on the live-probe evidence above — the three frame-name findings are now fixed on your branch, on top of your commit and the four adoption commits. Nothing of yours was rewritten; these are follow-ons.

33eb848 — render agent tool calls. This was the big one, and it's the half of your feature that wasn't reaching the log: _inline_line reads part.text, which tool parts don't carry, so the transcript rendered the assistant's prose and none of its actions. Tool use now renders from the tool-typed message.part.updated part, with the branch placed above the empty-text bail-out (below it, it's unreachable — that placement is the bug), gated on a terminal state.status so one tool call is one line:

[bmad] tool: bash {"command": "echo hi"}
[bmad] tool: bash {"command": "false"} -> error: exit status 1

One thing the live probe couldn't see, which the OpenAPI does: ToolState is a 4-variant union — pending / running / completed / error. Nothing failed during the probe, so gating on completed alone would have silently dropped every failed tool call. The gate is the terminal pair, which is exhaustive. state.input goes inline; state.output stays in the trace, since on this surface it's a whole command's stdout or a whole file.

command.executed keeps its branch — it's the slash-command surface, which is real. Its comment now says that explicitly, so nobody "fixes" tool rendering back into it later.

bad91df — permission frames. permission.updated doesn't exist, so that branch could never fire; the ask frame is permission.asked with a permission string + patterns list, and the reply carries reply, not response (so that line printed ? on every reply it rendered). Both are keyed to the live shapes now, labelled perm ask: / perm reply: so a reader can pair them.

We decided not to consume permission.v2.asked / permission.v2.replied. Neither appeared in 388 live frames, and the v2 ask is a genuinely different payload (action / resources / save / source) — writing that branch from the schema alone is exactly the guess that made all three of these corrections necessary. The v2 reply is shape-identical to v1's, but a reply with no ask logs a decision with no request, so they go in together or not at all. The docstring records the v2 shape so it's a small change when someone sees them fire.

594656a — session-less allowlist. file.edited carries no sessionID at all, so the sessionID filter dropped it above both sinks and your file: branch was unreachable. There's now an explicit allowlist of session-less types exempt from that filter — sound only because bmad-loop runs one server per session, which the comment states as a precondition. Two calls recorded there: allowlisted frames enter the SSE trace too (a rendered line with no trace record would make the sinks disagree), and it is not a blanket exemption — plugin.added alone was 90 of 388 live frames, and file.watcher.updated stays out because it fires for any change under the project, not just the agent's.

Testing

+9 tests (2879 passing), trunk clean, pyright 0 errors. "One line per tool" is a placement claim, and it's also what a renderer that emits nothing satisfies — so both ablation directions were run and are recorded in the test file:

  • delete the tool branch → all five tool tests fail, the count one at 0 != 1;
  • inverse: drop the terminal-status gate so it renders on every transition → the once-only test fails on its first intermediate assert, which is the proof that the pending and running frames really do reach the branch and the count isn't passing vacuously.

Docs and CHANGELOG are next — they describe what the transcript renders, which is exactly what changed here, so they were deliberately held until after this. The PR comes out of draft with that.

pbean added 2 commits July 25, 2026 12:13
The sink split shipped in this series was documented nowhere outside the
adapter module. Cover it at the three altitudes the docs already use:

- `adapter-authoring-guide.md` — the opencode worked example gains the sink
  split as a design lesson worth stealing: a transport with no pane still owes
  the operator a transcript, and you get one by curating it off the event
  stream the adapter already consumes for control, rather than by inventing a
  second channel. Names what each sink is for and the catch that comes with
  curating — what you render is only as good as the frame names you pinned.

- `FEATURES.md` / `setup-guide.md` — one sentence each, at the density those
  files already run: which three files appear and what lands in each, so an
  operator watching a run knows where to look.

None of them restate the `/event` pin list; that stays in the module docstring
as its single copy.

CHANGELOG gets one `### Added` entry under `[Unreleased]` crediting
@jackmcintyre, referencing bmad-code-org#306.
Full-branch review before un-drafting. The code's altitude is right for the
problem — three sinks, one renderer, one allowlist, no new config surface, no
new infrastructure — but the COMMENTS outgrew it: 34% of the added lines are
pure comment against 14% in the file they were added to, and the excess is
duplication, not explanation.

The live-probed `/event` facts were restated three and four times over — the
`file.edited` payload in the module docstring, at `_SESSIONLESS_TYPES`, and
again at the `_dispatch_sse` filter; the permission field names and the
tool-part ordering rule in the module docstring and again in
`_render_inline`'s. `_render_inline` was the worst of it: it told the reader
to go read the module docstring and then recapped its contents anyway.

That duplication is a liability rather than redundancy, because these facts
are the ones that go stale — this branch already had to correct three guessed
frame names, and the next version bump will re-verify them against exactly one
place or silently leave a stale copy behind.

Cut the restatements at both sites; keep the pointers, and at `_render_inline`
keep the REASON the single copy matters. Local comments that explain why THIS
code is shaped the way it is — the tool branch sitting above the empty-text
bail-out, the isinstance narrowing ahead of the frozenset test, the ablation
notes — are consequence rather than contract and stay where they are.

Comments only; no behavior change. 79 adapter tests pass unchanged.
@pbean
pbean marked this pull request as ready for review July 25, 2026 19:13

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/adapters/opencode_http.py`:
- Around line 493-507: Guard the sink-opening sequence in `_spawn_server` so any
failure opening `event_fh` or `server_fh` closes every handle already opened,
including `log_fh`, before propagating the original exception. Update
`_close_spawn_sinks` to close each handle independently and ignore `OSError`,
matching `_teardown` so close failures cannot mask the primary error.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 168ee193-6dd0-4650-8505-012d8b63e87d

📥 Commits

Reviewing files that changed from the base of the PR and between 2a614a8 and d9dab61.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • docs/FEATURES.md
  • docs/adapter-authoring-guide.md
  • docs/setup-guide.md
  • src/bmad_loop/adapters/opencode_http.py
  • tests/test_opencode_http.py

Comment thread src/bmad_loop/adapters/opencode_http.py Outdated
CodeRabbit review on bmad-code-org#279, both halves confirmed against the code.

All three per-task sinks open ABOVE the retry loop's try/except. An `open()`
that fails partway — ENOSPC, EMFILE, a permission race — propagates straight
out of `_spawn_server` without ever reaching that handler, so the handles
already opened are never closed and stay open for as long as the propagating
traceback keeps the frame alive. This series introduced the exposure: with one
sink there was nothing open at that point to leak, with three there is. Guard
the two later opens and route them through the same `_close_spawn_sinks`.

`_close_spawn_sinks` also closed each handle unguarded, unlike `_teardown`.
Both of its callers are already reporting a failure — one is mid-`raise`, the
other is about to raise `OpencodeServerError` — so an OSError from a
flush-on-close would replace the failure actually worth reporting, and would
strand the sinks after it. Close each one independently, swallowing OSError,
and skip None: `event_fh` is None when the sse_trace knob is off, and either of
the last two is None when the failure WAS the open that would have created it.

CodeRabbit's suggested diff deleted the append-semantics and event_seq comments
along the way; the fix keeps them.

ABLATIONS, both run:

- Drop the try/except around the two later opens and
  `test_spawn_open_failure_closes_the_sinks_already_open` fails on the leaked
  `.log` and `.sse.jsonl` handles. It asserts a list of leaks is empty, so it
  had to be checked — the assertion holds for every reason the handles could be
  absent, including never having been opened, which is why it also asserts both
  sinks ARE in the opened set first.

- Restore the unguarded closes and `test_close_spawn_sinks_survives_a_failing_close`
  fails with the OSError escaping. The fixture makes the FIRST sink the one that
  fails to close, so the test pins both properties at once: the error does not
  propagate, and the two sinks after it still get closed.
@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this, @jackmcintyre — and sorry it took a while to come back to you.

This was a real gap, and you found it in the right place. opencode-http was the only adapter whose finished runs were opaque: the claude/tmux adapters get a transcript for free by replaying the pane, and nobody had noticed that the HTTP adapter's <task>.log was just opencode serve's INFO stdout. Your framing of the fix is the part that made it adoptable — curating the transcript off the SSE dispatch the adapter already consumes for control, instead of inventing a second channel. That's now written up in the adapter authoring guide as a design lesson for the next non-tmux transport.

We took you up on the "check the shape is welcome before going further" and pushed the rest onto this branch directly (maintainerCanModify), so your e955c17 is still the base of the series with your authorship. Companion issue is #306.

What we added on top, roughly in order:

  • A live probe of opencode 1.18.2's /event — 388 frames, cross-checked against the server's own OpenAPI at GET /doc (89-variant Event union). Full evidence is in the comments above. It confirmed your core design decision: message.part.updated is complete-once, not cumulative, so rendering on it needs no de-duplication.
  • Three frame names the probe refuted. command.executed never fires for agent tool use (it's the slash-command surface) — tool use rides message.part.updated with part.type == "tool", which meant the transcript rendered no tool activity at all. permission.updated doesn't exist; it's permission.asked, and the reply carries reply, not response. And file.edited carries no sessionID, so the session filter dropped it above the render call. All three fixed, one commit each so the series reads against the evidence.
  • Hardening: the spawn give-up error now names .server.out (the file that actually holds the diagnostics) rather than the now-empty transcript; a guard so a render bug can never tear down the SSE reader and degrade completion signalling.
  • One rename you should know about: <task>.events.jsonl<task>.sse.jsonl, because events.jsonl was already taken by the copilot token parser for a different file. The "How" section of your description still says the old name. It's also gated now behind an sse_trace knob, with the per-token deltas dropped (they concatenate byte-exactly to the part.updated text already in the trace).
  • Docs across the authoring guide / FEATURES / setup-guide, and a CHANGELOG entry.

CI is 9/9 green and CodeRabbit's one finding (a file-handle leak in the spawn path — genuinely ours, introduced when we went from one sink to three) is fixed and confirmed.

Leaving the merge to the maintainer. If you want to keep going on this adapter, the permission.v2.* variants are the obvious next thing — deliberately left alone because the v2 ask is a different payload shape and we didn't want to write another branch from the schema alone.

@pbean

pbean commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pbean
pbean merged commit 98c1319 into bmad-code-org:main Jul 25, 2026
10 checks passed
@jackmcintyre

Copy link
Copy Markdown
Contributor Author

Thanks @pbean, happy to have been able to contribute

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.

opencode-http: finished runs are opaque — readable transcript + server-stdout split + gated SSE trace

2 participants