* Determines whether the conversation was interrupted mid-turn based on the * last message after filtering. An assistant as last message (after filtering * unresolved tool_uses) is treated as a completed turn because stop_reason is * always null on persisted messages in the streaming path. * *
( messages: NormalizedMessage[], )
| 270 | * interruption. Attachments are kept as part of the turn. |
| 271 | */ |
| 272 | function detectTurnInterruption( |
| 273 | messages: NormalizedMessage[], |
| 274 | ): InternalInterruptionState { |
| 275 | if (messages.length === 0) { |
| 276 | return { kind: 'none' } |
| 277 | } |
| 278 | |
| 279 | // Find the last turn-relevant message, skipping system/progress and |
| 280 | // synthetic API error assistants. Error assistants are already filtered |
| 281 | // before API send (normalizeMessagesForAPI) — skipping them here lets |
| 282 | // auto-resume fire after retry exhaustion instead of reading the error as |
| 283 | // a completed turn. |
| 284 | const lastMessageIdx = messages.findLastIndex( |
| 285 | m => |
| 286 | m.type !== 'system' && |
| 287 | m.type !== 'progress' && |
| 288 | !(m.type === 'assistant' && m.isApiErrorMessage), |
| 289 | ) |
| 290 | const lastMessage = |
| 291 | lastMessageIdx !== -1 ? messages[lastMessageIdx] : undefined |
| 292 | |
| 293 | if (!lastMessage) { |
| 294 | return { kind: 'none' } |
| 295 | } |
| 296 | |
| 297 | if (lastMessage.type === 'assistant') { |
| 298 | // In the streaming path, stop_reason is always null on persisted messages |
| 299 | // because messages are recorded at content_block_stop time, before |
| 300 | // message_delta delivers the stop_reason. After filterUnresolvedToolUses |
| 301 | // has removed assistant messages with unmatched tool_uses, an assistant as |
| 302 | // the last message means the turn most likely completed normally. |
| 303 | return { kind: 'none' } |
| 304 | } |
| 305 | |
| 306 | if (lastMessage.type === 'user') { |
| 307 | if (lastMessage.isMeta || lastMessage.isCompactSummary) { |
| 308 | return { kind: 'none' } |
| 309 | } |
| 310 | if (isToolUseResultMessage(lastMessage)) { |
| 311 | // Brief mode (#20467) drops the trailing assistant text block, so a |
| 312 | // completed brief-mode turn legitimately ends on SendUserMessage's |
| 313 | // tool_result. Without this check, resume misclassifies every |
| 314 | // brief-mode session as interrupted mid-turn and injects a phantom |
| 315 | // "Continue from where you left off." before the user's real next |
| 316 | // prompt. Look back one step for the originating tool_use. |
| 317 | if (isTerminalToolResult(lastMessage, messages, lastMessageIdx)) { |
| 318 | return { kind: 'none' } |
| 319 | } |
| 320 | return { kind: 'interrupted_turn' } |
| 321 | } |
| 322 | // Plain text user prompt — CC hadn't started responding |
| 323 | return { kind: 'interrupted_prompt', message: lastMessage } |
| 324 | } |
| 325 | |
| 326 | if (lastMessage.type === 'attachment') { |
| 327 | // Attachments are part of the user turn — the user provided context but |
| 328 | // the assistant never responded. |
| 329 | return { kind: 'interrupted_turn' } |
no test coverage detected