import type { Message, Part } from "@opencode-ai/sdk" import { subagentSessions } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" const TOOL_RESULT_PLACEHOLDER = "Tool output unavailable (context compacted)" type ToolUsePart = { type: "tool_use" id: string [key: string]: unknown } type ToolResultPart = { type: "tool_result" toolUseId: string tool_use_id?: string isError?: boolean content: Array<{ type: "text"; text: string }> [key: string]: unknown } type TransformPart = Part | ToolUsePart | ToolResultPart type TransformMessageInfo = Message | { role: "user" sessionID?: string } interface MessageWithParts { info: TransformMessageInfo parts: TransformPart[] } type MessagesTransformHook = { "experimental.chat.messages.transform"?: ( input: Record, output: { messages: MessageWithParts[] } ) => Promise } function getToolUseID(part: TransformPart): string | null { const candidate = part as { type?: unknown; id?: unknown; callID?: unknown } if (candidate.type === "tool_use" && typeof candidate.id === "string" && candidate.id.length > 0) { return candidate.id } if (candidate.type === "tool" && typeof candidate.callID === "string" && candidate.callID.length > 0) { return candidate.callID } return null } function getToolResultID(part: TransformPart): string | null { const candidate = part as { type?: unknown; toolUseId?: unknown; tool_use_id?: unknown } if (candidate.type !== "tool_result") { return null } if (typeof candidate.toolUseId === "string" && candidate.toolUseId.length > 0) { return candidate.toolUseId } if (typeof candidate.tool_use_id === "string" && candidate.tool_use_id.length > 0) { return candidate.tool_use_id } return null } function extractUniqueToolUseIDs(parts: TransformPart[]): string[] { const seen = new Set() const toolUseIDs: string[] = [] for (const part of parts) { const toolUseID = getToolUseID(part) if (!toolUseID || seen.has(toolUseID)) { continue } seen.add(toolUseID) toolUseIDs.push(toolUseID) } return toolUseIDs } function extractToolResultIDs(parts: TransformPart[]): Set { const toolResultIDs = new Set() for (const part of parts) { const toolResultID = getToolResultID(part) if (toolResultID) { toolResultIDs.add(toolResultID) } } return toolResultIDs } function createToolResultPart(toolUseID: string): ToolResultPart { return { type: "tool_result", toolUseId: toolUseID, tool_use_id: toolUseID, isError: true, content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], } } function findToolResultInsertIndex(parts: TransformPart[]): number { let lastToolResultIndex = -1 for (let i = 0; i < parts.length; i++) { if (getToolResultID(parts[i])) { lastToolResultIndex = i } } return lastToolResultIndex === -1 ? 0 : lastToolResultIndex + 1 } function insertMissingToolResults(message: MessageWithParts, missingToolUseIDs: string[]): void { const toolResultParts = missingToolUseIDs.map((toolUseID) => createToolResultPart(toolUseID)) const insertIndex = findToolResultInsertIndex(message.parts) message.parts.splice(insertIndex, 0, ...toolResultParts) } function createSyntheticUserMessage(assistantMessage: MessageWithParts, missingToolUseIDs: string[]): MessageWithParts { const assistantInfo = assistantMessage.info as { sessionID?: unknown } const sessionID = typeof assistantInfo.sessionID === "string" ? assistantInfo.sessionID : undefined return { info: { role: "user", ...(sessionID ? { sessionID } : {}), }, parts: missingToolUseIDs.map((toolUseID) => createToolResultPart(toolUseID)), } } function getMessageID(message: TransformMessageInfo): string | undefined { const candidate = message as { id?: unknown } return typeof candidate.id === "string" ? candidate.id : undefined } function getMessageSessionID(message: TransformMessageInfo): string | undefined { const candidate = message as { sessionID?: unknown } return typeof candidate.sessionID === "string" ? candidate.sessionID : undefined } function repairMissingToolResults(messages: MessageWithParts[], assistantIndex: number): void { const assistantMessage = messages[assistantIndex] const toolUseIDs = extractUniqueToolUseIDs(assistantMessage.parts) if (toolUseIDs.length === 0) { return } const nextMessage = messages[assistantIndex + 1] if (nextMessage?.info.role !== "user") { messages.splice(assistantIndex + 1, 0, createSyntheticUserMessage(assistantMessage, toolUseIDs)) log("[tool-pair-validator] Repaired missing tool_result blocks", { assistantMessageID: getMessageID(assistantMessage.info), syntheticUserMessageInserted: true, repairedToolUseIDs: toolUseIDs, }) return } const existingToolResultIDs = extractToolResultIDs(nextMessage.parts) const missingToolUseIDs = toolUseIDs.filter((toolUseID) => !existingToolResultIDs.has(toolUseID)) if (missingToolUseIDs.length === 0) { return } insertMissingToolResults(nextMessage, missingToolUseIDs) log("[tool-pair-validator] Repaired missing tool_result blocks", { assistantMessageID: getMessageID(assistantMessage.info), syntheticUserMessageInserted: false, repairedToolUseIDs: missingToolUseIDs, }) } export function createToolPairValidatorHook(): MessagesTransformHook { return { "experimental.chat.messages.transform": async (_input, output) => { for (let i = 0; i < output.messages.length; i++) { const messageInfo = output.messages[i].info if (messageInfo.role !== "assistant") { continue } const sessionID = getMessageSessionID(messageInfo) if (sessionID && subagentSessions.has(sessionID)) { log("[tool-pair-validator] Skipping repair for subagent session", { sessionID }) continue } repairMissingToolResults(output.messages, i) } }, } }