Skip to content

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

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

feat(ai-claude-code): stream tool-call arguments (+ fix processor id-rename orphaning tool results)#903
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 by wiring structured output through the Claude Code harness:

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

The stream translator only forwarded text_delta/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 (tool_use) → TOOL_CALL_START, input_json_delta → incremental TOOL_CALL_ARGS, content_block_stopTOOL_CALL_END — matching how @tanstack/ai-anthropic / @tanstack/ai-openai 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 unblocks streaming structured output on the Claude Code harness: routing structured data through a tool call now paints incrementally as the model writes it, instead of arriving atomically 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 + 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 / 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. 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 the Claude Code harness (native structured_output only appears in the final ResultMessage, 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: partial tool-arg streaming + dedup, abort-mid-tool-args pairs an interrupted result, parentMessageId grouping, sequential tool blocks, zero-arg START→END. 6 files / 39 tests pass.
  • @tanstack/ai — new stream-processor regression test: tool-first without parentMessageId → rename → tool result attaches (was silently dropped); the existing provided-parentMessageId test still passes (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
    • Claude Code tool calls now stream tool input arguments incrementally, producing smoother, earlier tool activity during generation.
  • Bug Fixes
    • Prevented duplicate/competing tool-call frames when partial tool input arrives before completion.
    • Fixed edge cases where tool results could be orphaned when tool calls started before any text message (including early/aborted streams), ensuring placeholder-to-real message renames keep tool results attached.
  • Tests
    • Added/extended regression coverage for incremental tool-args streaming, deduping, ordering, and interruption behavior.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds incremental Claude Code tool-call argument streaming and centralizes message-id renaming in the stream processor so tool results stay attached after placeholder renames.

Changes

Claude Code Partial Tool-Call Streaming

Layer / File(s) Summary
SDK raw event type extensions
packages/ai-claude-code/src/stream/sdk-types.ts
SdkRawStreamEvent gains optional content_block.id/name and delta.partial_json fields.
Partial tool-call streaming implementation
packages/ai-claude-code/src/stream/translate.ts
Tracks streamed tool-call ids, accumulates partial JSON args, adds closePartialToolUse, emits TOOL_CALL_START/ARGS/END incrementally, dedupes against complete tool_use blocks, and closes in-flight partials on result/error paths.
Tests and changeset for tool-call arg streaming
packages/ai-claude-code/tests/translate.test.ts, .changeset/claude-code-stream-tool-args.md
Adds tests for incremental args, dedup, interruption, parentMessageId grouping, sequential and zero-argument tool calls; documents the behavior change.

Processor Message ID Rename Fix

Layer / File(s) Summary
Centralized renameMessageId helper and wiring
packages/ai/src/activities/chat/stream/processor.ts
Adds renameMessageId to remap messages, messageStates, activeMessageIds, toolCallToMessage, structuredMessageIds, and structuredOutputUpdateBatches; replaces prior inline rename logic in handleTextMessageStartEvent.
Regression test and changeset for rename fix
packages/ai/tests/stream-processor.test.ts, .changeset/processor-message-id-rename.md
Adds a test verifying tool results route correctly after a placeholder message is renamed; documents the fix.

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

Sequence Diagram(s)

sequenceDiagram
  participant ClaudeAgentSDK
  participant translateSdkStream
  participant StreamConsumer

  ClaudeAgentSDK->>translateSdkStream: content_block_start (tool_use)
  translateSdkStream->>StreamConsumer: TOOL_CALL_START
  ClaudeAgentSDK->>translateSdkStream: content_block_delta (input_json_delta)
  translateSdkStream->>StreamConsumer: TOOL_CALL_ARGS (delta)
  ClaudeAgentSDK->>translateSdkStream: content_block_stop
  translateSdkStream->>translateSdkStream: closePartialToolUse (parse args)
  translateSdkStream->>StreamConsumer: TOOL_CALL_END (input)
Loading
sequenceDiagram
  participant StreamProcessor
  participant ProcessorState
  participant ToolCallRouting

  StreamProcessor->>ProcessorState: renameMessageId(oldId, newId)
  StreamProcessor->>ToolCallRouting: remap toolCallToMessage values
  StreamProcessor->>ProcessorState: update messages, messageStates, activeMessageIds, structured batches
Loading

Possibly related PRs

  • TanStack/ai#480: Both PRs address tool-first streaming where TOOL_CALL_START/TOOL_CALL_RESULT can be associated with the wrong or renamed assistant messageId.

Suggested reviewers: AlemTuzlak, tombeckenham

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also adds an extra @tanstack/ai message-id rename fix that is outside linked issue #901's scope. Move the processor rename fix to a separate PR or add a linked issue covering it if it is intended to ship together.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements #901 by streaming input_json_delta as TOOL_CALL_ARGS and matching the requested incremental tool-arg behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly summarizes the main changes: Claude tool-call arg streaming plus the processor id-rename fix.
Description check ✅ Passed The description is detailed and covers changes, motivation, and tests, though it omits the template's checklist and release-impact sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/claude-code-stream-tool-args

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.

ozolcar added 2 commits July 6, 2026 21:35
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` (tool_use),
`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.
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.
@ozolcar ozolcar force-pushed the feat/claude-code-stream-tool-args branch from 8b25a36 to 760cbb6 Compare July 6, 2026 18:36

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/ai-claude-code/tests/translate.test.ts (1)

636-701: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a test for tool_use streaming with no preceding message_start.

Existing grouping test covers parentMessageId when a message_start/text block precedes the tool call. Since translate.ts conditionally omits parentMessageId when partialMessageId === null (Line 449-451 in translate.ts), a test asserting the omission in that case would close the gap and guard the branch this layer is responsible for (the actual orphan-prevention itself is fixed at the processor layer per the PR stack).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-claude-code/tests/translate.test.ts` around lines 636 - 701, Add
a focused translate test for streamed tool_use events when no preceding
message_start exists, since the current sibling-grouping case only covers the
partialMessageId path. In translate.test.ts, extend the collect-based
stream_event coverage around the existing tool call assertions to simulate a
tool_use block without an earlier message_start, and verify that TOOL_CALL_START
does not include parentMessageId when translate() has no partialMessageId to
attach. Use the translate.ts branch that conditionally omits parentMessageId and
the existing TOOLS_CALL_START/TEXT_MESSAGE_START matching pattern to locate the
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ai/src/activities/chat/stream/processor.ts`:
- Around line 764-773: The docstring on the message-id renaming helper refers to
a nonexistent pruneToMessages() method, which will mislead readers. Update that
reference in the comment for the id-renaming logic around the processor’s
message-keyed map cleanup so it points to the actual enumerating method,
removeMessagesAfter(), and keep the wording aligned with the mirrored cleanup
behavior described by reset().

---

Nitpick comments:
In `@packages/ai-claude-code/tests/translate.test.ts`:
- Around line 636-701: Add a focused translate test for streamed tool_use events
when no preceding message_start exists, since the current sibling-grouping case
only covers the partialMessageId path. In translate.test.ts, extend the
collect-based stream_event coverage around the existing tool call assertions to
simulate a tool_use block without an earlier message_start, and verify that
TOOL_CALL_START does not include parentMessageId when translate() has no
partialMessageId to attach. Use the translate.ts branch that conditionally omits
parentMessageId and the existing TOOLS_CALL_START/TEXT_MESSAGE_START matching
pattern to locate the behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1d9d7458-6358-4a9f-bd17-c8de624e08ec

📥 Commits

Reviewing files that changed from the base of the PR and between e3de949 and 8b25a36.

📒 Files selected for processing (7)
  • .changeset/claude-code-stream-tool-args.md
  • .changeset/processor-message-id-rename.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

Comment on lines +764 to +773
/**
* Rename a message id across every structure keyed by it.
*
* A placeholder id (from ensureAssistantMessage / startAssistantMessage) is
* renamed to the real provider id on TEXT_MESSAGE_START. Every structure that
* holds that id must move together — miss one and the data it holds is
* silently orphaned onto the vanished id (e.g. a tool result whose
* toolCallToMessage entry still points at the placeholder). This is the same
* id-keyed set enumerated by pruneToMessages() and reset().
*/

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Docstring references a nonexistent method.

pruneToMessages() doesn't exist in this class — the method that enumerates the same id-keyed maps is removeMessagesAfter() (see its comment at Line 424-431). Fix the reference so future readers can actually find the mirrored cleanup logic.

📝 Proposed fix
-   * silently orphaned onto the vanished id (e.g. a tool result whose
-   * toolCallToMessage entry still points at the placeholder). This is the same
-   * id-keyed set enumerated by pruneToMessages() and reset().
+   * silently orphaned onto the vanished id (e.g. a tool result whose
+   * toolCallToMessage entry still points at the placeholder). This is the same
+   * id-keyed set enumerated by removeMessagesAfter() and reset().
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Rename a message id across every structure keyed by it.
*
* A placeholder id (from ensureAssistantMessage / startAssistantMessage) is
* renamed to the real provider id on TEXT_MESSAGE_START. Every structure that
* holds that id must move together miss one and the data it holds is
* silently orphaned onto the vanished id (e.g. a tool result whose
* toolCallToMessage entry still points at the placeholder). This is the same
* id-keyed set enumerated by pruneToMessages() and reset().
*/
/**
* Rename a message id across every structure keyed by it.
*
* A placeholder id (from ensureAssistantMessage / startAssistantMessage) is
* renamed to the real provider id on TEXT_MESSAGE_START. Every structure that
* holds that id must move together miss one and the data it holds is
* silently orphaned onto the vanished id (e.g. a tool result whose
* toolCallToMessage entry still points at the placeholder). This is the same
* id-keyed set enumerated by removeMessagesAfter() and reset().
*/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/activities/chat/stream/processor.ts` around lines 764 - 773,
The docstring on the message-id renaming helper refers to a nonexistent
pruneToMessages() method, which will mislead readers. Update that reference in
the comment for the id-renaming logic around the processor’s message-keyed map
cleanup so it points to the actual enumerating method, removeMessagesAfter(),
and keep the wording aligned with the mirrored cleanup behavior described by
reset().

@ozolcar ozolcar closed this by deleting the head repository Jul 6, 2026
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