vercel / vercel/workflow

DurableAgent: tool-output chunks dropped from result.uiMessages when stopWhen fires on a tool-call step

Open
#1,912 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
2.4k
Forks
365
Avg merge
2d 11h
Merged PRs (30d)
169

Description

Bug

When DurableAgent.stream({ collectUIMessages: true }) is configured with a stopWhen that fires after a tool call, the tool executes and its output appears in result.toolResults, but the matching tool part in result.uiMessages stays at state: 'input-available' instead of 'output-available'.

This breaks any downstream code that persists or renders uiMessages (DB writes, replay, UI rendering) — the saved/replayed message looks like a tool was called and never returned.

Versions

  • @workflow/ai: 4.1.2
  • workflow: 4.2.4
  • ai: 6.0.168

Expected

After stopWhen fires on a tool-call step, result.uiMessages should contain tool-output-available parts for every tool result that's already present in result.toolResults.

Actual

Tool parts in result.uiMessages remain at state: 'input-available'. The corresponding tool-output-available UI chunks never reach the agent's collected chunk buffer.

Root cause

In dist/agent/stream-text-iterator.js (verified against @workflow/ai@4.1.2), the iterator does this on a tool-call step:

  1. Yields { uiChunks: allStepUIChunks, ... } to the agent (only tool-input-available chunks at this point).
  2. Receives toolResults back from the agent.
  3. Calls writeToolOutputToUI(...) — produces tool-output-available chunks and merges them into allAccumulatedUIChunks / allStepUIChunks.
  4. Evaluates stopConditions. If matched → sets done = true, the outer while (!done) loop exits.

After the loop, the final flush yield at the bottom of the iterator is gated on !lastStepWasToolCalls:

if (lastStep && !lastStepWasToolCalls) {
  const finalUIChunks = [
    ...allAccumulatedUIChunks,
    ...(lastStepUIChunks ?? []),
  ];
  yield { ..., uiChunks: finalUIChunks, ... };
}

Since lastStepWasToolCalls was set to true earlier in the tool-call branch, this flush is skipped — so the post-step tool-output-available chunks never reach allUIChunks in durable-agent.js, and convertChunksToUIMessages(allUIChunks) returns UIMessages with the tool parts stuck at input-available.

The companion result.toolResults (top-level) is populated correctly via lastStepToolResults in durable-agent.js, so the data isn't lost — just absent from the UI-message view.

Suggested fix

Either:

  • Yield a final flush even when lastStepWasToolCalls is true, so the post-stop tool-output-available chunks reach the agent's chunk buffer; or
  • In durable-agent.js, after the iterator returns, append any unflushed UI chunks captured from writeToolOutputToUI directly into allUIChunks before calling convertChunksToUIMessages.

Workaround (in our codebase)

We post-process result.uiMessages by matching tool parts by toolCallId against result.toolResults and stamping state: 'output-available' + output onto any tool part that's missing its result.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing the tool-call path in dist/agent/stream-text-iterator.js and how durable-agent.js collects uiChunks into result.uiMessages. Reproduce the stopWhen case with collectUIMessages enabled, then verify that tool results already present in result.toolResults also produce tool-output-available parts in result.uiMessages after collection.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.