| 54 | * (model chose to emit no content blocks) |
| 55 | */ |
| 56 | export function isResultSuccessful( |
| 57 | message: Message | undefined, |
| 58 | stopReason: string | null = null, |
| 59 | ): message is Message { |
| 60 | if (!message) return false |
| 61 | |
| 62 | if (message.type === 'assistant') { |
| 63 | const lastContent = last(message.message.content) |
| 64 | return ( |
| 65 | lastContent?.type === 'text' || |
| 66 | lastContent?.type === 'thinking' || |
| 67 | lastContent?.type === 'redacted_thinking' |
| 68 | ) |
| 69 | } |
| 70 | |
| 71 | if (message.type === 'user') { |
| 72 | // Check if all content blocks are tool_result type |
| 73 | const content = message.message.content |
| 74 | if ( |
| 75 | Array.isArray(content) && |
| 76 | content.length > 0 && |
| 77 | content.every(block => 'type' in block && block.type === 'tool_result') |
| 78 | ) { |
| 79 | return true |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // Carve-out: API completed (message_delta set stop_reason) but yielded |
| 84 | // no assistant content — last(messages) is still this turn's prompt. |
| 85 | // claude.ts:2026 recognizes end_turn-with-zero-content-blocks as |
| 86 | // legitimate and passes through without throwing. Observed on |
| 87 | // task_notification drain turns: model returns stop_reason=end_turn, |
| 88 | // outputTokens=4, textContentLength=0 — it saw the subagent result |
| 89 | // and decided nothing needed saying. Without this, QueryEngine emits |
| 90 | // error_during_execution with errors[] = the entire process's |
| 91 | // accumulated logError() buffer. Covers both string-content and |
| 92 | // text-block-content user prompts, and any other non-passing shape. |
| 93 | return stopReason === 'end_turn' |
| 94 | } |
| 95 | |
| 96 | // Track last sent time for tool progress messages per tool use ID |
| 97 | // Keep only the last 100 entries to prevent unbounded growth |