{"version":3,"file":"transcript-validation.d.ts","sourceRoot":"","sources":["../../src/utils/transcript-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAoB,OAAO,EAAY,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAE1F,MAAM,WAAW,yBAAyB;IACzC,IAAI,EAAE,yBAAyB,CAAC;IAChC,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,mBAAmB,EAAE,MAAM,EAAE,CAAC;IAC9B,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,kBAAkB,GAAG,WAAW,CAAC;CAC3C;AAED,MAAM,WAAW,2BAA2B;IAC3C,EAAE,EAAE,IAAI,CAAC;CACT;AAED,MAAM,MAAM,0BAA0B,GAAG,2BAA2B,GAAG,yBAAyB,CAAC;AAcjG,wBAAgB,iCAAiC,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAMzG;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAMnG;AAsID;;;;;;;;;;GAUG;AACH,wBAAgB,iCAAiC,CAChD,QAAQ,EAAE,SAAS,OAAO,EAAE,EAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACX,0BAA0B,CA6H5B;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAC1C,QAAQ,EAAE,SAAS,OAAO,EAAE,EAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GACX,0BAA0B,CAqH5B;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG;IAC/D,KAAK,EAAE,OAAO,CAAC;IACf,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,mBAAmB,EAAE,MAAM,EAAE,CAAC;CAC9B,CA6CA;AAED;;GAEG;AACH,MAAM,MAAM,gCAAgC,GACzC,0BAA0B,GAC1B,yBAAyB,GACzB,2BAA2B,CAAC;AAE/B;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACzC,UAAU,EAAE,MAAM,EAClB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,EAC7B,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,EAChD,YAAY,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GACxB,gCAAgC,CAalC","sourcesContent":["/**\n * Provider-specific transcript validation.\n *\n * Validates that every assistant message containing tool_calls has exactly\n * matching tool result messages, with no missing, duplicate, or orphaned IDs.\n *\n * Two separate protocols:\n * - Chat Completions: uses `tool_call_id` pairing\n * - Responses API: uses `call_id` pairing\n */\n\nimport type { AssistantMessage, Message, ToolCall, ToolResultMessage } from \"../types.js\";\n\nexport interface TranscriptValidationError {\n\tcode: \"INVALID_TOOL_TRANSCRIPT\";\n\tmissingToolCallIds: string[];\n\tduplicateToolCallIds: string[];\n\torphanToolResultIds: string[];\n\tduplicateCallIds: string[];\n\tprovider: string;\n\tmodel: string;\n\tmessageIndex: number;\n\tprotocol: \"chat-completions\" | \"responses\";\n}\n\nexport interface TranscriptValidationSuccess {\n\tok: true;\n}\n\nexport type TranscriptValidationResult = TranscriptValidationSuccess | TranscriptValidationError;\n\nfunction throwValidationError(result: TranscriptValidationResult, field: string): void {\n\tif (!(\"code\" in result)) return;\n\tthrow new Error(\n\t\t`INVALID_TOOL_TRANSCRIPT: protocol=${result.protocol}, ` +\n\t\t\t`missingToolCallIds=[${result.missingToolCallIds.join(\", \")}], ` +\n\t\t\t`duplicateToolCallIds=[${result.duplicateToolCallIds.join(\", \")}], ` +\n\t\t\t`orphanToolResultIds=[${result.orphanToolResultIds.join(\", \")}], ` +\n\t\t\t`duplicateCallIds=[${result.duplicateCallIds.join(\", \")}], ` +\n\t\t\t`provider=${result.provider}, model=${result.model}, messageIndex=${result.messageIndex}, field=${field}`,\n\t);\n}\n\nexport function assertValidChatCompletionsPayload(payload: unknown, provider: string, model: string): void {\n\tconst messages = isRecord(payload) ? payload.messages : undefined;\n\tif (!Array.isArray(messages)) {\n\t\tthrow new Error(`INVALID_TOOL_TRANSCRIPT: protocol=chat-completions, missing messages`);\n\t}\n\tthrowValidationError(validateChatCompletionsTranscript(messages, provider, model), \"messages\");\n}\n\nexport function assertValidResponsesPayload(payload: unknown, provider: string, model: string): void {\n\tconst input = isRecord(payload) ? payload.input : undefined;\n\tif (!Array.isArray(input)) {\n\t\tthrow new Error(`INVALID_TOOL_TRANSCRIPT: protocol=responses, missing input`);\n\t}\n\tthrowValidationError(validateResponsesTranscript(input, provider, model), \"input\");\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null;\n}\n\nfunction asToolCall(id: unknown, name: unknown): ToolCall {\n\treturn {\n\t\ttype: \"toolCall\",\n\t\tid: typeof id === \"string\" ? id : \"\",\n\t\tname: typeof name === \"string\" ? name : \"\",\n\t\targuments: {},\n\t};\n}\n\nfunction asAssistantMessage(content: ToolCall[]): AssistantMessage {\n\treturn {\n\t\trole: \"assistant\",\n\t\tcontent,\n\t\tapi: \"openai-completions\",\n\t\tprovider: \"unknown\",\n\t\tmodel: \"unknown\",\n\t\tusage: {\n\t\t\tinput: 0,\n\t\t\toutput: 0,\n\t\t\tcacheRead: 0,\n\t\t\tcacheWrite: 0,\n\t\t\ttotalTokens: 0,\n\t\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n\t\t},\n\t\tstopReason: \"stop\",\n\t\ttimestamp: 0,\n\t};\n}\n\nfunction asToolResult(toolCallId: unknown): ToolResultMessage {\n\treturn {\n\t\trole: \"toolResult\",\n\t\ttoolCallId: typeof toolCallId === \"string\" ? toolCallId : \"\",\n\t\ttoolName: \"\",\n\t\tcontent: [],\n\t\tisError: false,\n\t\ttimestamp: 0,\n\t};\n}\n\nfunction normalizeChatCompletionsMessages(messages: readonly unknown[]): Message[] {\n\tconst normalized: Message[] = [];\n\tfor (const raw of messages) {\n\t\tif (!isRecord(raw)) continue;\n\t\tif (raw.role === \"assistant\") {\n\t\t\tconst toolCalls = Array.isArray(raw.tool_calls)\n\t\t\t\t? raw.tool_calls.map((call) => {\n\t\t\t\t\t\tif (!isRecord(call)) return asToolCall(undefined, undefined);\n\t\t\t\t\t\tconst functionData = isRecord(call.function) ? call.function : undefined;\n\t\t\t\t\t\treturn asToolCall(call.id, functionData?.name);\n\t\t\t\t\t})\n\t\t\t\t: [];\n\t\t\tnormalized.push(asAssistantMessage(toolCalls));\n\t\t} else if (raw.role === \"tool\") {\n\t\t\tnormalized.push(asToolResult(raw.tool_call_id));\n\t\t} else if (raw.role === \"user\") {\n\t\t\tnormalized.push({ role: \"user\", content: \"\", timestamp: 0 });\n\t\t}\n\t}\n\treturn normalized;\n}\n\nfunction normalizeResponsesMessages(messages: readonly unknown[]): Message[] {\n\tconst normalized: Message[] = [];\n\tlet pendingCalls: ToolCall[] = [];\n\tconst flushCalls = () => {\n\t\tif (pendingCalls.length > 0) {\n\t\t\tnormalized.push(asAssistantMessage(pendingCalls));\n\t\t\tpendingCalls = [];\n\t\t}\n\t};\n\n\tfor (const raw of messages) {\n\t\tif (!isRecord(raw)) continue;\n\t\tif (raw.type === \"function_call\") {\n\t\t\tpendingCalls.push(asToolCall(raw.call_id, raw.name));\n\t\t} else {\n\t\t\tflushCalls();\n\t\t\tif (raw.type === \"function_call_output\") {\n\t\t\t\tnormalized.push(asToolResult(raw.call_id));\n\t\t\t} else if (raw.role === \"user\") {\n\t\t\t\tnormalized.push({ role: \"user\", content: \"\", timestamp: 0 });\n\t\t\t}\n\t\t}\n\t}\n\tflushCalls();\n\treturn normalized;\n}\n\nfunction normalizeMessages(messages: readonly unknown[], protocol: \"chat-completions\" | \"responses\"): Message[] {\n\tif (messages.some((message) => isRecord(message) && message.role === \"toolResult\")) {\n\t\treturn messages as Message[];\n\t}\n\tif (\n\t\tmessages.some(\n\t\t\t(message) =>\n\t\t\t\tisRecord(message) &&\n\t\t\t\tmessage.role === \"assistant\" &&\n\t\t\t\tArray.isArray(message.content) &&\n\t\t\t\tmessage.content.some((block) => isRecord(block) && block.type === \"toolCall\"),\n\t\t)\n\t) {\n\t\treturn messages as Message[];\n\t}\n\tif (messages.some((message) => isRecord(message) && message.role === \"assistant\" && !(\"api\" in message))) {\n\t\treturn normalizeChatCompletionsMessages(messages);\n\t}\n\tif (\n\t\tmessages.some(\n\t\t\t(message) =>\n\t\t\t\tisRecord(message) && (message.type === \"function_call\" || message.type === \"function_call_output\"),\n\t\t)\n\t) {\n\t\treturn normalizeResponsesMessages(messages);\n\t}\n\tif (\n\t\tmessages.some(\n\t\t\t(message) =>\n\t\t\t\tisRecord(message) &&\n\t\t\t\t(message.role === \"tool\" || Array.isArray(message.tool_calls) || message.role === \"toolResult\"),\n\t\t)\n\t) {\n\t\treturn normalizeChatCompletionsMessages(messages);\n\t}\n\tif (protocol === \"responses\") return normalizeResponsesMessages(messages);\n\treturn messages as Message[];\n}\n\n/**\n * Validate a transcript for Chat Completions protocol.\n * For every assistant message with tool_calls, checks:\n * - Each tool call has a non-empty unique ID\n * - Exactly one tool result message responds to each ID\n * - No duplicate tool result messages\n * - No orphan tool result messages (result without a preceding tool call)\n * - All required tool results occur before the next user or assistant message\n * - Tool calls from different assistant turns are not merged\n * - A tool result appears in the uninterrupted span following its originating assistant\n */\nexport function validateChatCompletionsTranscript(\n\tmessages: readonly unknown[],\n\tprovider: string,\n\tmodel: string,\n): TranscriptValidationResult {\n\tconst normalizedMessages = normalizeMessages(messages, \"chat-completions\");\n\tconst missingToolCallIds: string[] = [];\n\tconst duplicateToolCallIds: string[] = [];\n\tconst orphanToolResultIds: string[] = [];\n\tconst duplicateCallIds: string[] = [];\n\tlet errorIndex = -1;\n\n\t// Track pending tool call IDs grouped by the assistant that emitted them.\n\t// Each assistant's tool calls must be resolved before the next assistant or user message.\n\ttype TurnSpan = {\n\t\tcallIds: Set<string>;\n\t\tassistantIndex: number;\n\t};\n\n\tlet currentSpan: TurnSpan | null = null;\n\t// Track seen tool call IDs to detect duplicates across spans\n\tconst seenToolCallIds = new Map<string, number>();\n\t// Track seen tool result IDs to detect duplicate results\n\tconst seenResultIds = new Map<string, number>();\n\n\tfor (let i = 0; i < normalizedMessages.length; i++) {\n\t\tconst msg = normalizedMessages[i];\n\n\t\tif (msg.role === \"assistant\") {\n\t\t\tconst assistantMsg = msg as AssistantMessage;\n\n\t\t\t// Skip errored/aborted assistant messages\n\t\t\tif (assistantMsg.stopReason === \"error\" || assistantMsg.stopReason === \"aborted\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Before starting a new assistant span, flush pending from the previous span\n\t\t\tif (currentSpan && currentSpan.callIds.size > 0) {\n\t\t\t\tfor (const pid of currentSpan.callIds) {\n\t\t\t\t\tmissingToolCallIds.push(pid);\n\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst toolCalls = assistantMsg.content.filter((b) => b.type === \"toolCall\") as ToolCall[];\n\n\t\t\tif (toolCalls.length > 0) {\n\t\t\t\t// Start a new span for this assistant's tool calls\n\t\t\t\tcurrentSpan = { callIds: new Set(), assistantIndex: i };\n\n\t\t\t\tfor (const tc of toolCalls) {\n\t\t\t\t\tif (!tc.id || tc.id.trim().length === 0) {\n\t\t\t\t\t\tmissingToolCallIds.push(\"<empty>\");\n\t\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tconst prevIndex = seenToolCallIds.get(tc.id);\n\t\t\t\t\tif (prevIndex !== undefined) {\n\t\t\t\t\t\tduplicateCallIds.push(tc.id);\n\t\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tseenToolCallIds.set(tc.id, i);\n\t\t\t\t\t}\n\t\t\t\t\tcurrentSpan!.callIds.add(tc.id);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Text-only assistant: the generic span-end handling below reports any\n\t\t\t\t// results still missing from the previous span before the next request.\n\t\t\t}\n\t\t} else if (msg.role === \"toolResult\") {\n\t\t\tconst toolResult = msg as ToolResultMessage;\n\t\t\tconst resultId = toolResult.toolCallId;\n\n\t\t\tif (!resultId || resultId.trim().length === 0) {\n\t\t\t\torphanToolResultIds.push(\"<empty>\");\n\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t} else if (currentSpan?.callIds.has(resultId)) {\n\t\t\t\t// Result belongs to current assistant's pending calls — valid\n\t\t\t\tcurrentSpan.callIds.delete(resultId);\n\t\t\t\tseenResultIds.set(resultId, i);\n\t\t\t} else if (seenResultIds.has(resultId)) {\n\t\t\t\tduplicateToolCallIds.push(resultId);\n\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t} else {\n\t\t\t\t// Not in current span and not seen before — orphaned\n\t\t\t\torphanToolResultIds.push(resultId);\n\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t}\n\t\t} else if (msg.role === \"user\") {\n\t\t\t// User message interrupts flow — flush all remaining pending\n\t\t\tif (currentSpan && currentSpan.callIds.size > 0) {\n\t\t\t\tfor (const pid of currentSpan.callIds) {\n\t\t\t\t\tmissingToolCallIds.push(pid);\n\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcurrentSpan = null;\n\t\t}\n\t}\n\n\t// Flush any remaining pending from the last span\n\tif (currentSpan && currentSpan.callIds.size > 0) {\n\t\tfor (const pid of currentSpan.callIds) {\n\t\t\tmissingToolCallIds.push(pid);\n\t\t\tif (errorIndex < 0) errorIndex = normalizedMessages.length - 1;\n\t\t}\n\t\tcurrentSpan = null;\n\t}\n\n\tif (\n\t\tmissingToolCallIds.length > 0 ||\n\t\tduplicateToolCallIds.length > 0 ||\n\t\torphanToolResultIds.length > 0 ||\n\t\tduplicateCallIds.length > 0\n\t) {\n\t\treturn {\n\t\t\tcode: \"INVALID_TOOL_TRANSCRIPT\",\n\t\t\tmissingToolCallIds,\n\t\t\tduplicateToolCallIds,\n\t\t\torphanToolResultIds,\n\t\t\tduplicateCallIds,\n\t\t\tprovider,\n\t\t\tmodel,\n\t\t\tmessageIndex: errorIndex >= 0 ? errorIndex : 0,\n\t\t\tprotocol: \"chat-completions\",\n\t\t};\n\t}\n\n\treturn { ok: true };\n}\n\n/**\n * Validate a transcript for Responses API protocol.\n * Uses function_call.call_id and function_call_output.call_id pairing.\n * Validates independently from Chat Completions - no protocol mixing.\n *\n * Each assistant's function calls form a span that must be resolved before\n * the next assistant or user message begins a new span.\n */\nexport function validateResponsesTranscript(\n\tmessages: readonly unknown[],\n\tprovider: string,\n\tmodel: string,\n): TranscriptValidationResult {\n\tconst normalizedMessages = normalizeMessages(messages, \"responses\");\n\tconst missingCallIds: string[] = [];\n\tconst duplicateCallIds: string[] = [];\n\tconst orphanOutputIds: string[] = [];\n\tconst duplicateCallIdValues: string[] = [];\n\tlet errorIndex = -1;\n\n\ttype TurnSpan = {\n\t\tcallIds: Set<string>;\n\t\tassistantIndex: number;\n\t};\n\n\tlet currentSpan: TurnSpan | null = null;\n\t// Track seen call IDs to detect duplicates across spans\n\tconst seenCallIds = new Map<string, number>();\n\t// Track seen output IDs to detect duplicate results\n\tconst seenOutputIds = new Map<string, number>();\n\n\tfor (let i = 0; i < normalizedMessages.length; i++) {\n\t\tconst msg = normalizedMessages[i];\n\n\t\tif (msg.role === \"assistant\") {\n\t\t\tconst assistantMsg = msg as AssistantMessage;\n\n\t\t\tif (assistantMsg.stopReason === \"error\" || assistantMsg.stopReason === \"aborted\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Before starting a new assistant span, flush pending from previous\n\t\t\tif (currentSpan && currentSpan.callIds.size > 0) {\n\t\t\t\tfor (const pid of currentSpan.callIds) {\n\t\t\t\t\tmissingCallIds.push(pid);\n\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst toolCalls = assistantMsg.content.filter((b) => b.type === \"toolCall\") as ToolCall[];\n\n\t\t\tif (toolCalls.length > 0) {\n\t\t\t\tcurrentSpan = { callIds: new Set(), assistantIndex: i };\n\n\t\t\t\tfor (const tc of toolCalls) {\n\t\t\t\t\tif (!tc.id || tc.id.trim().length === 0) {\n\t\t\t\t\t\tmissingCallIds.push(\"<empty>\");\n\t\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tconst prevIndex = seenCallIds.get(tc.id);\n\t\t\t\t\tif (prevIndex !== undefined) {\n\t\t\t\t\t\tduplicateCallIdValues.push(tc.id);\n\t\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tseenCallIds.set(tc.id, i);\n\t\t\t\t\t}\n\t\t\t\t\tcurrentSpan!.callIds.add(tc.id);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Text-only assistant: the generic span-end handling below reports any\n\t\t\t\t// results still missing from the previous span before the next request.\n\t\t\t}\n\t\t} else if (msg.role === \"toolResult\") {\n\t\t\tconst toolResult = msg as ToolResultMessage;\n\t\t\tconst callId = toolResult.toolCallId;\n\n\t\t\tif (!callId || callId.trim().length === 0) {\n\t\t\t\torphanOutputIds.push(\"<empty>\");\n\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t} else if (currentSpan?.callIds.has(callId)) {\n\t\t\t\tcurrentSpan.callIds.delete(callId);\n\t\t\t\tseenOutputIds.set(callId, i);\n\t\t\t} else if (seenOutputIds.has(callId)) {\n\t\t\t\tduplicateCallIds.push(callId);\n\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t} else {\n\t\t\t\torphanOutputIds.push(callId);\n\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t}\n\t\t} else if (msg.role === \"user\") {\n\t\t\tif (currentSpan && currentSpan.callIds.size > 0) {\n\t\t\t\tfor (const pid of currentSpan.callIds) {\n\t\t\t\t\tmissingCallIds.push(pid);\n\t\t\t\t\tif (errorIndex < 0) errorIndex = i;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcurrentSpan = null;\n\t\t}\n\t}\n\n\tif (currentSpan && currentSpan.callIds.size > 0) {\n\t\tfor (const pid of currentSpan.callIds) {\n\t\t\tmissingCallIds.push(pid);\n\t\t\tif (errorIndex < 0) errorIndex = normalizedMessages.length - 1;\n\t\t}\n\t\tcurrentSpan = null;\n\t}\n\n\tif (\n\t\tmissingCallIds.length > 0 ||\n\t\tduplicateCallIds.length > 0 ||\n\t\torphanOutputIds.length > 0 ||\n\t\tduplicateCallIdValues.length > 0\n\t) {\n\t\treturn {\n\t\t\tcode: \"INVALID_TOOL_TRANSCRIPT\",\n\t\t\tmissingToolCallIds: missingCallIds,\n\t\t\tduplicateToolCallIds: duplicateCallIds,\n\t\t\torphanToolResultIds: orphanOutputIds,\n\t\t\tduplicateCallIds: duplicateCallIdValues,\n\t\t\tprovider,\n\t\t\tmodel,\n\t\t\tmessageIndex: errorIndex >= 0 ? errorIndex : 0,\n\t\t\tprotocol: \"responses\",\n\t\t};\n\t}\n\n\treturn { ok: true };\n}\n\n/**\n * Validate tool call/result span integrity in a raw agent message array.\n * Checks that every assistant message with tool_calls has all matching\n * tool result messages before the next non-tool-result message.\n *\n * This operates on the raw AgentMessage[] level (which includes toolResult\n * and non-LLM messages) before convertToLlm.\n */\nexport function validateToolSpanIntegrity(messages: Message[]): {\n\tvalid: boolean;\n\tmissingToolCallIds: string[];\n\torphanToolResultIds: string[];\n} {\n\tconst missingToolCallIds: string[] = [];\n\tconst orphanToolResultIds: string[] = [];\n\tlet pendingToolCallIds = new Set<string>();\n\n\tfor (let i = 0; i < messages.length; i++) {\n\t\tconst msg = messages[i];\n\n\t\tif (msg.role === \"assistant\") {\n\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\tif (assistantMsg.stopReason === \"error\" || assistantMsg.stopReason === \"aborted\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Before processing new assistant, check if there are still pending\n\t\t\tfor (const pid of pendingToolCallIds) {\n\t\t\t\tmissingToolCallIds.push(pid);\n\t\t\t}\n\n\t\t\tconst toolCalls = assistantMsg.content.filter((b) => b.type === \"toolCall\") as ToolCall[];\n\t\t\tpendingToolCallIds = new Set(toolCalls.map((tc) => tc.id).filter((id) => id.trim().length > 0));\n\t\t} else if (msg.role === \"toolResult\") {\n\t\t\tconst toolResult = msg as ToolResultMessage;\n\t\t\tif (pendingToolCallIds.has(toolResult.toolCallId)) {\n\t\t\t\tpendingToolCallIds.delete(toolResult.toolCallId);\n\t\t\t} else {\n\t\t\t\torphanToolResultIds.push(toolResult.toolCallId);\n\t\t\t}\n\t\t} else if (msg.role === \"user\") {\n\t\t\tfor (const pid of pendingToolCallIds) {\n\t\t\t\tmissingToolCallIds.push(pid);\n\t\t\t}\n\t\t\tpendingToolCallIds = new Set();\n\t\t}\n\t}\n\n\tfor (const pid of pendingToolCallIds) {\n\t\tmissingToolCallIds.push(pid);\n\t}\n\n\treturn {\n\t\tvalid: missingToolCallIds.length === 0 && orphanToolResultIds.length === 0,\n\t\tmissingToolCallIds,\n\t\torphanToolResultIds,\n\t};\n}\n\n/**\n * Classify an unresolved tool call after an interrupt or resume.\n */\nexport type UnresolvedToolCallClassification =\n\t| \"DURABLE_RESULT_AVAILABLE\"\n\t| \"DEFINITELY_NOT_EXECUTED\"\n\t| \"EXECUTION_OUTCOME_UNKNOWN\";\n\n/**\n * Classify the status of an unresolved tool call based on available state.\n *\n * @param toolCallId - The tool call ID to classify\n * @param pendingToolCalls - Set of tool call IDs still pending execution\n * @param persistedResults - Map of tool call IDs to their persisted result messages\n * @param executionLog - Optional set of tool call IDs confirmed to have started execution\n */\nexport function classifyUnresolvedToolCall(\n\ttoolCallId: string,\n\tpendingToolCalls: Set<string>,\n\tpersistedResults: Map<string, ToolResultMessage>,\n\texecutionLog?: Set<string>,\n): UnresolvedToolCallClassification {\n\t// If we have a persisted result, it's durable\n\tif (persistedResults.has(toolCallId)) {\n\t\treturn \"DURABLE_RESULT_AVAILABLE\";\n\t}\n\n\t// If the tool call was never started (not in pending or execution log), not executed\n\tif (!pendingToolCalls.has(toolCallId) && (!executionLog || !executionLog.has(toolCallId))) {\n\t\treturn \"DEFINITELY_NOT_EXECUTED\";\n\t}\n\n\t// Otherwise, outcome is unknown (was started but result not persisted)\n\treturn \"EXECUTION_OUTCOME_UNKNOWN\";\n}\n"]}