TanStack AI version
v0.42.0
Framework/Library version
React v 18.3.1
Describe the bug and the steps to reproduce it
Summary
A single TEXT_MESSAGE_CONTENT event arriving between two TOOL_CALL_ARGS events causes the tool call's input to be set from a lenient partial-JSON parse of the arguments received so far. The value is never corrected, because the subsequent authoritative TOOL_CALL_END is skipped for a call already marked input-complete.
The result is a tool-call part in state input-complete whose input contains silently truncated values, while arguments holds the correct, complete JSON string. Consumers have no way to detect this from state.
This reproduces in the framework agnostic core with no React involved.
Reproduction
Run inside a project with @tanstack/ai installed:
import { StreamProcessor } from '@tanstack/ai/client';
const ARGS_HEAD = '{"templateIds":["mock-gsk-e';
const ARGS_TAIL = 'fimosfermin"]}';
const toolCallPartFrom = async (chunks) => {
let messages = [];
const processor = new StreamProcessor({
events: { onMessagesChange: (next) => { messages = next; } },
});
await processor.process((async function* () { for (const c of chunks) yield c; })());
return messages.flatMap((m) => m.parts ?? []).find((p) => p.type === 'tool-call');
};
const withoutInterleavedText = [
{ type: 'RUN_STARTED' },
{ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'offerTemplates', messageId: 'm1' },
{ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: ARGS_HEAD },
{ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: ARGS_TAIL },
{ type: 'TOOL_CALL_END', toolCallId: 't1' },
{ type: 'RUN_FINISHED' },
];
const withInterleavedText = [
{ type: 'RUN_STARTED' },
{ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'offerTemplates', messageId: 'm1' },
{ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: ARGS_HEAD },
// The only difference: one text delta between two TOOL_CALL_ARGS events.
{ type: 'TEXT_MESSAGE_CONTENT', messageId: 'm1', delta: 'Let me look those up. ' },
{ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: ARGS_TAIL },
{ type: 'TOOL_CALL_END', toolCallId: 't1' },
{ type: 'RUN_FINISHED' },
];
for (const [label, chunks] of [
['without interleaved text', withoutInterleavedText],
['with interleaved text', withInterleavedText],
]) {
const part = await toolCallPartFrom(chunks);
console.log(`\n${label}`);
console.log(' state :', part?.state);
console.log(' arguments:', part?.arguments);
console.log(' input :', JSON.stringify(part?.input));
}
outputs:
without interleaved text
state : input-complete
arguments: {"templateIds":["mock-gsk-efimosfermin"]}
input : {"templateIds":["mock-gsk-efimosfermin"]}
with interleaved text
state : input-complete
arguments: {"templateIds":["mock-gsk-efimosfermin"]}
input : {"templateIds":["mock-gsk-e"]}
input should equal {"templateIds":["mock-gsk-efimosfermin"]} in both cases, matching arguments. Failing that, input should be left undefined rather than populated with a truncated value, since ToolCallPart.input is already optional and its documentation states the raw arguments string is always available as a fallback.
Root Cause (claude's best guess)
Three behaviours in activities/chat/stream/processor.js combine:
- handleTextMessageContentEvent calls completeAllToolCallsForMessage(messageId) on every text delta, inferring that any in-flight tool call must be finished because text has started.
- completeToolCall then populates the authoritative input via this.jsonParser.parse(toolCall.arguments). That is the lenient partial-json parser, which closes an unterminated string instead of rejecting it, so the truncated "mock-gsk-e becomes a plausible looking but wrong value.
- handleToolCallEndEvent begins with if (existingToolCall && existingToolCall.state !== "input-complete"). The call is already input-complete from step 1, so the handler early-returns and cannot repair input.
Step 1 on its own would be a recoverable heuristic. Step 3 is what makes the corruption permanent.
Two pieces of in-repo documentation are contradicted by this behaviour. handleToolCallEndEvent is documented as the "authoritative signal that a tool call's input is finalized", and the ToolCallPart.input type comment says it is "Set from the parsed arguments once they are complete."
Any consumer reading part.input for a client side or human in the loop tool can act on truncated arguments while state reports input-complete. In our case an id arrived truncated and the UI issued a request for a resource that does not exist. Depending on the field, the failure mode may be a visible error or silently wrong content, for example a clipped question string or a dropped final array element.
Claude's suggested fix
Any one of these resolves it; the first two seem most targeted:
- In completeToolCall, populate input only when a strict JSON.parse(toolCall.arguments) succeeds, and leave it undefined otherwise.
- Distinguish inferred completion from authoritative completion, and let TOOL_CALL_END overwrite an inferred one.
- Stop force-completing tool calls from handleTextMessageContentEvent. The missed TOOL_CALL_END safety net is already covered by RUN_FINISHED and finalizeStream, which call completeAllToolCalls at stream termination.
Other notes
In this case, I am using a client-side tool without an execution (empty `.client()), allowing a tool to "pause" the convo so the user can make a selection.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
See above for reproduction notes!
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
No, because I do not have time to dig into it
Terms & Code of Conduct
TanStack AI version
v0.42.0
Framework/Library version
React v 18.3.1
Describe the bug and the steps to reproduce it
Summary
A single TEXT_MESSAGE_CONTENT event arriving between two TOOL_CALL_ARGS events causes the tool call's input to be set from a lenient partial-JSON parse of the arguments received so far. The value is never corrected, because the subsequent authoritative TOOL_CALL_END is skipped for a call already marked input-complete.
The result is a tool-call part in state input-complete whose input contains silently truncated values, while arguments holds the correct, complete JSON string. Consumers have no way to detect this from state.
This reproduces in the framework agnostic core with no React involved.
Reproduction
Run inside a project with @tanstack/ai installed:
outputs:
input should equal {"templateIds":["mock-gsk-efimosfermin"]} in both cases, matching arguments. Failing that, input should be left undefined rather than populated with a truncated value, since ToolCallPart.input is already optional and its documentation states the raw arguments string is always available as a fallback.
Root Cause (claude's best guess)
Three behaviours in activities/chat/stream/processor.js combine:
Step 1 on its own would be a recoverable heuristic. Step 3 is what makes the corruption permanent.
Two pieces of in-repo documentation are contradicted by this behaviour. handleToolCallEndEvent is documented as the "authoritative signal that a tool call's input is finalized", and the ToolCallPart.input type comment says it is "Set from the parsed arguments once they are complete."
Any consumer reading part.input for a client side or human in the loop tool can act on truncated arguments while state reports input-complete. In our case an id arrived truncated and the UI issued a request for a resource that does not exist. Depending on the field, the failure mode may be a visible error or silently wrong content, for example a clipped question string or a dropped final array element.
Claude's suggested fix
Any one of these resolves it; the first two seem most targeted:
Other notes
In this case, I am using a client-side tool without an execution (empty `.client()), allowing a tool to "pause" the convo so the user can make a selection.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
See above for reproduction notes!
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
No, because I do not have time to dig into it
Terms & Code of Conduct