Skip to content

feat(ai-claude-code): stream tool-call arguments (+ fix processor id-rename orphaning tool results)#904

Open
ozolcar wants to merge 2 commits into
TanStack:mainfrom
ozolcar:feat/claude-code-stream-tool-args
Open

feat(ai-claude-code): stream tool-call arguments (+ fix processor id-rename orphaning tool results)#904
ozolcar wants to merge 2 commits into
TanStack:mainfrom
ozolcar:feat/claude-code-stream-tool-args

Conversation

@ozolcar

@ozolcar ozolcar commented Jul 6, 2026

Copy link
Copy Markdown

What

Two related changes, surfaced while wiring structured output through a tool call on the @tanstack/ai-claude-code harness adapter.

1. @tanstack/ai-claude-code: stream tool-call arguments (feat, patch)

The stream translator only forwarded text_delta and thinking_delta partial events, so a tool_use block surfaced as a single completed TOOL_CALL_START/ARGS/END at the end of the message. It now also translates the SDK's partial tool input (content_block_start for tool_use into TOOL_CALL_START, input_json_delta into incremental TOOL_CALL_ARGS, content_block_stop into TOOL_CALL_END), matching how the @tanstack/ai-anthropic and @tanstack/ai-openai adapters already stream tool args. The complete assistant message dedupes anything already streamed via partials (tracked by tool-call id, mirroring the existing text/thinking message-id dedup). With streamPartials: false, tool calls still emit whole from the complete message.

This lets structured output routed through a tool call paint incrementally as the model writes it, instead of arriving all at once at run end.

Also in this commit:

  • The partial TOOL_CALL_START carries parentMessageId (mirroring the partial text path and every other adapter), so a tool-first stream groups the call under the correct message.
  • On abort mid-tool-args, the catch flushes the in-flight partial tool call (mirroring handleResult) so its TOOL_CALL_START is still paired with a TOOL_CALL_END and an interrupted TOOL_CALL_RESULT, upholding the module's documented invariant.

2. @tanstack/ai: atomic message-id rename (fix, patch)

Independently reachable from any adapter that omits parentMessageId (optional per the AG-UI spec; Mistral omits it today). When a tool call streams before any text, the StreamProcessor creates a placeholder assistant message and renames it to the real provider id on TEXT_MESSAGE_START. That rename updated messages, messageStates, and activeMessageIds but not toolCallToMessage, structuredMessageIds, or structuredOutputUpdateBatches, so a TOOL_CALL_RESULT arriving after the rename resolved to the vanished placeholder id and was silently dropped. The rename now goes through a single renameMessageId() seam that remaps every id-keyed structure (the same set already enumerated by pruneToMessages() and reset()). Complements the adapter-side fix in #480.

Why

Routing structured output through a tool call is the sanctioned way to stream it on this harness (native structured_output only appears in the final result, not as deltas). Getting there surfaced (1) the translator dropping input_json_delta, and (2) the processor's rename orphaning a tool result when parentMessageId is absent.

Test plan

  • @tanstack/ai-claude-code: new translate tests covering partial tool-arg streaming plus dedup, abort mid-tool-args pairing an interrupted result, parentMessageId grouping, sequential tool blocks, and zero-arg START/END. 6 files, 39 tests pass.
  • @tanstack/ai: new stream-processor regression test (tool-first without parentMessageId, then rename, then the tool result attaches, which was silently dropped); the existing provided-parentMessageId test still passes because renameMessageId no-ops when ids match. 63 files, 1133 tests pass.
  • Both: test:types, test:eslint (0 errors), build clean.
  • Two changesets (@tanstack/ai-claude-code patch, @tanstack/ai patch).

Closes #901.

Summary by CodeRabbit

  • New Features

    • Tool calls can now stream arguments incrementally as they arrive, instead of waiting until the end.
    • Claude tool-use streams are handled more smoothly, including cases where tools start before text.
  • Bug Fixes

    • Fixed a case where tool results could be lost when a temporary message was replaced by the final one.
    • Improved handling for interrupted tool calls so partial activity still closes cleanly.
  • Tests

    • Added coverage for incremental tool-call streaming, deduplication, message grouping, and interrupted streams.

ozolcar added 2 commits July 6, 2026 21:53
The stream translator only handled `text_delta` and `thinking_delta` partial
events, so a `tool_use` block surfaced as a single completed
TOOL_CALL_START/ARGS/END at the end of the message. It now also translates the
SDK's partial tool input (`content_block_start`, `content_block_delta` with
`input_json_delta`, and `content_block_stop`) into incremental TOOL_CALL_ARGS
deltas, matching how the model adapters (`@tanstack/ai-anthropic`,
`@tanstack/ai-openai`) already stream tool args.

The complete `assistant` message dedupes any tool call already streamed via
partials (tracked by tool-call id, mirroring the existing text/thinking
message-id dedup), so nothing is emitted twice. With `streamPartials: false`,
tool calls still emit whole from the complete message.

The partial TOOL_CALL_START also carries parentMessageId (mirroring the partial
text path and every other adapter) so a tool-first stream groups the call under
the correct message, and an abort mid-tool-args flushes the in-flight partial
tool call so its START is still paired with an END and an interrupted RESULT.
When a tool call streams before any text and without parentMessageId (optional
per the AG-UI spec), the StreamProcessor creates a placeholder assistant message
and renames it to the real provider id on TEXT_MESSAGE_START. The rename updated
messages/messageStates/activeMessageIds but not
toolCallToMessage/structuredMessageIds/structuredOutputUpdateBatches, so a
TOOL_CALL_RESULT arriving after the rename resolved to the vanished placeholder
id and was silently dropped.

Route the rename through a single renameMessageId() seam that remaps every
id-keyed structure (the same set enumerated by pruneToMessages() and reset()),
so no adapter that legitimately omits parentMessageId can orphan a tool result.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 654e77aa-ee65-4e8d-9221-c2ece217ba19

📥 Commits

Reviewing files that changed from the base of the PR and between e3de949 and 984a601.

📒 Files selected for processing (7)
  • .changeset/processor-message-id-rename.md
  • .changeset/stream-tool-call-args.md
  • packages/ai-claude-code/src/stream/sdk-types.ts
  • packages/ai-claude-code/src/stream/translate.ts
  • packages/ai-claude-code/tests/translate.test.ts
  • packages/ai/src/activities/chat/stream/processor.ts
  • packages/ai/tests/stream-processor.test.ts

📝 Walkthrough

Walkthrough

This PR fixes two issues: StreamProcessor now uses a centralized renameMessageId helper to remap placeholder-to-provider message IDs across all id-keyed structures (previously only partially updated, causing dropped tool results), and Claude Code's stream translator now emits incremental TOOL_CALL_ARGS from partial JSON deltas with deduplication against complete-message tool calls and abort flushing.

Changes

StreamProcessor Message-ID Rename Fix

Layer / File(s) Summary
Centralized rename helper and wiring
packages/ai/src/activities/chat/stream/processor.ts, packages/ai/tests/stream-processor.test.ts, .changeset/processor-message-id-rename.md
Adds renameMessageId(oldId, newId) to consistently rekey messages, messageStates, activeMessageIds, toolCallToMessage, and structured-output maps; wires it into handleTextMessageStartEvent replacing partial inline remapping; adds a regression test and changeset.

Claude Code Partial Tool-Call Argument Streaming

Layer / File(s) Summary
SDK event type updates
packages/ai-claude-code/src/stream/sdk-types.ts
Broadens content_block_start/content_block_delta payload types with optional id, name, partial_json fields.
Partial tool-use accumulation and dedup
packages/ai-claude-code/src/stream/translate.ts, .changeset/stream-tool-call-args.md
Adds dedup tracking, partial tool-use state, closePartialToolUse(), and event handling for content_block_start/delta/stop and abort paths to emit incremental TOOL_CALL_ARGS/TOOL_CALL_END.
Translator tests
packages/ai-claude-code/tests/translate.test.ts
Adds tests for incremental args, dedup, abort/interrupted results, message grouping, no arg bleed, and zero-argument tool calls.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SDK as Claude Agent SDK
  participant Translator as translateSdkStream
  participant Consumer as AG-UI Event Stream
  SDK->>Translator: content_block_start (tool_use)
  Translator->>Translator: set partial tool-use state
  Translator-->>Consumer: TOOL_CALL_START (parentMessageId)
  SDK->>Translator: content_block_delta (input_json_delta)
  Translator->>Translator: accumulate partial args
  Translator-->>Consumer: TOOL_CALL_ARGS (delta)
  SDK->>Translator: content_block_stop
  Translator->>Translator: closePartialToolUse() parses JSON
  Translator-->>Consumer: TOOL_CALL_END (input)
  SDK->>Translator: complete assistant message (tool_use block)
  Translator->>Translator: skip duplicate via streamedToolCallIds
Loading
sequenceDiagram
  participant Stream as AG-UI Stream
  participant Processor as StreamProcessor
  Stream->>Processor: TOOL_CALL_START (no parentMessageId)
  Processor->>Processor: create placeholder assistant message
  Stream->>Processor: TEXT_MESSAGE_START (real messageId)
  Processor->>Processor: renameMessageId(placeholder, real id)
  Processor->>Processor: update messages, toolCallToMessage, structured maps
  Stream->>Processor: TOOL_CALL_RESULT
  Processor-->>Stream: attach result to renamed message
Loading

Possibly related PRs

  • TanStack/ai#480: Both PRs address tool-first streaming message-id stability by routing TOOL_CALL_* events to the correct assistant message id.

Suggested reviewers: AlemTuzlak, tombeckenham

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is informative, but it does not follow the required template and omits the checklist and release-impact sections. Add the required Changes, checklist, and release impact sections, including the contributing/test steps and whether a changeset is needed.
Out of Scope Changes check ⚠️ Warning The StreamProcessor message-id rename fix is a second change not requested by #901, so the PR includes extra scope beyond the linked issue. Split the message-id rename fix into a separate PR or link an issue that explicitly requests it.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two related changes: streaming tool-call arguments and fixing message-id renaming.
Linked Issues check ✅ Passed The PR implements #901 by forwarding input_json_delta as incremental TOOL_CALL_ARGS and deduping partial tool calls.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

@tanstack/ai-claude-code: stream tool-call arguments (forward input_json_delta → TOOL_CALL_ARGS)

1 participant