agents proxy: opencode M2 — prompts bridge to the hosted session, answers stream back - #1948
Conversation
…wers stream back
prompt_async (and the sync /message variant, treated as fire-and-forget)
concatenates text parts into SendInput and tracks the returned per-turn
run id; the /global/event handler drains StreamSession and translates
canonical events into the opencode frame sequence, verified against a
ground-truth capture of a real `opencode serve` turn:
user echo (message.updated + text part) -> session.status busy ->
assistant message.updated -> part-created-before-first-delta ->
message.part.delta trail -> finalized text part -> assistant
message.updated with time.completed -> session.status idle -> session.idle
Three of those are load-bearing, found by testing against the real TUI:
a delta for a part no message.part.updated created renders nothing;
an assistant info without cost/tokens crashes the TUI's message list
('undefined is not an object evaluating tokens.output'); and a missing
time.completed leaves the turn looking in-flight. Reasoning streams as
its own part with field "reasoning".
Live-verified against the dev stack: a prompt typed in a real opencode
1.18.25 TUI streams the hosted agent's answer back into the TUI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… order) The TUI orders messages lexicographically by id; opencode ids embed a 48-bit time value T = ((unix_ms << 12) | counter) mod 2^48 (decoded from a real server's ids, verified to the bit — session ids use ~T for newest-first). Run-UUID-derived ids sort randomly, which rendered the assistant's answer above the user's prompt. Ids are now minted with the real encoding, and the assistant id is forced to sort strictly after the user id in T-space — the user id's prefix comes from a different clock (the client's, or the proxy's on fallback) than the harness event time, and the dev stack's container clock runs ~half a second behind the host. Comparing a truncated T against an untruncated millisecond count silently never fires; the regression test pins the skew scenario. Live-verified: the TUI now renders user prompt above the streamed answer, matching a real opencode server's layout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
A few concrete issues (notably potential unbounded in-memory turn retention and some inconsistencies in logging/tests) should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds M2 “prompt bridge” and live event-stream translation so an unmodified opencode TUI can send prompts to a hosted agents session and receive streamed answers back via /global/event.
Changes:
- Implemented bridged session endpoints (
POST /session,GET /session*) and prompt forwarding (POST /session/{id}/prompt_asyncand/messagetreated as async). - Added a StreamSession-driven SSE event loop translating hosted canonical events into opencode frame sequences (including part-create-before-delta, tokens/cost fields, and completion signaling).
- Expanded/updated unit tests to cover the prompt bridge, frame sequencing, failure paths, and session lifecycle.
File summaries
| File | Description |
|---|---|
| internal/agentproxy/opencode/facade.go | Adds bridged session routes and replaces the one-off SSE send with an eventWriter plus a live StreamSession event loop. |
| internal/agentproxy/opencode/facade_test.go | Updates SSE tests to use a wired harness now that /global/event drains the live event loop. |
| internal/agentproxy/opencode/bridge.go | New harness bridge: prompt forwarding, turn tracking, and hosted-event → opencode-frame translation logic. |
| internal/agentproxy/opencode/bridge_test.go | New tests validating prompt bridging and the full streamed frame sequence (including ordering and ids). |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| f.mu.Lock() | ||
| if f.turns == nil { | ||
| f.turns = make(map[string]*turnState) | ||
| } | ||
| f.turns[resp.RunID] = &turnState{userMsgID: userMsgID, promptText: text} |
| } | ||
| return frames |
| }); err != nil { | ||
| return err | ||
| } | ||
| return f.emitIdle(sid, ew) |
There was a problem hiding this comment.
opencode TUI uses time.completed to know “this message is done.” Without it, the UI can keep treating the assistant reply like it’s still running (spinner / QUEUED), even though the run already failed. Worth calling finishAssistantMessage on the failed path too
There was a problem hiding this comment.
Done in ce005d9 — run.failed now closes the assistant message (time.completed) before session.error, so a failed reply no longer renders in-flight. One nuance propagated up the stack: from M4 on, finishAssistantMessage stamps finish:"stop", which a failed turn shouldn't claim — the M4 branch adds a failed flag so failed turns get time.completed but no finish reason, matching the history reconstruction's failed-turn shape. Test updated to pin both. (Also picked up Copilot's three notes in the same commit: bounded turn tracking, log-prefix consistency, and scanner-error surfacing in the test helper.)
- Close the assistant message (time.completed) on run.failed too — the TUI keys "this message is done" off it, so a failed run otherwise leaves the reply looking in-flight forever (review: SSharma-10). - Bound the tracked-turn map (evict oldest past 16): entries are normally deleted by terminal events, but a facade whose event stream is never drained must not retain every prompt's text unboundedly. - Consistent "agentproxy/opencode:" log prefix on the unhandled-event line. - drainFrames surfaces scanner errors instead of silently truncating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follows #1942 (M0/M1, merged). Second slice of the opencode facade: the first demo checkpoint — a prompt typed in a real, unmodified
opencodeTUI streams the hosted agent's answer back into the TUI. This also delivers exactly what @SSharma-10's M1 verification flagged:POST /sessionand prompt bridging.What's here
POST /session/{id}/prompt_asyncconcatenates the body's text parts intoSendInputand tracks the returned per-turnrun_id. The synchronousPOST /session/{id}/messagevariant is accepted as fire-and-forget (logged; current TUIs useprompt_async). Non-text parts are logged and dropped./global/eventhandler drainsStreamSessionand translates canonical events into the opencode frame sequence. Single writer by construction (the SSE handler goroutine), so no notifier mutex — a comment marks M5's HITL work as the point where that changes.POST /session/GET /session/GET /session/{id}map every client-side session onto the one bridged hosted session.The translation was captured, not guessed — and the capture mattered
A first version built only from the OHR adapter's inverse mapping tables bridged the turn correctly (the hosted run executed and completed) but rendered nothing in the TUI. Ground truth from a real
opencode serveturn (logging relay +curl -N /global/event) surfaced three load-bearing requirements, each confirmed against the real client:message.part.updatedwith empty text, then deltas. A delta for an unannounced part silently renders nothing.message.updatedinfo must carrycostandtokens{...}— withouttokens.outputthe 1.18.25 TUI crashes (undefined is not an object). Zeros are fine; absent is fatal.message.updatedcarryingtime.completed— otherwise the TUI leaves the turn looking in-flight.Full verified sequence: user echo (message + prompt part) →
session.status busy→ assistant message → part-create → delta trail → finalized part → completed message →session.status idle→session.idle. Reasoning tokens stream as their own part withfield:"reasoning".Testing
agentproxytestharness; failure path (run.failed→session.error→ idle); untracked-run skipping; prompt validation; session list lifecycle.opencode attach+ typed prompt rendered the hosted agent's streamed answer (verified by replaying the pty capture through a terminal emulator), with no unhandled routes during the turn.Not in this PR
History on re-attach (M3), tool-call parts and real usage/cost in
step-finish(M4), permission round-trips (M5), stream reconnect (M6).🤖 Generated with Claude Code