From c12f73f774e1be0418e6a91de502244ea14e614c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 11 Dec 2025 11:07:07 +0900 Subject: [PATCH] fix(hooks): improve thinking block order recovery with error-based index targeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add findMessageByIndexNeedingThinking for precise message targeting - Detect "expected X found Y" error pattern for thinking block order - Remove isLastMessage skip - recovery now handles final assistant messages - Simplify orphan detection: any non-thinking first part is orphan 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/session-recovery/index.ts | 19 +++++++++++--- src/hooks/session-recovery/storage.ts | 37 ++++++++++++++++++++------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/hooks/session-recovery/index.ts b/src/hooks/session-recovery/index.ts index c8d0d19..38f2779 100644 --- a/src/hooks/session-recovery/index.ts +++ b/src/hooks/session-recovery/index.ts @@ -3,6 +3,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk" import { findEmptyMessages, findEmptyMessageByIndex, + findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, findMessagesWithThinkingBlocks, injectTextPart, @@ -70,7 +71,10 @@ function detectErrorType(error: unknown): RecoveryErrorType { if ( message.includes("thinking") && - (message.includes("first block") || message.includes("must start with") || message.includes("preceeding")) + (message.includes("first block") || + message.includes("must start with") || + message.includes("preceeding") || + (message.includes("expected") && message.includes("found"))) ) { return "thinking_block_order" } @@ -125,8 +129,17 @@ async function recoverThinkingBlockOrder( _client: Client, sessionID: string, _failedAssistantMsg: MessageData, - _directory: string + _directory: string, + error: unknown ): Promise { + const targetIndex = extractMessageIndex(error) + if (targetIndex !== null) { + const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex) + if (targetMessageID) { + return prependThinkingPart(sessionID, targetMessageID) + } + } + const orphanMessages = findMessagesWithOrphanThinking(sessionID) if (orphanMessages.length === 0) { @@ -275,7 +288,7 @@ export function createSessionRecoveryHook(ctx: PluginInput) { if (errorType === "tool_result_missing") { success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) } else if (errorType === "thinking_block_order") { - success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory) + success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) } else if (errorType === "thinking_disabled_violation") { success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) } else if (errorType === "empty_content_message") { diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts index 42ac2a3..2598129 100644 --- a/src/hooks/session-recovery/storage.ts +++ b/src/hooks/session-recovery/storage.ts @@ -154,13 +154,9 @@ export function findMessagesWithThinkingBlocks(sessionID: string): string[] { const messages = readMessages(sessionID) const result: string[] = [] - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] + for (const msg of messages) { if (msg.role !== "assistant") continue - const isLastMessage = i === messages.length - 1 - if (isLastMessage) continue - const parts = readParts(msg.id) const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)) if (hasThinking) { @@ -179,8 +175,8 @@ export function findMessagesWithOrphanThinking(sessionID: string): string[] { const msg = messages[i] if (msg.role !== "assistant") continue - const isLastMessage = i === messages.length - 1 - if (isLastMessage) continue + // NOTE: Removed isLastMessage skip - recovery needs to fix last message too + // when "thinking must start with" errors occur on final assistant message const parts = readParts(msg.id) if (parts.length === 0) continue @@ -188,10 +184,11 @@ export function findMessagesWithOrphanThinking(sessionID: string): string[] { const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) const firstPart = sortedParts[0] - const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)) const firstIsThinking = THINKING_TYPES.has(firstPart.type) - if (hasThinking && !firstIsThinking) { + // NOTE: Changed condition - if first part is not thinking, it's orphan + // regardless of whether thinking blocks exist elsewhere in the message + if (!firstIsThinking) { result.push(msg.id) } } @@ -246,3 +243,25 @@ export function stripThinkingParts(messageID: string): boolean { return anyRemoved } + +export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null { + const messages = readMessages(sessionID) + + if (targetIndex < 0 || targetIndex >= messages.length) return null + + const targetMsg = messages[targetIndex] + if (targetMsg.role !== "assistant") return null + + const parts = readParts(targetMsg.id) + if (parts.length === 0) return null + + const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id)) + const firstPart = sortedParts[0] + const firstIsThinking = THINKING_TYPES.has(firstPart.type) + + if (!firstIsThinking) { + return targetMsg.id + } + + return null +}