Skip to content

fix(claude-agent-sdk): preserve multimodal input - #2609

Merged
marthakelly merged 4 commits into
mainfrom
codex/fac-144-171-claude-multimodal
Sep 4, 2026
Merged

fix(claude-agent-sdk): preserve multimodal input#2609
marthakelly merged 4 commits into
mainfrom
codex/fac-144-171-claude-multimodal

Conversation

@marthakelly

@marthakelly marthakelly commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • preserve image and PDF attachments when converting AG-UI messages for the Python Claude Agent SDK adapter
  • preserve image and PDF attachments when converting AG-UI messages for the TypeScript Claude Agent SDK adapter
  • add focused regression coverage for multimodal conversion and adapter input

Linear

Validation

  • pnpm nx run @ag-ui/claude-agent-sdk:test
  • pnpm nx run @ag-ui/claude-agent-sdk:typecheck
  • uv run python -m pytest -q (from integrations/claude-agent-sdk/python)
  • git diff --check origin/main...HEAD

Fixes FAC-144
Fixes FAC-171

@marthakelly
marthakelly requested a review from a team as a code owner September 2, 2026 21:33
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1788550060 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.dev1788550060' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1788550060' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1788550060' --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.dev1788550060' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1788550060' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1788550060' --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.dev1788550060

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


Commit: 99bc2b5

@pkg-pr-new

pkg-pr-new Bot commented Sep 2, 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@2609

@ag-ui/a2ui-middleware

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

@ag-ui/event-throttle-middleware

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

@ag-ui/mcp-apps-middleware

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

@ag-ui/mcp-middleware

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

@ag-ui/a2a

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

@ag-ui/adk

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

@ag-ui/ag2

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

@ag-ui/agno

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

@ag-ui/aws-strands

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

@ag-ui/claude-agent-sdk

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

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2609

@ag-ui/crewai

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

@ag-ui/langchain

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

@ag-ui/langgraph

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

@ag-ui/llamaindex

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

@ag-ui/mastra

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

@ag-ui/pydantic-ai

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

@ag-ui/vercel-ai-sdk

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

@ag-ui/watsonx

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

@ag-ui/a2ui-toolkit

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

create-ag-ui-app

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

@ag-ui/client

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

@ag-ui/core

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

@ag-ui/encoder

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

@ag-ui/proto

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

commit: 88bf584

@mxmzb mxmzb 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 both runtimes. The core change is right — attachments now survive into the SDK, ordering is preserved, and the block shapes match what the Claude Agent SDK expects on each side (Python's client.query() accepts str | AsyncIterable[dict], TS query({prompt}) accepts string | AsyncIterable<SDKUserMessage>). I re-ran the stated validation locally and it all passes: 117 Python tests, 14 TS tests, typecheck clean.

Two blocking issues, both about what happens when conversion fails rather than when it succeeds. Verified empirically, not read off the diff.

1. Python: one unsupported attachment tears down the thread's live session

process_messages now raises, and it is called at adapter.py:348 inside run()'s main try. The generic except Exception at adapter.py:440 pops and stops the SessionWorker — so a purely client-side validation failure that never reached the SDK destroys the Claude CLI session for that thread along with its conversation history. The user's next message starts a fresh session with no memory of the conversation.

Probe: prime a thread with a plain text run, then send [text, audio] on the same thread_id:

run1 events: [RUN_STARTED, RUN_FINISHED]   workers: ['t1']
run2 events: [RUN_ERROR 'content[1] type audio is not supported']
workers after run2: []                     worker stopped? [True]

Suggested fix: convert the content before the worker try-block, or catch the conversion error separately, emit RUN_ERROR, and leave the worker untouched. Nothing about a bad content block implies the session is broken.

2. TypeScript: the throw escapes before any AG-UI event is emitted

processMessages(runInput) is called at adapter.ts:156, in the Observable subscribe body — outside translateStream's try/catch. RxJS routes the synchronous throw to subscriber.error, so the consumer sees a bare error notification and zero events — no RUN_STARTED, no RUN_ERROR:

EVENTS: []   ERROR: [ClaudeAdapter] content[1] type audio is not supported   COMPLETED: false

Python at least emits a RUN_ERROR on this path, so the two runtimes disagree on the failure contract. Suggested fix: move the processMessages call inside translateStream's try, after RUN_STARTED, so the existing catch produces a proper RUN_ERROR.

3. Worth a second look: is hard-fail the right policy at all?

The test names make it clear this is deliberate (test_rejects_unsupported_media_instead_of_dropping_it), and I agree silently dropping an image is the bug FAC-144 is about. But audio and video are protocol-legal content that Claude simply cannot accept, and the same is true of an opaque binary file id. Under this change, a user who attaches a voice memo alongside a text question gets no answer at all, where before they got an answer to the text.

Consider: convert what is convertible, replace what is not with a text marker ([unsupported attachment: audio/mp4]), and fail the run only when nothing survives. That keeps the loud signal (the model and the user both see the attachment was not read) without discarding the rest of the turn.

4. Empty text blocks now reach the API

[{"type": "text", "text": ""}] converts to {"type": "text", "text": ""} and is sent. The Messages API rejects empty text content blocks, and because has_user_content / hasUserContent is set from blocks.length > 0 rather than from the content itself, the "No user message found" warning no longer fires either. The old if not user_message: guard covered this incidentally.

Verified on the Python side:

process_messages(... content=[{"type": "text", "text": ""}])
-> {'type': 'user', 'message': {'role': 'user', 'content': [{'type': 'text', 'text': ''}]}, ...}

Suggest skipping empty / whitespace-only text blocks, or rejecting them with the same explicit error the other validators use.

5. The Python tests added here never run in CI

unit-python-sdk.yml:15 states it directly: "agent-spec and claude-agent-sdk have no test lane" — only the committed lockfile is verified for this package. So the regression coverage in tests/test_utils.py, tests/test_adapter.py and tests/test_concurrency_integration.py will not execute on any future PR, and the FAC-144 regression can return green.

The TypeScript side is fine: nx run-many -t test at the repo root picks up @ag-ui/claude-agent-sdk:test, and I confirmed the new suite runs under it.

Adding a Python lane is arguably out of scope for this PR, but since the PR's value is largely the regression coverage, it seems worth either adding the lane or filing a follow-up.


Smaller notes

  • session_id on the structured message. TS sets session_id: input.threadId ?? "default", but the SDK's own string-prompt path normalizes to session_id: "". Python's string path already passed the thread id through, so Python stays self-consistent — TS is the side that changed behaviour. Probably harmless, but worth one real CLI run to confirm the daemon ignores it, or just use "" to match the SDK.
  • Diff noise. typescript/src/utils.ts carries roughly 150 lines of unrelated prettier reformatting (trailing commas, argument wrapping) mixed in with the real change, which makes the multimodal logic harder to review. Splitting the reformat into its own commit would help.
  • Untested failure path. Neither runtime has a test that drives run() with unconvertible content and asserts on the emitted events. That gap is exactly what hid items 1 and 2 — the existing tests call process_messages / processMessages directly and assert only that it throws.

Checked and fine

  • ag-ui-protocol>=0.1.15 genuinely exports every symbol the new Python import block pulls in (verified against the published 0.1.15 wheel), so the floor pin is not a trap.
  • requires-python = ">=3.11" covers the eagerly-evaluated str | AsyncIterable[...] annotations and the module-level ClaudePrompt alias.
  • Raw dict content blocks are not a regression risk: UserMessage.content is a discriminated union, so pydantic materializes the typed models before _convert_content_block sees them.
  • TS streaming-input mode still terminates. isSingleUserTurn becomes false with an iterable prompt, so the SDK skips its post-result endInput() — but streamInput calls endInput() itself once the iterable is exhausted (waiting for the first result when bidirectional needs exist, which they always do here because of the SDK MCP server). No hung process.
  • split(";", 1)[0] behaves correctly for media-type normalization in both languages, despite the differing limit / maxsplit semantics.

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

Marking this as requesting changes to make the status explicit — details are in my review above.

Blocking on the two verified failure-path issues:

  1. Python — a single unsupported attachment tears down the thread's SessionWorker, destroying the Claude CLI session and its conversation history for a validation failure that never reached the SDK (adapter.py:348 raising into the except Exception at adapter.py:440).
  2. TypeScriptprocessMessages is called outside translateStream's try/catch (adapter.ts:156), so the throw surfaces as a bare RxJS error with zero AG-UI events emitted — no RUN_STARTED, no RUN_ERROR. Python emits RUN_ERROR on the same path, so the runtimes disagree on the failure contract.

Also worth resolving before merge, though I'd defer to you on the first one:

  1. Whether hard-failing the whole run on protocol-legal-but-unconvertible content (audio, video, opaque binary ids) is the behaviour we want, versus converting what we can and marking the rest.
  2. Empty text blocks now reach the API, which rejects them, and the "No user message found" warning no longer fires for that case.

The conversion logic itself looks correct on both sides and the stated validation reproduces cleanly here — this is entirely about the error paths.

@marthakelly

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 88bf5848:

  • Python now converts and validates input before acquiring the per-thread run lock or touching the live SessionWorker. Adapter-level coverage primes a session, sends unsupported audio, asserts the same worker was neither stopped nor replaced, and then successfully reuses it for another turn.
  • TypeScript now runs conversion inside the AG-UI event-handling try, after RUN_STARTED. Adapter-level coverage asserts unsupported audio emits exactly RUN_STARTED then RUN_ERROR, completes normally, and never calls query().
  • Empty and whitespace-only text blocks are filtered in both runtimes. Adapter tests assert the empty-block case reaches query() as the prior plain empty prompt rather than an invalid structured empty text block.
  • Unsupported media still hard-fails intentionally, per the product-policy decision in the follow-up feedback.
  • Python CI coverage is tracked separately in ci(python): run the agent-spec and claude-agent-sdk test suites #2646, which adds the claude-agent-sdk-python lane and is currently green.

Validation rerun:

  • Python: 120 passed
  • TypeScript: 15 passed
  • TypeScript typecheck: clean
  • Prettier and git diff --check: clean

The PR description now also includes Fixes FAC-144 and Fixes FAC-171. Please take another look.

@marthakelly
marthakelly requested a review from mxmzb September 4, 2026 19:28
@marthakelly
marthakelly merged commit 7627061 into main Sep 4, 2026
55 checks passed
@marthakelly
marthakelly deleted the codex/fac-144-171-claude-multimodal branch September 4, 2026 19:44
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.

2 participants