Skip to content

feat(opencode-agents): add an OpenCode handler - #159

Merged
apucacao merged 8 commits into
mainfrom
cursor/opencode-handler-a60f
Oct 15, 2026
Merged

apucacao merged 8 commits into
mainfrom
cursor/opencode-handler-a60f

Conversation

@apucacao

@apucacao apucacao commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds launchdarkly-ai-opencode-agents, a normal handler package with provides_for ('*', 'agent'). It reduces caller-supplied OpenCode session events and does not depend on an OpenCode SDK.

config().stream() yields chunk then done. Content capture uses the tri-state read from #158.

Tests

pytest (2416 passed, 11 skipped), ruff check, ruff format --check, and mypy packages/*/src were clean.

Open in Web Open in Cursor 

@cursor

cursor Bot commented Oct 12, 2026

Copy link
Copy Markdown

bugbot run

1 similar comment
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Comment thread packages/client/src/launchdarkly_ai_server/experimental/_opencode.py Outdated
Comment thread packages/client/src/launchdarkly_ai_server/experimental/_opencode.py Outdated
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@apucacao apucacao changed the title feat(opencode): add an experimental OpenCode handler feat(opencode-agents): add an OpenCode handler Oct 13, 2026
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

1 similar comment
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@apucacao

Copy link
Copy Markdown
Contributor Author

cursor review

@apucacao
apucacao marked this pull request as ready for review October 13, 2026 02:43
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Comment thread packages/opencode-agents/README.md Outdated
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

@apucacao
apucacao added this pull request to stack #160 October 13, 2026 02:58
@apucacao
apucacao marked this pull request as draft October 13, 2026 03:03
@apucacao
apucacao marked this pull request as ready for review October 13, 2026 03:05
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

@jeffdupont jeffdupont 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.

Reviewed at ec7b918 (own diff on top of #158). Package tests pass (uv run pytest packages/opencode-agents, 13 tests). Finding 3 (session mixing) and 10 were reproduced with a script against this branch.

Must fix

1. The AI Config and the user's input never reach OpenCode (handler.py:263-277, :201-209)
The stream function receives config, user_input, variables, history and tool_handlers, then calls events() with no arguments. In the README example, "Hello" is discarded (README.md:15); changing the model or instructions in the flag has no effect, and multi-turn history is lost. Misses TESTING.md §1.2, §1.3/§1.4, §1.11.
Fix: pass them through, e.g. events(config, user_input, variables, history), and add a test asserting events received them.

2. Judges get routed to this handler (handler.py:298-299 with client/judges.py:36-41,110,142, client.py:147,238)
It claims ('*', 'agent'), and judges use a single handler that covers their provider. A judged config sends the judge prompt here, which is ignored (see 1), and events() runs again: either another full OpenCode run or a replay of the agent answer. parse_judge_response then gets agent text, so the score is None or wrong.
Fix: only claim '*' once the handler actually runs the prompt it's given; until then, at least document and test skip_judges=True.

3. Events aren't scoped to one session, and the stream may never end (handler.py:72-79, :119-137)
The reducer never checks sessionID. Reproduced: an assistant delta from a second session on the same server appears in this run's output ('HiHi OTHER'), and a session.error from any session fails this run (:76-77). Unverified: if OpenCode's /event is a server-wide SSE stream that doesn't close per session (end of session = session.idle, which is ignored here), passing the subscription straight in means done never arrives.
Fix: give events the session id, drop other sessions' events, and return on that session's session.idle. If out of scope, README and agents.md must say the caller has to filter and terminate the iterable.

Should fix

4. Ships as a stable package with no spec section or API surface (release-please-config.json, .release-please-manifest.json, pyproject.toml)
Commit df46a04 switched it from experimental to a normal package. TESTING.md has no OpenCode section; there's no api-surface/python/launchdarkly_ai_opencode_agents.txt; usage-ids.txt has no opencode-agents.* ids (§3.27 wants them in the same PR, §5.2 wants the list merged before first release); no A.14 row.
Fix: monorepo PR first (spec section with stage marker, surface list, usage ids), or ship experimental as 60c14bd did.

5. No JS counterpart. Nothing in js-ai-sdk for OpenCode. Either widen the ticket or record in the spec why it's Python-only (as A.14 does for Vercel/TS-only).

6. No spans (whole handler.py; spec §1.5, §1.6, §1.8)
The other six handlers emit invoke_agent/chat/execute_tool spans with launchdarkly.* attributes. Per §1.5, a run without launchdarkly.operation.type won't show in the AI observability dashboard.
Fix: reuse the client span helpers for at least one invoke_agent root span; add the package to HANDLERS in tests/test_cross_handler_parity.py.

7. A missing events fails late as the wrong error (handler.py:201-205, :212-216)
create_opencode_handler() with no args succeeds; the first request raises OpenCodeSessionError, the same class as a provider failure. Fix: make events a required keyword-only argument (TypeError at build time) and delete the None branch.

8. Wrapper and usage reporting untested (handler.py:228,232-255)
No tests/test_sdk_usage.py like every other package (e.g. openai-agents/tests/test_sdk_usage.py), and no §1.7 test that variables is popped and forwarded.

9. Docs (README.md, agents.md; spec §6.1, §6.2)
The README example uses an undefined session_events and never iterates .stream(...). agents.md lacks the file map, OTel, init_client, dependencies and "common pitfalls" sections; pitfalls is where 1–3 belong.

Nice to have

  1. A delta without partID followed by its part snapshot duplicates text ("HiHi") (handler.py:99-103, :146-149). Drop deltas without partID, as snapshots already require id (:159).
  2. Include properties.error.name (e.g. ProviderAuthError) in the session error (handler.py:76-77); keeping the provider body out is right.

Verdict: fix 1, 2 and 3 first; 4 and 5 need an owner decision. Not checked: OpenCode's real event schema (message.part.delta, whether tokens.input excludes cache, whether tokens.reasoning counts as output); the judge path at runtime (from reading the code).

@apucacao

Copy link
Copy Markdown
Contributor Author

Addressed Jeff's review in 449d871.

  1. events(config, user_input, variables, history, session_id) is called on every run. events is required.
  2. provides_for stays ('*', 'agent'). The handler still does not run the prompt it is given, so a judged config must pass skip_judges=True. README, agents.md, and a test cover that.
  3. A passed session_id, or the first sessionID seen, is the session. Other sessions are dropped, including their session.error. session.idle for this session ends the run. agents.md says the caller has to close the iterable when idle never arrives.
  4. The spec, surface list, and usage-ids row live in ai-sdks-monorepo, not this repo. This stays a normal pre-v1 package. agents.md records the helper ids.
  5. agents.md records that this slice is Python-only.
  6. A run opens an invoke_agent span with launchdarkly.operation.type. The package is in the cross-handler span parity list.
  7. Omitting events raises TypeError at construction.
  8. tests/test_sdk_usage.py covers both helpers. The wrapper test checks that variables, history, and session id are forwarded.
  9. The README example defines session_events and iterates .stream(...). agents.md has the file map, dependencies, init_client, OTel, and the pitfalls above.
  10. A text delta without partID is dropped.
  11. session.error includes error.name and still omits the provider body.

rich_stream is not in this PR.

@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

@apucacao

Copy link
Copy Markdown
Contributor Author

Bugbot on 449d871 is fixed in 72708e3.

_session_id now reads properties.sessionID, properties.info.sessionID, and properties.part.sessionID. A message.updated or message.part.updated from another session no longer sets roles or usage, and it cannot latch the run so this session's session.idle is dropped.

@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Base automatically changed from cursor/configure-sdk-defaults-7168 to main October 14, 2026 17:23
cursoragent and others added 7 commits October 14, 2026 10:23
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.
@apucacao
apucacao force-pushed the cursor/opencode-handler-a60f branch from 72708e3 to c03b511 Compare October 14, 2026 17:23

@cursor cursor 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.

Stale Bugbot comment from a previous run.

messages.append(text_message(role, content))
if isinstance(user_input, str):
messages.append(text_message("user", user_input))
return messages

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Span input drops structured history

Low Severity

_input_messages only records history items whose content is a string, so the documented block-array history shape never reaches set_input_content_attributes. System turns are also kept, and an empty user_input is appended. The invoke_agent span therefore omits multimodal or structured history that other handlers flatten with content_to_text after compose_history.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c03b511. Configure here.

@jeffdupont jeffdupont 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.

Re-checked after the rebase onto main. The OpenCode package is unchanged since 72708e3 and the nested session-id fix is in. One item is still open (inline). I agree with Bugbot's Low finding on structured history at handler.py:296; it can be a follow-up.

session_id = _session_id(raw)
if session_id is not None:
if self._session_id is None:
self._session_id = session_id

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.

Without a session_id, the first session that shows up in the event stream wins. On a server that shares one OpenCode event stream across requests, that can be another user's session, so this run would report their answer. Please require session_id (raise if it's missing) rather than latching onto the first one seen.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c934e2d.

session_id is required. Omitting it, or passing None or "", raises TypeError before a run starts. The reducer no longer adopts the first sessionID on the stream. Events from any other session are ignored, so a shared OpenCode stream cannot report another user's answer. test_session_id_is_required and test_the_first_session_on_the_stream_is_not_adopted cover that.

A shared OpenCode event stream must not latch onto the first session
it sees. session_id is required, and every other session is ignored.
@apucacao

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c934e2d. Configure here.

@apucacao
apucacao requested a review from jeffdupont October 14, 2026 23:59

@jeffdupont jeffdupont 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.

Re-checked at c934e2d: session_id is required on both entry points, and the reducer no longer adopts the first session on the stream. With the session filter removed, test_the_first_session_on_the_stream_is_not_adopted and two other session tests fail, so the fix is pinned. OpenCode suite: 25 passed; ruff and mypy are clean.

Non-blocking: events with no sessionID still pass the filter (documented in the reducer docstring). That's fine while events returns one session's stream, as the README requires.

@apucacao
apucacao merged commit fc08e8c into main Oct 15, 2026
7 checks passed
@apucacao
apucacao deleted the cursor/opencode-handler-a60f branch October 15, 2026 00:25
@github-actions github-actions Bot mentioned this pull request Oct 14, 2026
apucacao added a commit that referenced this pull request Oct 15, 2026
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
OpenCode session events map onto the shared AG-UI reader. The handler
emits text and tool lifecycle events; `config().stream()` still yields
`chunk` then `done`. Argument text and results follow `capture_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.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-8f118cf9-ed13-58e7-a6ab-e576119e7ca3?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-8f118cf9-ed13-58e7-a6ab-e576119e7ca3&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
@jeffdupont

Copy link
Copy Markdown
Contributor

@apucacao thanks for the session_id fix. Two non-blocking follow-ups for a later PR:

  1. Judges still route here. provides_for is ("*", "agent") (handler.py:450), so a judged config sends the judge prompt to OpenCode unless the caller remembers skip_judges=True. Only the docstring guards this today. Either run the prompt the handler is given, or skip judges in the handler itself.
  2. Span input drops structured history. _input_messages only records history items whose content is a string (handler.py:292), so the block-array shape never reaches the invoke_agent span. An empty user_input is also appended (:294). This is Bugbot's low-severity finding on c03b5113.

I also checked this against #164 now that it's on main: it merges cleanly, the tests pass, and a run through agui.stream gives the right text, usage, and session filtering.

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