Skip to content

fix(claude-agent-sdk): dedup errored-turn assistant messages, surface RunErrorEvent (#2145)#2152

Merged
BenTaylorDev merged 4 commits into
ag-ui-protocol:mainfrom
enginerd-kr:fix/issue-2145-duplicate-error-messages
Jul 15, 2026
Merged

fix(claude-agent-sdk): dedup errored-turn assistant messages, surface RunErrorEvent (#2145)#2152
BenTaylorDev merged 4 commits into
ag-ui-protocol:mainfrom
enginerd-kr:fix/issue-2145-duplicate-error-messages

Conversation

@enginerd-kr

@enginerd-kr enginerd-kr commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #2145.

Changes

  • Gate the ResultMessage fallback with not is_error
  • Track ids minted by the non-streaming AssistantMessage fallback
    (unstreamed_fallback_ids) and drop them from the snapshot when the
    turn errored
  • Emit RunErrorEvent from run() in place of RUN_FINISHED on
    errored turns — terminal ownership stays in one function, matching the
    pattern already used on the three exception paths (review follow-up in
    a0019cec; the stream itself no longer emits terminals)

Why combined

The first two dedup the duplicate text; the third makes the errored turn
identifiable at all instead of leaving one raw-text assistant message
indistinguishable from a normal reply. Sending together since dedup alone
still leaves the original bug's second symptom ("no indication anything
went wrong") unresolved -- happy to split into two PRs if you'd rather
review them separately.

Testing

  • New tests in test_adapter.py (TestResultMessageErrorHandling);
    confirmed they fail on the pre-fix code
  • Review follow-up: test_run_replaces_run_finished_with_run_error_on_api_error
    goes through adapter.run() (mirroring TestRunErrorPath) and asserts
    exactly one RUN_ERROR, RUN_FINISHED absent, RUN_ERROR as the final
    event — confirmed it fails on the pre-review commit with the
    double-terminal
  • Full suite: 109 passed
  • Live e2e check (pre-review commit): real FastAPI server + HTTP/SSE
    against the patched package

… RunErrorEvent

Fixes ag-ui-protocol#2145. ResultMessage.is_error now gates the non-streaming fallback
and drops ids minted by the AssistantMessage fallback before the
snapshot, then emits RunErrorEvent -- reusing the same pattern already
used on the three exception paths.
@enginerd-kr enginerd-kr requested a review from a team as a code owner July 10, 2026 12:42

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

The dedup half of this is in good shape — the not is_error gate plus the unstreamed_fallback_ids drop collapse the errored turn to a single assistant message, _get_msg_id handles both dict/object rows, and both duplicate sources in _stream_claude_sdk are covered. The one thing holding it back is the RunErrorEvent emission point, which I think we can fix with a small move.

🔴 Blocker — errored turns emit two terminal events (RUN_ERROR then RUN_FINISHED).
The new RunErrorEvent is yielded from inside _stream_claude_sdk (adapter.py ~L1127), and the generator then returns normally. Back in run(), the async for … yield event loop completes without an exception, so control falls through to the unconditional yield RunFinishedEvent at L402. On an errored turn that produces:
RUN_STARTED → TEXT_* → MESSAGES_SNAPSHOT → RUN_ERROR → RUN_FINISHED.
AG-UI's verifyEvents rejects anything after RUN_ERROR ("Cannot send event type 'RUN_FINISHED': The run has already errored…"), so verifying consumers (incl. CopilotKit) throw on every errored turn.
This file already encodes the opposite invariant for the exception paths: the three existing RunErrorEvents live in run()'s except blocks (mutually exclusive with RUN_FINISHED), and TestRunErrorPath::test_run_emits_run_error_on_worker_failure asserts RUN_ERROR in types and RUN_FINISHED not in types.
The smallest fix that keeps your dedup work intact: don't emit the terminal from the stream. You're already recording is_error in _per_run_result — so let run() read that after the stream drains and emit RUN_ERROR in place of RUN_FINISHED, matching the existing except-path pattern. That keeps terminal ownership in one function and reuses the contract the codebase already tests for.

🟠 The current tests can't catch this one. _drive() (test_adapter.py L28-35) drives _stream_claude_sdk directly and bypasses run(), so RUN_FINISHED never appears in the captured events, and test_errored_turn_emits_one_assistant_message_and_run_error asserts one RUN_ERROR without asserting RUN_FINISHED is absent. Once the emission moves into run(), a regression test that goes through adapter.run() (mirroring TestRunErrorPath) and asserts RUN_FINISHED absent on an errored turn would lock the ordering.

🟡 Minor — code/api_error_status pin floor. Your read on the field is right: ResultMessage.api_error_status is present in current SDKs (confirmed on 0.2.116). It just isn't there at the declared floor >=0.1.12, so on that older end getattr(…, None) quietly yields code=None. No crash either way — just worth bumping the pin floor to the version that introduced it (or a quick note that code is best-effort) so the signal isn't silently inert for older-pinned installs.

🟡 Nit — at L1131 result_text relies on being bound in the ResultMessage branch (L1044); safe today but worth initializing result_text = None at the top of the function.

Happy to look again as soon as the terminal emission moves into run().

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1784111109 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1784111109' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1784111109' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1784111109' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1784111109' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1784111109' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1784111109' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1784111109

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 6856d80

@pkg-pr-new

pkg-pr-new Bot commented Jul 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2152

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2152

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2152

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2152

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2152

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2152

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2152

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2152

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2152

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2152

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2152

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2152

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2152

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2152

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2152

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2152

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2152

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2152

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2152

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2152

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2152

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2152

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2152

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2152

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2152

commit: b9e10d2

…SHED on errored turns

Review follow-up: yielding RunErrorEvent from _stream_claude_sdk let
run() fall through to its unconditional RunFinishedEvent, producing two
terminal events per errored run — verifyEvents rejects anything after
RUN_ERROR. Terminal ownership now lives in run(): after the stream
drains it reads the per-run result slot and emits RUN_ERROR instead of
RUN_FINISHED, matching the existing except-path contract.

The failure text is threaded through _per_run_result only on errored
turns, so the success-path RunFinishedEvent.result payload is unchanged.
The worker is not evicted on this path — the stream completed cleanly,
unlike the exception paths.

New run()-level regression test (mirroring TestRunErrorPath) locks in
RUN_ERROR-as-final-event and RUN_FINISHED absence; api_error_status is
documented as best-effort (None on claude-agent-sdk versions predating
the field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@enginerd-kr

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — you were right on all four points. Fixed in a0019cec:

  • Blocker: moved the terminal emission into run() as suggested — after the stream drains it reads the per-run result slot and emits RUN_ERROR in place of RUN_FINISHED, matching the except-path contract. The failure text is threaded through _per_run_result only on errored turns, so the success-path RunFinishedEvent.result payload is byte-identical. One judgment call: the worker is not evicted on this path (the stream completed cleanly, unlike the exception paths) — pinned by an assertion in the new test.
  • Test gap: added test_run_replaces_run_finished_with_run_error_on_api_error, which goes through adapter.run() (mirroring TestRunErrorPath) and asserts exactly one RUN_ERROR, RUN_FINISHED absent, and RUN_ERROR as the final event. Confirmed it fails on the previous commit with exactly the double-terminal you described.
  • code pin floor: went with the best-effort comment rather than a floor bump — the exact introducing version is unclear, and the field is absent even on the 0.1.21 in our test env. The new test injects api_error_status via setattr so the threading is still exercised on old SDKs.
  • result_text nit: moot — that block is deleted.

Full suite: 109 passed.

BenTaylorDev
BenTaylorDev previously approved these changes Jul 14, 2026

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

Thanks for turning this around so cleanly — you addressed all four points exactly as suggested, and the extra rigor is appreciated. Verified against the diff:

  • Terminal emission now lives in run(): reads is_error off the per-run result slot after the stream drains and emits RUN_ERROR in place of RUN_FINISHED. Terminal ownership is back in one function alongside the except paths, and the stream no longer yields a terminal. 👍
  • test_run_replaces_run_finished_with_run_error_on_api_error goes through adapter.run() and locks the contract: exactly one RUN_ERROR, RUN_FINISHED absent, RUN_ERROR last. That's the regression guard the old _drive-based tests couldn't provide.
  • api_error_status best-effort comment + setattr injection in the test is a fine resolution — the code=None degradation on older-pinned SDKs is now documented rather than silently inert.
  • result_text nit is genuinely moot now that the stream's terminal block is gone.

Two things I especially liked: not evicting the worker on this path (correct — the stream completed cleanly, unlike the exception paths) with an assertion pinning it, and test_successful_turn_unaffected guaranteeing the success-path RunFinishedEvent.result payload stays untouched.

LGTM — approving. 🚀

…ate-error-messages

# Conflicts:
#	integrations/claude-agent-sdk/python/ag_ui_claude_sdk/adapter.py
@BenTaylorDev BenTaylorDev merged commit 6501fd0 into ag-ui-protocol:main Jul 15, 2026
33 checks passed
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.

[Bug]: ag-ui-claude-sdk duplicates API failure text as ordinary assistant messages

2 participants