Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/processor-message-id-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@tanstack/ai': patch
---

Rename message ids atomically across all id-keyed processor state, fixing a silently dropped tool
result. When a tool call streams before any text and without `parentMessageId` (which the AG-UI spec
allows to be optional), the `StreamProcessor` creates a placeholder assistant message and later
renames it to the real provider id on `TEXT_MESSAGE_START`. That rename updated `messages`,
`messageStates`, and `activeMessageIds` but not `toolCallToMessage` / `structuredMessageIds` /
`structuredOutputUpdateBatches`, so a `TOOL_CALL_RESULT` arriving after the rename resolved to the
vanished placeholder id and was discarded. The rename now goes 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.
9 changes: 9 additions & 0 deletions .changeset/stream-tool-call-args.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai-claude-code': patch
---

Stream tool-call arguments incrementally instead of surfacing each tool call whole. The stream translator only handled `text_delta` and `thinking_delta` partial events, so a `tool_use` block arrived 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, `content_block_delta` with `input_json_delta`, and `content_block_stop`) into incremental `TOOL_CALL_ARGS` deltas, matching how the `@tanstack/ai-anthropic` and `@tanstack/ai-openai` adapters already stream tool args.

The complete `assistant` message dedupes any tool call that 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. 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.

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. On abort mid-tool-args, the in-flight partial tool call is flushed so its `TOOL_CALL_START` is still paired with a `TOOL_CALL_END` and an interrupted `TOOL_CALL_RESULT`.
9 changes: 7 additions & 2 deletions packages/ai-claude-code/src/stream/sdk-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,17 @@ export type SdkRawStreamEvent =
| {
type: 'content_block_start'
index: number
content_block: { type: string }
content_block: { type: string; id?: string; name?: string }
}
| {
type: 'content_block_delta'
index: number
delta: { type: string; text?: string; thinking?: string }
delta: {
type: string
text?: string
thinking?: string
partial_json?: string
}
}
| { type: 'content_block_stop'; index: number }
| { type: 'message_delta' }
Expand Down
91 changes: 84 additions & 7 deletions packages/ai-claude-code/src/stream/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export async function* translateSdkStream(
const unresolvedToolCalls = new Set<string>()
/** Anthropic message ids whose text/thinking already streamed via partials. */
const streamedMessageIds = new Set<string>()
/** Tool-call ids already streamed via partials, so the complete message dedupes them. */
const streamedToolCallIds = new Set<string>()

// Partial-stream state
let partialMessageId: string | null = null
Expand All @@ -119,6 +121,9 @@ export async function* translateSdkStream(
let partialTextContent = ''
let partialTextStarted = false
let partialReasoningId: string | null = null
let partialToolCallId: string | null = null
let partialToolCallName: string | null = null
let partialToolArgs = ''

function* startRun(): Generator<StreamChunk> {
if (runStarted) return
Expand Down Expand Up @@ -179,6 +184,33 @@ export async function* translateSdkStream(
partialReasoningId = null
}

function* closePartialToolUse(): Generator<StreamChunk> {
if (partialToolCallId) {
// At content_block_stop the accumulated JSON is complete; the try/catch
// mirrors the model adapters and only guards a truncated/aborted stream.
let input: unknown = {}
try {
const parsed = partialToolArgs ? JSON.parse(partialToolArgs) : {}
input = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
input = {}
}
yield {
type: EventType.TOOL_CALL_END,
toolCallId: partialToolCallId,
toolCallName: partialToolCallName ?? '',
toolName: partialToolCallName ?? '',
model,
timestamp: now(),
input,
}
unresolvedToolCalls.add(partialToolCallId)
}
partialToolCallId = null
partialToolCallName = null
partialToolArgs = ''
}

function* emitToolUse(block: {
id: string
name: string
Expand Down Expand Up @@ -284,9 +316,10 @@ export async function* translateSdkStream(
timestamp: now(),
}
} else if (block.type === 'tool_use') {
yield* emitToolUse(
block as { id: string; name: string; input: unknown },
)
const toolBlock = block as { id: string; name: string; input: unknown }
// Skip the tool call if the partial stream already emitted it.
if (streamedToolCallIds.has(toolBlock.id)) continue
yield* emitToolUse(toolBlock)
}
}
}
Expand Down Expand Up @@ -317,6 +350,7 @@ export async function* translateSdkStream(
function* handleResult(message: SdkResultMessage): Generator<StreamChunk> {
yield* closePartialText()
yield* closePartialReasoning()
yield* closePartialToolUse()
yield* synthesizeUnresolvedResults()

const usage = buildUsage(message.usage, message.total_cost_usd)
Expand Down Expand Up @@ -393,6 +427,30 @@ export async function* translateSdkStream(
model,
timestamp: now(),
}
} else if (partialBlockType === 'tool_use') {
// The partial content_block_start carries the tool id + name; stream
// the call so its args paint incrementally instead of arriving whole.
const block = event.content_block
if (block.id) {
partialToolCallId = block.id
partialToolCallName = stripMcpPrefix(block.name ?? '')
partialToolArgs = ''
streamedToolCallIds.add(block.id)
yield {
type: EventType.TOOL_CALL_START,
toolCallId: block.id,
toolCallName: partialToolCallName,
toolName: partialToolCallName,
model,
timestamp: now(),
// Group under the same message as sibling text/thinking blocks (as
// the partial text path does) so a downstream message rename can't
// orphan this call's later result.
...(partialMessageId !== null && {
parentMessageId: partialMessageId,
}),
}
}
}
} else if (event.type === 'content_block_delta') {
if (
Expand Down Expand Up @@ -422,12 +480,28 @@ export async function* translateSdkStream(
model,
timestamp: now(),
}
} else if (
event.delta.type === 'input_json_delta' &&
partialToolCallId &&
typeof event.delta.partial_json === 'string'
) {
partialToolArgs += event.delta.partial_json
yield {
type: EventType.TOOL_CALL_ARGS,
toolCallId: partialToolCallId,
model,
timestamp: now(),
delta: event.delta.partial_json,
args: partialToolArgs,
}
}
} else if (event.type === 'content_block_stop') {
if (partialBlockType === 'text') {
yield* closePartialText()
} else if (partialBlockType === 'thinking') {
yield* closePartialReasoning()
} else if (partialBlockType === 'tool_use') {
yield* closePartialToolUse()
}
partialBlockType = null
}
Expand Down Expand Up @@ -473,10 +547,13 @@ export async function* translateSdkStream(
// harness-internal and intentionally ignored.
}
} catch (error) {
// The run is dying (abort or SDK failure). Pair any started tool calls
// with a synthetic result first so the next request's pending-tool-call
// scan doesn't try to execute them, then let the adapter surface the
// error as RUN_ERROR.
// The run is dying (abort or SDK failure). Close any in-flight partial
// tool call first (mirrors handleResult) so its TOOL_CALL_START is paired
// with a TOOL_CALL_END and registered as unresolved, then synthesize
// results for every started-but-unresolved tool call so the next request's
// pending-tool-call scan doesn't try to execute them. Finally let the
// adapter surface the error as RUN_ERROR.
yield* closePartialToolUse()
yield* synthesizeUnresolvedResults()
throw error
}
Expand Down
Loading