@@ -437,7 +437,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
437437 * Reset to `false` at the start of each API request.
438438 * Set to `true` only after the assistant message is durably saved.
439439 */
440- assistantMessageSavedToHistory = true
440+ assistantMessageSavedToHistory = false
441441 private assistantMessagePersistencePromise ! : Promise < AssistantMessagePersistenceResult >
442442 private resolveAssistantMessagePersistence ! : ( result : AssistantMessagePersistenceResult ) => void
443443 private assistantMessagePersistenceCancellation ?: AssistantMessagePersistenceCancellation
@@ -500,46 +500,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
500500 // Native tool call streaming state (track which index each tool is at)
501501 private streamingToolCallIndices : Map < string , number > = new Map ( )
502502
503- /**
504- * Finalize a streaming native tool call by id and present it.
505- *
506- * Shared by every site that observes a tool_call_end: the per-chunk event
507- * loop, the stream-level tool_call_end case, and the end-of-stream
508- * finalizeRawChunks() pass. Calling it again for an already-finalized id is a
509- * safe no-op because finalizeStreamingToolCall() and the index map entry are
510- * both cleared on first finalize.
511- */
512- private finalizeStreamingToolCallById ( id : string , nativeToolCallParserScope : object ) : void {
513- const finalToolUse = NativeToolCallParser . finalizeStreamingToolCall ( id , nativeToolCallParserScope )
514- const toolUseIndex = this . streamingToolCallIndices . get ( id )
515-
516- if ( finalToolUse ) {
517- ; ( finalToolUse as any ) . id = id
518- if ( toolUseIndex !== undefined ) {
519- this . assistantMessageContent [ toolUseIndex ] = finalToolUse
520- }
521- this . streamingToolCallIndices . delete ( id )
522- this . userMessageContentReady = false
523- /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */
524- this . presentAssistantMessageSafe ( )
525- } else if ( toolUseIndex !== undefined ) {
526- // finalizeStreamingToolCall returned null (malformed JSON or missing args).
527- // Clear partial arguments before presentation so truncated values cannot run
528- // or enter conversation history.
529- const existingToolUse = this . assistantMessageContent [ toolUseIndex ]
530- if ( existingToolUse && existingToolUse . type === "tool_use" ) {
531- existingToolUse . partial = false
532- existingToolUse . nativeArgs = undefined
533- existingToolUse . params = { }
534- ; ( existingToolUse as any ) . id = id
535- }
536- this . streamingToolCallIndices . delete ( id )
537- this . userMessageContentReady = false
538- /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */
539- this . presentAssistantMessageSafe ( )
540- }
541- }
542-
543503 // Cached model info for current streaming session (set at start of each API request)
544504 // This prevents excessive getModel() calls during tool execution
545505 cachedStreamingModel ?: { id : string ; info : ModelInfo }
@@ -1074,9 +1034,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
10741034 * If the message resolves a pending action, retries the save on initial failure before clearing the action.
10751035 */
10761036 private async addToApiConversationHistory ( message : Anthropic . MessageParam , reasoning ?: string ) : Promise < void > {
1077- if ( message . role === "assistant" ) {
1078- this . assistantMessageSavedToHistory = false
1079- }
10801037 const resolvesPendingAction =
10811038 this . pendingAction &&
10821039 message . role === "user" &&
@@ -1194,18 +1151,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11941151 * So we usually only need to flush the pending user message with tool_results.
11951152 */
11961153 public async flushPendingToolResultsToHistory ( ) : Promise < boolean > {
1154+ // Only flush if there's actually pending content to save
1155+ if ( this . userMessageContent . length === 0 ) {
1156+ return true
1157+ }
11971158 if ( this . abort ) {
11981159 return false
11991160 }
12001161
1201- // CRITICAL: Wait for the assistant message to be saved to API history before
1202- // any early return, including the empty-content case below. Delegation relies
1203- // on this barrier: an auto-approved new_task can execute while the parent
1204- // assistant turn still awaits persistence, and disposing the parent at that
1205- // point would leave its tool_use turn out of the durable history.
1206- //
1207- // Without the wait, tool_result blocks would also appear BEFORE tool_use blocks
1208- // in the conversation history, causing API errors like:
1162+ // CRITICAL: Wait for the assistant message to be saved to API history first.
1163+ // Without this, tool_result blocks would appear BEFORE tool_use blocks in the
1164+ // conversation history, causing API errors like:
12091165 // "unexpected `tool_use_id` found in `tool_result` blocks"
12101166 //
12111167 // This can happen when parallel tools are called (e.g., update_todo_list + new_task).
@@ -1230,11 +1186,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12301186 }
12311187 }
12321188
1233- // Only flush if there's actually pending content to save
1234- if ( this . userMessageContent . length === 0 ) {
1235- return true
1236- }
1237-
12381189 // If task was aborted while waiting, don't flush
12391190 if ( this . abort ) {
12401191 return false
@@ -3285,6 +3236,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
32853236 this . didRejectTool = false
32863237 this . didAlreadyUseTool = false
32873238 this . assistantMessageSavedToHistory = false
3239+ this . didFinishAbortingStream = false
32883240 this . resetAssistantMessagePersistence ( )
32893241 // Reset tool failure flag for each new assistant turn - this ensures that tool failures
32903242 // only prevent attempt_completion within the same assistant message, not across turns
@@ -3474,22 +3426,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
34743426 this . presentAssistantMessageSafe ( )
34753427 }
34763428 }
3477- } else if ( event . type === "tool_call_end" ) {
3478- this . finalizeStreamingToolCallById ( event . id , nativeToolCallParserScope )
34793429 }
34803430 }
34813431 break
34823432 }
34833433
3484- case "tool_call_end" : {
3485- // Providers emit a tool_call_end chunk when finish_reason is
3486- // "tool_calls" (either directly or via processFinishReason).
3487- // Finalize the streaming tool call now so it is presented during
3488- // streaming rather than waiting for finalizeRawChunks() at stream end.
3489- this . finalizeStreamingToolCallById ( chunk . id , nativeToolCallParserScope )
3490- break
3491- }
3492-
34933434 case "tool_call" : {
34943435 // Legacy: Handle complete tool calls (for backward compatibility)
34953436 // Convert native tool call to ToolUse format
@@ -3775,8 +3716,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
37753716 await abortStream ( cancelReason , streamingFailedMessage )
37763717
37773718 if ( this . abort ) {
3778- // User cancelled - abort the entire task
3779- this . abortReason = cancelReason
3719+ // ??= keeps the first reason; a cancel can land during abortStream after cancelReason was already computed.
3720+ this . abortReason ??= "user_cancelled"
37803721 await this . abortTask ( )
37813722 } else if ( error instanceof OutputTokenLimitError ) {
37823723 // Truncation repeats on an identical request, so never auto-retry it
@@ -3811,8 +3752,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
38113752 console . log (
38123753 `[Task#${ this . taskId } .${ this . instanceId } ] Task aborted during mid-stream retry backoff` ,
38133754 )
3814- // Abort the entire task
3815- this . abortReason = "user_cancelled"
3755+ this . abortReason ??= "user_cancelled"
38163756 await this . abortTask ( )
38173757 break
38183758 }
@@ -3858,7 +3798,64 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
38583798 const finalizeEvents = NativeToolCallParser . finalizeRawChunks ( nativeToolCallParserScope )
38593799 for ( const event of finalizeEvents ) {
38603800 if ( event . type === "tool_call_end" ) {
3861- this . finalizeStreamingToolCallById ( event . id , nativeToolCallParserScope )
3801+ // Finalize the streaming tool call
3802+ const finalToolUse = NativeToolCallParser . finalizeStreamingToolCall (
3803+ event . id ,
3804+ nativeToolCallParserScope ,
3805+ )
3806+
3807+ // Get the index for this tool call
3808+ const toolUseIndex = this . streamingToolCallIndices . get ( event . id )
3809+
3810+ if ( finalToolUse ) {
3811+ // Store the tool call ID
3812+ ; ( finalToolUse as any ) . id = event . id
3813+
3814+ // Get the index and replace partial with final
3815+ if ( toolUseIndex !== undefined ) {
3816+ this . assistantMessageContent [ toolUseIndex ] = finalToolUse
3817+ }
3818+
3819+ // Clean up tracking
3820+ this . streamingToolCallIndices . delete ( event . id )
3821+
3822+ // Mark that we have new content to process
3823+ this . userMessageContentReady = false
3824+
3825+ // Present the finalized tool call
3826+ /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */
3827+ this . presentAssistantMessageSafe ( )
3828+ } else if ( toolUseIndex !== undefined ) {
3829+ // finalizeStreamingToolCall returned null (malformed JSON or missing args).
3830+ // existingToolUse is the same object the streaming phase was mutating in
3831+ // place, so it still carries nativeArgs AND params built from the incomplete
3832+ // partial parse (e.g. a truncated write_to_file `content` string) - both were
3833+ // only ever meant for live progress display, never for execution or for
3834+ // ending up in conversation history. Mark the tool as non-partial so it's
3835+ // presented as complete, and clear both so presentAssistantMessage's
3836+ // `!block.nativeArgs` guard short-circuits with a structured tool_result
3837+ // instead of executing the truncated value, and so the toolUse.nativeArgs ||
3838+ // toolUse.params fallback used when recording history doesn't fall through to
3839+ // the same truncated data under a different name.
3840+ const existingToolUse = this . assistantMessageContent [ toolUseIndex ]
3841+ if ( existingToolUse && existingToolUse . type === "tool_use" ) {
3842+ existingToolUse . partial = false
3843+ existingToolUse . nativeArgs = undefined
3844+ existingToolUse . params = { }
3845+ // Ensure it has the ID for native protocol
3846+ ; ( existingToolUse as any ) . id = event . id
3847+ }
3848+
3849+ // Clean up tracking
3850+ this . streamingToolCallIndices . delete ( event . id )
3851+
3852+ // Mark that we have new content to process
3853+ this . userMessageContentReady = false
3854+
3855+ // Present the tool call - validation will handle missing params
3856+ /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */
3857+ this . presentAssistantMessageSafe ( )
3858+ }
38623859 }
38633860 }
38643861
0 commit comments