presentAssistantMessage: the assistant-message block presenter
Responsibilities
presentAssistantMessage is the "block advancer" inside the Task class. After the LLM stream is sliced into text / tool_use / reasoning blocks by parseAssistantMessageV2, this method is responsible for pushing those blocks to the UI and the tool executor one by one. It does not run in a loop of its own — each time it is called by a stream callback or by scheduleAssistantPresentation, it advances one block, then recursively calls itself to advance the next.
It sits at the innermost layer of the agent loop. recursivelyMakeClineRequests starts attemptApiRequest to grab the stream; inside the stream callback, the accumulated text is re-parsed into the assistantMessageContent array, and then presentAssistantMessage is called. So when you read this method, think of it as a state machine that is "woken repeatedly while the stream is still emitting, to see whether there is a new block to present".
The state it manages lives on taskState: currentStreamingContentIndex tracks which block it has advanced to, presentAssistantMessageLocked is a spin lock that prevents re-entry, presentAssistantMessageHasPendingUpdates flags "new content arrived while I was running", and userMessageContentReady is the signal to the outer pWaitFor that "all blocks for this round have been processed".
Design motivation
- Lock + pending flag instead of a queue: There is no message queue; instead, a boolean lock plus a "if there is pending, run once more" tail-recursive re-entry control (
lock check:2637). Simple, and it naturally coalesces multiple stream callbacks into one execution. - Present as it streams: It does not wait for the full response to finish; it advances as far as the stream has reached. A text block goes through
say("text", content, ..., block.partial)incrementally; a tool_use block, once complete, is immediately handed totoolExecutor.executeTool. - Blocks are serial: When parallel tool calling is off, the
didAlreadyUseToolflag makes subsequent blocks skip execution directly (parallel gate:2668). Serial execution ensures the user is not interrupted by a new tool while approving another. - cloneDeep against reference tampering: When taking a block, it deep-clones it before processing, because the stream is still updating properties on the objects in the original array — taking a reference directly would read a half-finished object (
cloneDeep:2658). - Out-of-bounds is normal: An out-of-bounds index is not an error; it is the signal "the stream has not produced the next block yet, you came too early". Only if the stream has already ended (
didCompleteReadingStream) does it setuserMessageContentReadyto let the outer layer continue (oob handling:2650).
Key files
presentAssistantMessage:2630— the method body; the lock, block-type dispatch, and advance logic all live here.lock + pending:2637— re-entry protection: if already locked, set pending and return.cloneDeep block:2658— deep-clones the current block to avoid reading half-finished data the stream is writing.switch block.type:2663— dispatches bytext/tool_use; reasoning goes through a separate path.thinking tag strip:2685— strips<thinking>,<function_calls>, and other tags so they do not pollute markdown rendering.say text:2731— sends the cleaned text content to the UI;block.partialcontrols incremental update vs final version.checkpoint gate:2737— when an initial checkpoint commit is running, non-read-only tools must wait for it to finish before executing.executeTool:2743— tool_use blocks go toToolExecutor.executeTool; this method itself does not care about concrete tools.userMessageContentReady:2769— set to true when the last block completes, unblocking the outerpWaitFor.tail recursion:2780— if there are more blocks, it calls itself to advance to the next one, without waiting for a stream callback.parseAssistantMessageV2 call:3506— in the stream callback, re-parses the entire assistant text to produce the block array.flush callback:685— the flush entry registered withpresentationScheduler; ultimately lands here.
Data flow
Every time presentAssistantMessage is woken, it first grabs the lock. Once it has the lock, it checks whether the current index is in bounds; if so, it takes the block and dispatches by type. The snippet below is the core logic for advancing to the next block after dispatch:
// apps/vscode/src/core/task/index.ts
if (
!block.partial ||
this.taskState.didRejectTool ||
(!this.isParallelToolCallingEnabled() && this.taskState.didAlreadyUseTool)
) {
// block is finished streaming and executing
if (
this.taskState.currentStreamingContentIndex ===
this.taskState.assistantMessageContent.length - 1
) {
// last block is complete and it is finished executing
this.taskState.userMessageContentReady = true; // will allow pwaitfor to continue
}
// call next block if it exists (if not then read stream will call it when its ready)
this.taskState.currentStreamingContentIndex++; // need to increment regardless, so when read stream calls this function again it will be streaming the next block
if (
this.taskState.currentStreamingContentIndex <
this.taskState.assistantMessageContent.length
) {
// there are already more content blocks to stream, so we'll call this function ourselves
await this.presentAssistantMessage();
return;
}
}
// block is partial, but the read stream may have finished
if (this.taskState.presentAssistantMessageHasPendingUpdates) {
await this.presentAssistantMessage();
}This decides "after the current block is done, do we go straight to the next one?". If the block is partial (still streaming), it does not advance proactively; it waits for the next stream callback. If the block is complete, it advances the index; if the new index is still within the array, it calls itself to advance to the next block without waiting for a stream callback. The final presentAssistantMessageHasPendingUpdates is the fallback: if the stream moved forward during execution, it runs once more. userMessageContentReady is set only when "the last block is complete" (ready flag:2769).
Boundaries and failure modes
- abort preempts: The very first thing at the method entry is checking
taskState.abort; if cancelled, it throws "Cline instance aborted" directly (abort guard:2631). This ensures the cancel signal takes effect before any block executes. - Serial skip after a tool is rejected: Once
didRejectToolis true, subsequent text blocksbreakdirectly; tool_use blocks are also caught by ToolExecutor's own rejection check when they go through it (reject gate:2667). The index still advances until it goes out of bounds, thenuserMessageContentReadyis set so the outer layer takes back control. - Initial checkpoint blocking: If an
initialCheckpointCommitPromiseis running at task start, non-read-only tools (!READ_ONLY_TOOLS.includes(block.name)) must wait for it to finish (checkpoint wait:2737). Read-only tools can run in parallel. - Lock-leak protection: The lock is released before the dispatch switch (
early unlock:2754). This looks odd but is intentional — later it callspresentAssistantMessageitself, and holding the lock would deadlock against itself. - Partial-block cleanup: A text block is sent to the UI even in partial state, but its tail may contain half an XML tag (e.g.
<thinknot yet closed). The code checks whether what follows the last<is a valid tag name and trims it if so, to avoid UI jitter (partial tag trim:2695). - Stream ends first, block arrives later: When
didCompleteReadingStreamis already true and the index is out of bounds,userMessageContentReadyis set directly so the outer pWaitFor continues (stream done oob:2650). It does not naively wait for a block that will never arrive.
Summary
presentAssistantMessage is the advancer at the innermost layer of the agent loop. It takes "present as far as the stream has reached" to the extreme: run when there is a new block, wait when there is not, and release when the stream ends. All UI presentation and tool-execution entries converge on this one method. To continue into how tools execute, go to /tools/coordinator and /tools/validator; to see the recursive driver one layer up, go to /agent-loop/attempt-api-request and /agent-loop/task-class.
See official docs: Cline docs · README