Skip to content

Interleaved TEXT_MESSAGE_CONTENT permanently corrupts tool-call input with a partial-JSON parse #1017

Description

@andrewmcgivery

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:

  1. handleTextMessageContentEvent calls completeAllToolCallsForMessage(messageId) on every text delta, inferring that any in-flight tool call must be finished because text has started.
  2. 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.
  3. 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:

  1. In completeToolCall, populate input only when a strict JSON.parse(toolCall.arguments) succeeds, and leave it undefined otherwise.
  2. Distinguish inferred completion from authoritative completion, and let TOOL_CALL_END overwrite an inferred one.
  3. 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

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions