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:
- Yields
{ uiChunks: allStepUIChunks, ... } to the agent (only tool-input-available chunks at this point).
- Receives
toolResults back from the agent.
- Calls
writeToolOutputToUI(...) — produces tool-output-available chunks and merges them into allAccumulatedUIChunks / allStepUIChunks.
- 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.
Bug
When
DurableAgent.stream({ collectUIMessages: true })is configured with astopWhenthat fires after a tool call, the tool executes and its output appears inresult.toolResults, but the matching tool part inresult.uiMessagesstays atstate: '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.2workflow:4.2.4ai:6.0.168Expected
After
stopWhenfires on a tool-call step,result.uiMessagesshould containtool-output-availableparts for every tool result that's already present inresult.toolResults.Actual
Tool parts in
result.uiMessagesremain atstate: 'input-available'. The correspondingtool-output-availableUI 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:{ uiChunks: allStepUIChunks, ... }to the agent (onlytool-input-availablechunks at this point).toolResultsback from the agent.writeToolOutputToUI(...)— producestool-output-availablechunks and merges them intoallAccumulatedUIChunks/allStepUIChunks.stopConditions. If matched → setsdone = true, the outerwhile (!done)loop exits.After the loop, the final flush yield at the bottom of the iterator is gated on
!lastStepWasToolCalls:Since
lastStepWasToolCallswas set totrueearlier in the tool-call branch, this flush is skipped — so the post-steptool-output-availablechunks never reachallUIChunksindurable-agent.js, andconvertChunksToUIMessages(allUIChunks)returns UIMessages with the tool parts stuck atinput-available.The companion
result.toolResults(top-level) is populated correctly vialastStepToolResultsindurable-agent.js, so the data isn't lost — just absent from the UI-message view.Suggested fix
Either:
lastStepWasToolCallsis true, so the post-stoptool-output-availablechunks reach the agent's chunk buffer; ordurable-agent.js, after the iterator returns, append any unflushed UI chunks captured fromwriteToolOutputToUIdirectly intoallUIChunksbefore callingconvertChunksToUIMessages.Workaround (in our codebase)
We post-process
result.uiMessagesby matching tool parts bytoolCallIdagainstresult.toolResultsand stampingstate: 'output-available'+outputonto any tool part that's missing its result.