Skip to content

feat(claude): emit AG-UI text and tool events - #165

Merged
apucacao merged 10 commits into
cursor/agui-reader-7ca3from
cursor/agui-claude-7ca3
Oct 14, 2026
Merged

apucacao merged 10 commits into
cursor/agui-reader-7ca3from
cursor/agui-claude-7ca3

Conversation

@apucacao

Copy link
Copy Markdown
Contributor

Claude messages and agents map Claude stream events and the tool loop onto the shared AG-UI subset. Argument text and results follow capture_content. config().stream() stays chunk then done.

Verified with the Claude handler tests and mypy.

Open in Web Open in Cursor 

cursoragent and others added 3 commits October 13, 2026 14:18
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>
Messages and agents map Claude stream events and the tool loop onto
the shared subset. Argument text and results follow capture_content.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

@apucacao
apucacao marked this pull request as ready for review October 13, 2026 15:44

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

@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

cursoragent and others added 2 commits October 13, 2026 15:59
A streamed tool_use that never becomes a tool turn, and any sibling still
open when a call raises, now get a failed TOOL_CALL_RESULT.

Co-authored-by: alexis <alexis@launchdarkly.com>
A stream error, a handler that raises, and a streamed call the final
message never runs now emit TOOL_CALL_RESULT before the turn ends.

Co-authored-by: alexis <alexis@launchdarkly.com>
@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 d8f1166. pytest packages/claude-agents/tests packages/claude-messages/tests: 249 passed. Findings 1, 2 and 6 were confirmed with throwaway probes.

Must fix

1. A failed run can be recorded as "abandoned" instead of ERROR (claude-messages handler.py:640-645, 698-705; claude-agents handler.py:938-948)
On error the handlers now yield closing tool events inside except before marking spans failed. If the consumer closes the stream at that yield, GeneratorExit skips the outer except Exception, and finally ends the root span as UNSET with launchdarkly.stream.abandoned=True. Probe: raising tool + consumer that breaks on the first TOOL_CALL_RESULT gives root statuses [], abandoned: True. Before this PR the root got ERROR. In claude-agents the yields (939-942) also come before tool_telemetry(exc) / fail_span, so tool spans are abandoned too.
Fix: messages, delete the yields in the two inner excepts (643-644, 701-704); the outer except at 749 already does fail_open() after fail_span. Agents, move the pending flush + fail_open to just before raise, after fail_span. ~−6 lines.

2. claude-agents emits the internal MCP name as toolCallName (claude-agents/.../handler.py:737)
content_block_start uses block["name"] as-is, i.e. mcp__tool-mcp__search (spans.py:66-67). The stream event arrives before PreToolUse (include_partial_messages=True, :849), so the hook's tool_start(use_id, display_name) (270) is deduped away. Probe: {'type':'TOOL_CALL_START','toolCallId':'tu-1','toolCallName':'mcp__tool-mcp__search'}. claude-messages, #161 and #163 all emit the config name.
Fix: tool_display_name(str(block.get("name") or "")) at 737 (spans.py:74).

Should fix

3. claude-agents has no test for any tool event (claude-agents/tests/test_handler.py)
_map_claude_stream_event (727-767), the saw_args/content_block_stop rule and the hook → pending flush are untested; that's how 2 got through. One test: content_block_start(tool_use, mcp__tool-mcp__search) → input_json_delta → content_block_stop + Pre/PostToolUse, asserting START (name search) / ARGS / END / RESULT once each, in order, with capture on and off.

4. With no configured tools, built-in tool calls are reported as failed (claude-agents/.../handler.py:831-847, 904-909)
Hooks are only built when there are native/MCP tools (833), so nothing sends tool_result; per the comment around 503-507 the run keeps Claude Code's built-ins. A successful Read gets START/ARGS/END, then fail_open() at done emits toolFailed: True. (Unverified whether built-ins run with allowed_tools=[] under the default permission mode.)
Fix: emit results from the UserMessage ToolResultBlocks the SDK already yields: emitter.tool_result(block.tool_use_id, block.content, failed=bool(block.is_error)); the emitter's dedupe (agui.py:190) drops the hook's duplicate.

5. Written against a different TurnEmitter.tool_end than #161/#163 (agui.py:164-184)
#161 and #163 both add _arg_text/prefix extension to tool_end; this PR doesn't and keeps its own saw_args (agents 730, 811, 761-763). After either merges, claude-messages' tool_end(..., arguments=block.input) (666-668) runs against untested emitter logic. Claude streams spaced JSON and argument_text is compact, so the prefix never matches; harmless today, but untested. Best fixed by landing that change once in #164 and rebasing all three.

Nice to have

  1. MagicMock(type="tool_use", id="c1", name="search") names the mock, not .name: probe gives toolCallName = "<MagicMock name='search.name' ...>" in test_a_streamed_tool_without_tool_use_is_closed / test_a_stream_error_closes_an_opened_tool. Set block.name = "search" and assert it.
  2. claude-agents/tests/test_handler.py:1940-1944 loosened == "chunk" to "one of three types"; the fake stream is fixed, so assert the exact type.

Cross-provider: openai-agents (#161) closes text before tool_start (no parentMessageId); both Claude handlers keep text open and attach it. claude-agents sends the SDK's tool_response (285) while claude-messages sends the handler's return value (709), which may be shaped differently for MCP tools (unverified). Also: no AG-UI spec in the monorepo and no js-ai-sdk counterpart (see #164).

Verdict: fix 1 and 2 first. Not checked: real Agent SDK behaviour (built-in permissions with allowed_tools=[], tool_response shape, hook tool_use_id == stream block id, which the dedupe relies on); mypy.

Closing tool events inside an error handler let a consumer stop the
stream before the root span was marked failed, so the run looked
abandoned. Streamed MCP tools now use the config name.

Co-authored-by: alexis <alexis@launchdarkly.com>
@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.

cursoragent and others added 3 commits October 13, 2026 18:51
Message ids are unique across runs, tool results always carry content,
and a run cannot finish while a tool call is open. Argument text is
completed only when it extends what was streamed. Disconnects close the
handler, and callers can echo threadId and runId.

Co-authored-by: alexis <alexis@launchdarkly.com>
Co-authored-by: alexis <alexis@launchdarkly.com>
A Read or other built-in with no configured hook was marked failed at
the end of the run. The user message's tool result now closes it, and
the stream test covers the full tool lifecycle.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

Addressed the review. The span and MCP-name fixes are in 580a822. Built-in results and the lifecycle test are in bbde74e, on top of reader c8d198c.

Must fix:

  1. Messages no longer yields tool events inside an inner except before the root is failed. Agents flushes pending events and fail_open only after fail_span.
  2. A streamed MCP tool uses tool_display_name, so the event name is search rather than mcp__tool-mcp__search.

Should fix:
3. One test drives content_block_start, input_json_delta, content_block_stop, and the user-message result, with capture on and off.
4. A built-in such as Read is closed from the SDK ToolResultBlock instead of fail_open marking it failed.
5. This branch merged reader c8d198c, so tool_end is the shared implementation. The MagicMock name= tests now set block.name and assert it.

@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
apucacao added this pull request to stack #169 October 14, 2026 21:23
The reader now sits on main, including configure(). Keep the Claude
AG-UI stream and that capture default so this branch merges into the
reader.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao
apucacao merged commit 3f3a0ce into main Oct 14, 2026
7 checks passed
@apucacao
apucacao deleted the cursor/agui-claude-7ca3 branch October 14, 2026 21:36
@github-actions github-actions Bot mentioned this pull request Oct 14, 2026

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit c90090d. Configure here.

str(block.tool_use_id),
block.content,
failed=bool(getattr(block, "is_error", False)),
):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tool results mishandle structured content

Medium Severity

Built-in and hook tool results pass raw block.content / tool_response into tool_result. That value is often a list of content blocks rather than a string, so safe_tool_content either JSON-encodes the blocks or treats SDK objects as a failed call. Successful tools then show as An error occurred. with toolFailed, or as a second encoding instead of the text the model received.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c90090d. Configure here.

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