Skip to content

agents proxy: opencode M2 — prompts bridge to the hosted session, answers stream back - #1948

Merged
SSharma-10 merged 3 commits into
feat/agents-subcommandsfrom
juliaye/opencode-proxy-m2
Sep 17, 2026
Merged

SSharma-10 merged 3 commits into
feat/agents-subcommandsfrom
juliaye/opencode-proxy-m2

Conversation

@julia-ye

@julia-ye julia-ye commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Follows #1942 (M0/M1, merged). Second slice of the opencode facade: the first demo checkpoint — a prompt typed in a real, unmodified opencode TUI streams the hosted agent's answer back into the TUI. This also delivers exactly what @SSharma-10's M1 verification flagged: POST /session and prompt bridging.

What's here

  • Prompt bridge: POST /session/{id}/prompt_async concatenates the body's text parts into SendInput and tracks the returned per-turn run_id. The synchronous POST /session/{id}/message variant is accepted as fire-and-forget (logged; current TUIs use prompt_async). Non-text parts are logged and dropped.
  • Event loop: the /global/event handler drains StreamSession and 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.
  • Session synthesis: 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 serve turn (logging relay + curl -N /global/event) surfaced three load-bearing requirements, each confirmed against the real client:

  1. A part must be created before its first delta — message.part.updated with empty text, then deltas. A delta for an unannounced part silently renders nothing.
  2. Assistant message.updated info must carry cost and tokens{...} — without tokens.output the 1.18.25 TUI crashes (undefined is not an object). Zeros are fine; absent is fatal.
  3. The turn must close with an assistant message.updated carrying time.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 with field:"reasoning".

Testing

  • Unit: the full frame sequence (order + ids + per-frame fields) against the shared agentproxytest harness; failure path (run.failed → session.error → idle); untracked-run skipping; prompt validation; session list lifecycle.
  • Live: against the local dev stack, a real 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

julia-ye and others added 2 commits September 3, 2026 21:29
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_async and /message treated 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.

Comment on lines +229 to +233
f.mu.Lock()
if f.turns == nil {
f.turns = make(map[string]*turnState)
}
f.turns[resp.RunID] = &turnState{userMsgID: userMsgID, promptText: text}
Comment thread internal/agentproxy/opencode/bridge.go
Comment on lines +79 to +80
}
return frames
}); err != nil {
return err
}
return f.emitIdle(sid, ew)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@SSharma-10
SSharma-10 merged commit ef65fd2 into feat/agents-subcommands Sep 17, 2026
2 checks passed
@SSharma-10
SSharma-10 deleted the juliaye/opencode-proxy-m2 branch September 17, 2026 05:54
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.

3 participants