Repository navigation
feat(opencode-agents): emit the AG-UI text and tool subset - #162
Conversation
Add a process-wide configure(options=SDKOptions) API on the lifecycle singleton. Handlers read capture_content when they run, and an explicit True or False overrides the configured default.
Reduce OpenCode session events into text and tool events. config().stream() stays chunk then done, so a tool event does not close it. The experimental rich_stream reader is how a caller opts into those events.
Move the OpenCode handler into launchdarkly-ai-opencode-agents and export rich_stream from launchdarkly_ai_server. The handler reduces caller-supplied session events and does not depend on an OpenCode SDK. Assistant text waits until the message role is assistant, and a part snapshot emits only text that has not already been sent.
init_client() discovers launchdarkly-server-sdk at runtime, so the package README install line has to include it. Co-authored-by: alexis <alexis@launchdarkly.com>
The public stream matches the other handlers. This change does not export a rich stream reader or forward tool events.
Handlers can yield the AG-UI text and tool subset. config().stream() still yields chunk then done. stream() is the opt-in entry. Co-authored-by: alexis <alexis@launchdarkly.com>
A handler that raises mid-call can still report the lifecycle without the tool result. Co-authored-by: alexis <alexis@launchdarkly.com>
Co-authored-by: alexis <alexis@launchdarkly.com>
OpenCode session events map onto the shared reader. config().stream() still yields chunk then done, and capture_content gates tool payloads. Co-authored-by: alexis <alexis@launchdarkly.com>
|
bugbot run |
|
bugbot run |
A tool part that arrives before message.updated stays with the held text. Assistant messages flush text first, so the call can name its parent. User messages drop the call. Co-authored-by: alexis <alexis@launchdarkly.com>
close() emitted a tool whose role never arrived while an assistant message was still open, so tool_start copied that message into parentMessageId. End the text message first. Co-authored-by: alexis <alexis@launchdarkly.com>
|
bugbot run |
jeffdupont
left a comment
There was a problem hiding this comment.
Reviewed at ae29f0f. 16 opencode tests + test_agui.py (31 total) pass; ruff and mypy clean. Ran the handler through both config().stream() (still chunk, chunk, done) and agui.stream() (RUN_STARTED → text → tool START/ARGS/END/RESULT → text → RUN_FINISHED) and both look right.
Scope note: commit 32c92a3 merges cursor/agui-reader-7ca3 in, so 1,166 of the 1,345 added lines are #164's files, byte-identical. This PR's own change is handler.py and tests/test_handler.py; that's all this review covers. #164 needs to land first and this one rebased/retargeted, or the reader merges twice.
Should fix
1. A late update for an earlier message splits the current message's text (handler.py:253-254, :130-135)
Every assistant message.updated calls _begin_message, which closes the open text. Reproduced: m1 text, m2 text "B1", then m1 completed, then m2 text "B2" gives msg-2 = "B1" and msg-3 = "B2", and m2's tool attaches to msg-3. (Unverified whether OpenCode sends that order.)
Fix: call _begin_message only when the update is for self._text_message_id or has no time.completed. Add the sequence as a test.
2. No test that capture_content=False hides tool content (tests/test_handler.py:107, :151-156; wiring at handler.py:362)
collect() always uses capture_content=True, and test_capture_content_follows_configure only checks the attribute. Tool args/results (bash commands, file contents) are exactly what the gate protects. It works today (checked by hand), but nothing fails if the wiring breaks.
Fix: one test with capture off, a running → completed tool, asserting no TOOL_CALL_ARGS and neither input nor output in str(events).
3. For a message that never gets a role, its tool is emitted but its text is dropped (handler.py:144-148 vs _held, :94-96)
Reproduced: text plus a completed tool on unlabeled m9 gives START/ARGS/END/RESULT and done.output == "", with the tool after all other messages. Pick one rule: dropping unlabeled tools like unlabeled text deletes :144-148; otherwise flush _held too. test_an_unlabeled_tool_does_not_inherit_an_open_message encodes the current choice.
4. _started_tools / _resulted_tools duplicate TurnEmitter's own dedupe (handler.py:49-50, 227, 229, 233, 236-237, 240)
TurnEmitter already ignores repeated start/end/result (agui.py:141, 168, 190); removing the sets gives identical output in every branch. Delete both, keeping only the "input" in state check on running. ~−7 lines.
Nice to have
handler.py:263-273re-readsinfo.get("time")after checking it; keep onlyif message_id == self._text_message_id:. ~−5 lines.agents.md:11still says "The public stream ischunkthendone" (now true only ofconfig().stream()).test_text_around_a_tool_is_chunks_then_doneandtest_a_failed_tool_does_not_leak_into_the_streamnow assert the opposite of their names.
Spec/parity: there's no AG-UI spec in the monorepo and no js-ai-sdk counterpart; opencode-agents.* / client.agui.stream usage ids (handler.py:320, 336, agui.py:537) aren't in api-surface/usage-ids.txt. Same gap as #164.
Verdict: fix 1 and 2; decide 3; 4–6 are cleanup. Not checked: real OpenCode event ordering behind 1 and 3, and whether callID is always present (part.get("id") fallback at handler.py:221).
… completes A completion for an earlier assistant message was closing the message still being read. Capture-off now has a test that tool payloads stay out of the stream. Co-authored-by: alexis <alexis@launchdarkly.com>
|
bugbot run |
Co-authored-by: alexis <alexis@launchdarkly.com>
|
bugbot run |
events receives the config, input, variables, history, and session id. Other sessions are dropped and session.idle ends the run. A judged config must set skip_judges=True. The run opens an invoke_agent span.
Reduce OpenCode session events into text and tool events. config().stream() stays chunk then done, so a tool event does not close it. The experimental rich_stream reader is how a caller opts into those events.
Move the OpenCode handler into launchdarkly-ai-opencode-agents and export rich_stream from launchdarkly_ai_server. The handler reduces caller-supplied session events and does not depend on an OpenCode SDK. Assistant text waits until the message role is assistant, and a part snapshot emits only text that has not already been sent.
init_client() discovers launchdarkly-server-sdk at runtime, so the package README install line has to include it. Co-authored-by: alexis <alexis@launchdarkly.com>
The public stream matches the other handlers. This change does not export a rich stream reader or forward tool events.
events receives the config, input, variables, history, and session id. Other sessions are dropped and session.idle ends the run. A judged config must set skip_judges=True. The run opens an invoke_agent span.
message.updated carries sessionID on info, and message.part.updated carries it on part. A foreign event no longer latches the run.
jeffdupont
left a comment
There was a problem hiding this comment.
Re-checked at 1029dc3. The session scoping and the text-before-tools ordering are fixed. Still open:
- Blocking: a late completion for an earlier message drops that message's held text and tools (inline). A tool that actually ran can vanish with no event and no error.
test_a_late_completion_does_not_split_the_open_message(tests/test_handler.py:296) asserts the drop, so it needs to change with the fix. - This now conflicts with its base after #159 was rebased onto
main. has_startedinagui.pybelongs in #164, the shared reader; this branch still carries #164's oldb7a9f3a.
| ) | ||
| if late_completion: | ||
| self._held.pop(message_id, None) | ||
| self._held_tools.pop(message_id, None) |
There was a problem hiding this comment.
This discards whatever is held for the earlier message. Not splitting the open message is right, but the held text and tools should still come out, e.g. emitted after the open text ends, or as their own message. Dropping a tool call loses a step the agent really took.
There was a problem hiding this comment.
Fixed in a06988b.
A late completion no longer drops the earlier message. The open message stays one message (B1 and B2 still share it). The held text and the tool that already ran are emitted after that text ends, as their own message: stale then c3 with content old.txt, parented to that message, not to the one that was open. test_a_late_completion_does_not_split_the_open_message now asserts that instead of the drop.
The handler branch contains main. config().stream() stays chunk then done. The handler stream remains the AG-UI text and tool subset.
|
bugbot run |
A completion for an earlier message no longer drops its held text and tools. The open message stays whole. Those parts are emitted once that text ends, as their own message.
|
@cursor review |
The merged handler requires session_id and does not adopt the first session on a shared stream. This branch keeps the AG-UI text and tool subset. config().stream() stays chunk then done.
|
@cursor review |
jeffdupont
left a comment
There was a problem hiding this comment.
Re-checked at 71703ea. All three points from 1029dc3 are fixed: a late completion now parks its held text and tools and emits them after the open text ends (a06988b, and the test asserts it), the base conflict is gone, and agui.py is no longer in the diff. 34 opencode tests pass locally.
Still open:
- Blocking: late text for an earlier message still splits the open message (inline, same as Bugbot's thread). Reproduced: m1 "A", m2 "B1", a repeat snapshot of m1 "A" (adds nothing), m2 "B2" puts B1 and B2 in different AG-UI messages. A late delta with new text for m1 does the same. (Unverified whether OpenCode sends text that late.)
test_judge_gate_uses_the_effective_boolinpackages/client/tests/test_configure.pyis a leftover from the old6be2bb0, carried in byfed0cf1. It copies thejudges.pyexpression instead of calling it, so it can't fail, and it isn't OpenCode work. Please drop it (inline).- With the fix for 1,
close()should also emit held text for assistant messages that never completed, the way_emit_parkeddoes, or that text is lost at session end.
| if role != "assistant": | ||
| return [] | ||
| return self._apply_text(part_id, kind, text) | ||
| events = self._begin_message(message_id) |
There was a problem hiding this comment.
This ends the open message whenever the text belongs to a different one, even when _apply_text then emits nothing. Suggest: if self._text_open and message_id != self._text_message_id, append to self._held[message_id] and return []. The late-completion path already moves _held into _parked. Regression test:
assistant_open("m1"), text_delta("m1", "p1", "A"),
assistant_open("m2"), text_delta("m2", "p2", "B1"),
bus("message.part.updated", part={"id": "p1", "messageID": "m1", "type": "text", "text": "A"}),
text_delta("m2", "p2", "B2"),
assistant_completed("m2"),
# B1 and B2 should share one messageId| assert _handler(None).capture_content is True | ||
|
|
||
|
|
||
| def test_judge_gate_uses_the_effective_bool() -> None: |
There was a problem hiding this comment.
Leftover from the old 6be2bb0; not on main. It re-implements judges.py:188 rather than exercising it, so it passes whatever judges.py does. Please drop these 8 lines from this PR.
Text for an earlier message no longer closes the message that is streaming. It is emitted after that message ends. Session end emits assistant text that never completed. Drop the leftover configure test.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 079f887. Configure here.
A part that arrives after the blocking message ends applies the buffered delta first. A later snapshot or close no longer emits it again.

OpenCode session events map onto the shared AG-UI reader. The handler emits text and tool lifecycle events;
config().stream()still yieldschunkthendone. Argument text and results followcapture_content.Verified with the OpenCode handler tests. This branch includes the reader so the stable stream keeps dropping tool events. It does not change #159.