( messages: (UserMessage | AssistantMessage)[], )
| 5131 | * that will be rejected at submission anyway. |
| 5132 | */ |
| 5133 | export function ensureToolResultPairing( |
| 5134 | messages: (UserMessage | AssistantMessage)[], |
| 5135 | ): (UserMessage | AssistantMessage)[] { |
| 5136 | const result: (UserMessage | AssistantMessage)[] = [] |
| 5137 | let repaired = false |
| 5138 | |
| 5139 | // Cross-message tool_use ID tracking. The per-message seenToolUseIds below |
| 5140 | // only caught duplicates within a single assistant's content array (the |
| 5141 | // normalizeMessagesForAPI-merged case). When two assistants with DIFFERENT |
| 5142 | // message.id carry the same tool_use ID — e.g. orphan handler re-pushed an |
| 5143 | // assistant already present in mutableMessages with a fresh message.id, or |
| 5144 | // normalizeMessagesForAPI's backward walk broke on an intervening user |
| 5145 | // message — the dup lived in separate result entries and the API rejected |
| 5146 | // with "tool_use ids must be unique", deadlocking the session (CC-1212). |
| 5147 | const allSeenToolUseIds = new Set<string>() |
| 5148 | |
| 5149 | for (let i = 0; i < messages.length; i++) { |
| 5150 | const msg = messages[i]! |
| 5151 | |
| 5152 | if (msg.type !== 'assistant') { |
| 5153 | // A user message with tool_result blocks but NO preceding assistant |
| 5154 | // message in the output has orphaned tool_results. The assistant |
| 5155 | // lookahead below only validates assistant→user adjacency; it never |
| 5156 | // sees user messages at index 0 or user messages preceded by another |
| 5157 | // user. This happens on resume when the transcript starts mid-turn |
| 5158 | // (e.g. messages[0] is a tool_result whose assistant pair was dropped |
| 5159 | // by earlier compaction — API rejects with "messages.0.content: |
| 5160 | // unexpected tool_use_id"). |
| 5161 | if ( |
| 5162 | msg.type === 'user' && |
| 5163 | Array.isArray(msg.message.content) && |
| 5164 | result.at(-1)?.type !== 'assistant' |
| 5165 | ) { |
| 5166 | const stripped = msg.message.content.filter( |
| 5167 | block => |
| 5168 | !( |
| 5169 | typeof block === 'object' && |
| 5170 | 'type' in block && |
| 5171 | block.type === 'tool_result' |
| 5172 | ), |
| 5173 | ) |
| 5174 | if (stripped.length !== msg.message.content.length) { |
| 5175 | repaired = true |
| 5176 | // If stripping emptied the message and nothing has been pushed yet, |
| 5177 | // keep a placeholder so the payload still starts with a user |
| 5178 | // message (normalizeMessagesForAPI runs before us, so messages[1] |
| 5179 | // is an assistant — dropping messages[0] entirely would yield a |
| 5180 | // payload starting with assistant, a different 400). |
| 5181 | const content = |
| 5182 | stripped.length > 0 |
| 5183 | ? stripped |
| 5184 | : result.length === 0 |
| 5185 | ? [ |
| 5186 | { |
| 5187 | type: 'text' as const, |
| 5188 | text: '[Orphaned tool result removed due to conversation resume]', |
| 5189 | }, |
| 5190 | ] |
no test coverage detected