Skip to content

fix(client-js): tolerate tool-result chunks on resume streams#16385

Open
machester4 wants to merge 2 commits into
mastra-ai:mainfrom
machester4:fix/client-js-tolerate-resume-tool-result
Open

fix(client-js): tolerate tool-result chunks on resume streams#16385
machester4 wants to merge 2 commits into
mastra-ai:mainfrom
machester4:fix/client-js-tolerate-resume-tool-result

Conversation

@machester4
Copy link
Copy Markdown

@machester4 machester4 commented May 10, 2026

Problem

processChatResponse and processChatResponse_vNext (in client-sdks/client-js/src/resources/agent.ts) throw Error: tool_result must be preceded by a tool_call whenever a tool-result chunk arrives without a matching tool-call in the same stream.

Resume streams — resume-stream, approve-tool-call, and decline-tool-call — legitimately violate that invariant: they start with a tool-result whose matching tool-call was emitted in the prior turn. Combined with processStreamResponse always passing lastMessage: undefined to processChatResponse_vNext, the in-memory toolInvocations buffer is always empty when the first chunk arrives, so the validator throws on every resume.

The throw is caught by the outer .catch in processStreamResponse and only logged via console.error("Error processing stream response:", error). That makes the failure look cosmetic at first glance, but it is not.

Two distinct impacts

1. resumeStream + clientTools: real, observable bug (UX-level).

When resumeStream is called with clientTools and the resume cycle emits another tool-call for one of those client tools after the suspended tool's result, the throw aborts processing inside processChatResponse_vNext. onFinish never fires, so the recursive client-tool execution path in processStreamResponse never runs — the client tool's execute is silently never called. From the user's perspective, the agent simply stops mid-conversation: the tool that should have run client-side just doesn't.

This is what the new test resumeStream: still runs client tools when the resume cycle starts with a tool-result (post-suspend) demonstrates and protects against.

2. approveToolCall / declineToolCall: noisy logs.

These two methods don't expose clientTools in their public signatures, so the recursion path never applies. The resume stream still flows through to the consumer via the controller-side branch of the teed body, so users don't see any UX impact — just Error processing stream response: tool_result must be preceded by a tool_call repeated in logs on every HITL approval, masking real errors and making observability harder.

Repro (minimal)

// Scenario 1: resumeStream + clientTools (UX-level bug)
const weatherTool = createTool({
  id: 'weatherTool',
  description: 'Weather',
  inputSchema: z.object({ location: z.string() }),
  execute: async args => ({ ok: true }),
});

const resp = await agent.resumeStream(
  { approved: true },
  { runId, messages: [...], clientTools: { weatherTool } },
);
await resp.processDataStream({ onChunk: () => {} });

// Pre-fix: weatherTool.execute is NEVER called when the resume cycle
// starts with a tool-result and later emits a tool-call for weatherTool.
// The throw aborts processChatResponse_vNext, onFinish never fires,
// recursion never triggers.
// Scenario 2: approveToolCall (log noise)
const stream2 = await agent.approveToolCall({ runId, toolCallId, requestContext });
await stream2.processDataStream({ onChunk: () => {} });
// Pre-fix: server-side console.error fires for every approval.
// User-side stream content is unaffected.

Verified the bug exists on every published version from 1.13.4 through 1.18.0-alpha.8 and the most recent tip-of-main snapshot at the time of writing.

Fix

When toolInvocations is null at the time the tool-result chunk arrives, initialize it to [] and build a result-only invocation directly from the chunk payload. This is the legitimate resume-stream path.

Strict mid-stream desync detection is preserved: once any tool-call has populated the buffer, an unmatched toolCallId still throws tool_result must be preceded by a tool_call with the same toolCallId. Only the first-chunk-of-resume case is relaxed.

The same change is applied in both processChatResponse (legacy onToolResultPart) and processChatResponse_vNext (case 'tool-result'), since processStreamResponse and processStreamResponseLegacy both feed the resume-stream pattern.

Tests

Three new tests:

  1. agent.test.tsshould tolerate a tool-result as the first chunk (approve-tool-call / decline-tool-call resume): verifies the validator no longer throws on a leading tool-result and produces a result-only invocation.
  2. agent.test.tsshould still throw on mid-stream desync (tool-result without matching tool-call after another tool-call): verifies the strict desync check still fires once the buffer has been populated.
  3. agent.vnext.test.tsresumeStream: still runs client tools when the resume cycle starts with a tool-result (post-suspend): end-to-end repro of scenario 1 above. Asserts that executeSpy is called and the recursive fetch fires — both of which are unreachable on the unfixed SDK because onFinish never runs.

Local run on client-sdks/client-js: 419 passing tests (3 new). The 2 unrelated failing tests (agent.test.ts > Agent.stream > should handle vNext step-finish..., agent.vnext.test.ts > Agent vNext > resumeStream: omits local seed messages...) were already failing on main before this change — verified by stashing this PR's changes and re-running.

Changeset

Patch bump for @mastra/client-js. See .changeset/client-js-tolerate-resume-tool-result.md.

Notes

  • No public API change; observable as: (a) resumeStream + clientTools + post-suspend client tool calls now actually run; (b) absence of the spurious server-side log line on approveToolCall / declineToolCall consumers.
  • The fix is fully backward-compatible: streams that currently work continue to work identically. The relaxation kicks in only when toolInvocations was previously null at the moment the tool-result arrives, which is precisely the resume-stream scenario.

ELI5

When resuming an interrupted conversation, the client sometimes saw harmless server-side errors because a tool result arrived that referenced a tool-call from the previous turn. This PR stops those noisy errors by accepting a result-only chunk when resuming, so resume flows proceed cleanly.

Summary

This PR fixes client-js handling of tool-result chunks at the start of resume streams (approve-tool-call, decline-tool-call, resume-stream). Previously, processChatResponse and processChatResponse_vNext threw "tool_result must be preceded by a tool_call" when a tool-result arrived without a matching tool-call in the same stream (the matching call was emitted in a prior turn). Because processStreamResponse passes lastMessage: undefined for resume streams, the in-memory toolInvocations buffer could be empty and the validator errored; the error was caught and logged, producing noisy server-side logs despite correct client behavior.

Fix: when a tool-result chunk arrives and toolInvocations is null, initialize toolInvocations = [] and synthesize a result-only ToolInvocation from the chunk payload. Preserve strict mid-stream desync detection: if the buffer was already populated by a prior tool-call, an unmatched toolCallId still throws. Applied fixes in both processChatResponse (legacy onToolResultPart) and processChatResponse_vNext (case 'tool-result').

Changes

  • client-sdks/client-js/src/resources/agent.ts (+39/-22)

    • Tolerate result-only streams by initializing toolInvocations when null and synthesizing result-only invocations.
    • Retain existing validation when a tool-call buffer is present (unmatched toolCallId still errors).
  • client-sdks/client-js/src/resources/agent.test.ts (+67)

    • Added makeStream helper for SSE-like test streams.
    • Test A: first-chunk tool-result (resume scenario) is tolerated and creates a result-state ToolInvocation.
    • Test B: tool-call for call_a followed by tool-result for call_b still throws.
  • client-sdks/client-js/src/resources/agent.vnext.test.ts (+69)

    • Added resumeStream test ensuring a resume cycle that starts with a tool-result still triggers the recursive stream and executes the client tool exactly once.
  • .changeset/client-js-tolerate-resume-tool-result.md

    • Patch changeset for @mastra/client-js.

Tests & impact

  • Local client-js tests: +2 tests (417 → 419 passing in the reported run). Three unrelated failures in agent.vnext.test.ts existed on main prior to this change.
  • No public API changes. Backward-compatible: only relaxes validation when toolInvocations is null (resume-stream case).
  • Removes server-side log noise for resume-stream consumers and fixes a case that could block client tool execution in recursive resume cycles.

Review Change Stack

processChatResponse and processChatResponse_vNext threw `tool_result must
be preceded by a tool_call` whenever a tool-result chunk arrived without
a matching tool-call in the same stream. Resume streams (approve-tool-call,
decline-tool-call, resume-stream) legitimately start with a tool-result
whose matching tool-call was emitted in the prior turn, so this validator
fired on every HITL approve/decline. The error is caught by the outer
.catch and only logged via console.error("Error processing stream
response: ..."), so user UX is unaffected, but it pollutes logs and
masks real errors.

Fix: when toolInvocations is null at the time the tool-result chunk
arrives, initialize it to [] and synthesize a result-only invocation.
Strict mid-stream desync detection is preserved: once any tool-call has
populated the buffer, an unmatched toolCallId still throws
`tool_result must be preceded by a tool_call with the same toolCallId`.

Adds two tests:
- a tool-result arriving as the first chunk does not throw and produces
  a result-only invocation.
- a tool-result with no matching toolCallId mid-stream still throws.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 10, 2026

🦋 Changeset detected

Latest commit: e990a23

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@mastra/client-js Patch
@mastra/playground-ui Patch
@internal/playground Patch
@mastra/react Patch
mastra Patch
create-mastra Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 10, 2026

@machester4 is attempting to deploy a commit to the Mastra Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4bb1066b-d479-4fd8-b010-85bd595b1b5f

📥 Commits

Reviewing files that changed from the base of the PR and between 8d620fe and e990a23.

📒 Files selected for processing (1)
  • client-sdks/client-js/src/resources/agent.vnext.test.ts

Walkthrough

Resume streams now accept tool-result chunks at their start by synthesizing result-only invocations when no prior tool-call has been buffered. Existing desync validation for unmatched toolCallIds after a tool-call buffer is populated is preserved. The change spans dual sync/async handlers, documentation, and test coverage.

Changes

Resume Stream Tool-Result Tolerance

Layer / File(s) Summary
Behavioral Contract
.changeset/client-js-tolerate-resume-tool-result.md
Patch-level changesets entry documents tolerance of tool-result chunks at resume stream start, result-only invocation synthesis when toolInvocations is null, and preserved strict validation for mismatched toolCallIds.
Core Stream Handler Logic
client-sdks/client-js/src/resources/agent.ts
Both processChatResponse (onToolResultPart) and processChatResponse_vNext ('tool-result' chunk case) initialize empty invocation arrays when missing, synthesize result-only entries, and only throw for unmatched toolCallId if prior tool-calls were buffered.
Resume Stream Test Coverage
client-sdks/client-js/src/resources/agent.test.ts, client-sdks/client-js/src/resources/agent.vnext.test.ts
Adds makeStream helper for SSE-style ReadableStream construction and tests: first verifies tool-result at stream start produces result-only toolInvocations entry; second verifies unmatched toolCallId after prior tool-call throws expected desync error; vNext test covers resume cycle starting with tool-result and recursive request/tool execution behavior.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: fixing tool-result chunk tolerance on resume streams, using imperative mood and proper capitalization.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

@dane-ai-mastra
Copy link
Copy Markdown
Contributor

Thank you for your contribution!

Please ensure that your PR fixes an existing issue and that you have linked it in the description (e.g. with Fixes #1234).

We use CodeRabbit for automated code reviews. Please address all feedback from CodeRabbit by either making changes to your PR or leaving a comment explaining why you disagree with the feedback. Since CodeRabbit is an AI, it may occasionally provide incorrect feedback.

Addressing CodeRabbit's feedback will greatly increase the chances of your PR being merged. We appreciate your understanding and cooperation in helping us maintain high code quality standards.

Comment @coderabbitai review in case you want to trigger a review.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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 @.changeset/client-js-tolerate-resume-tool-result.md:
- Around line 5-9: The changeset is too implementation‑focused; rewrite it to
state the user‑facing outcome in one or two short sentences (e.g., "Resume
streams no longer log spurious 'tool-result' errors during
approve/decline/polling."), remove internal implementation details and function
names (do not mention processChatResponse, processChatResponse_vNext,
processStreamResponse, buffering, catch paths, or desync logic), and keep the
tone brief and release‑note appropriate per the guidelines.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 949462b5-33e1-444c-8527-3a47a9543963

📥 Commits

Reviewing files that changed from the base of the PR and between 4639cd6 and 8d620fe.

📒 Files selected for processing (3)
  • .changeset/client-js-tolerate-resume-tool-result.md
  • client-sdks/client-js/src/resources/agent.test.ts
  • client-sdks/client-js/src/resources/agent.ts

Comment on lines +5 to +9
Tolerate `tool-result` chunks at the start of resume streams (`approve-tool-call`, `decline-tool-call`, `resume-stream`).

Previously, `processChatResponse` and `processChatResponse_vNext` threw `tool_result must be preceded by a tool_call` whenever a `tool-result` chunk arrived without a matching `tool-call` in the same stream. Because `processStreamResponse` always passes `lastMessage: undefined`, resume streams (which legitimately start with a `tool-result` whose matching `tool-call` was emitted in the prior turn) would trip this validation on every HITL approve/decline and pollute logs with `Error processing stream response: tool_result must be preceded by a tool_call` via the outer `.catch`.

The fix synthesizes a result-only invocation when `toolInvocations` is null at the time the chunk arrives. Strict desync detection is preserved: once a `tool-call` has populated the buffer, an unmatched `toolCallId` still throws `tool_result must be preceded by a tool_call with the same toolCallId`.
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Changeset text is too implementation-heavy for release notes.

Please rewrite this entry to focus on user-visible outcome (resume streams no longer log false tool-result errors) and keep internals (processChatResponse*, buffer details, catch path) out of the changelog body.

Suggested rewrite
-Tolerate `tool-result` chunks at the start of resume streams (`approve-tool-call`, `decline-tool-call`, `resume-stream`).
-
-Previously, `processChatResponse` and `processChatResponse_vNext` threw `tool_result must be preceded by a tool_call` whenever a `tool-result` chunk arrived without a matching `tool-call` in the same stream. Because `processStreamResponse` always passes `lastMessage: undefined`, resume streams (which legitimately start with a `tool-result` whose matching `tool-call` was emitted in the prior turn) would trip this validation on every HITL approve/decline and pollute logs with `Error processing stream response: tool_result must be preceded by a tool_call` via the outer `.catch`.
-
-The fix synthesizes a result-only invocation when `toolInvocations` is null at the time the chunk arrives. Strict desync detection is preserved: once a `tool-call` has populated the buffer, an unmatched `toolCallId` still throws `tool_result must be preceded by a tool_call with the same toolCallId`.
+Fixed resume streams to accept a `tool-result` chunk as the first event in `approve-tool-call`, `decline-tool-call`, and `resume-stream`.
+
+This removes false validation errors and noisy logs during resume flows while keeping mismatch validation for genuinely out-of-sync tool events.

[potential_issue]
As per coding guidelines: “Highlight outcomes! … Do not focus on internal implementation details” and “Write short, direct sentences”.

📝 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
Tolerate `tool-result` chunks at the start of resume streams (`approve-tool-call`, `decline-tool-call`, `resume-stream`).
Previously, `processChatResponse` and `processChatResponse_vNext` threw `tool_result must be preceded by a tool_call` whenever a `tool-result` chunk arrived without a matching `tool-call` in the same stream. Because `processStreamResponse` always passes `lastMessage: undefined`, resume streams (which legitimately start with a `tool-result` whose matching `tool-call` was emitted in the prior turn) would trip this validation on every HITL approve/decline and pollute logs with `Error processing stream response: tool_result must be preceded by a tool_call` via the outer `.catch`.
The fix synthesizes a result-only invocation when `toolInvocations` is null at the time the chunk arrives. Strict desync detection is preserved: once a `tool-call` has populated the buffer, an unmatched `toolCallId` still throws `tool_result must be preceded by a tool_call with the same toolCallId`.
'@mastra/client-js': patch
---
Fixed resume streams to accept a `tool-result` chunk as the first event in `approve-tool-call`, `decline-tool-call`, and `resume-stream`.
This removes false validation errors and noisy logs during resume flows while keeping mismatch validation for genuinely out-of-sync tool events.
🤖 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 @.changeset/client-js-tolerate-resume-tool-result.md around lines 5 - 9, The
changeset is too implementation‑focused; rewrite it to state the user‑facing
outcome in one or two short sentences (e.g., "Resume streams no longer log
spurious 'tool-result' errors during approve/decline/polling."), remove internal
implementation details and function names (do not mention processChatResponse,
processChatResponse_vNext, processStreamResponse, buffering, catch paths, or
desync logic), and keep the tone brief and release‑note appropriate per the
guidelines.

Demonstrates the deeper bug fixed by tolerating a `tool-result` as the
first chunk of a resume stream: when `resumeStream` is called with
`clientTools` and the resume cycle emits a `tool-call` for one of those
client tools after the suspended tool's result, the client tool's
`execute` must run and trigger the recursive stream.

Pre-fix: the leading `tool-result` chunk threw inside
`processChatResponse_vNext`, the outer `.catch` ran, and `onFinish`
never fired — so the recursion path was unreachable and the client
tool was silently never executed (UX-level bug, not just log noise).
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.

1 participant