From 8f39575264ec8aa90fe4708082139bb7583860be Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 5 Dec 2025 15:28:22 +0900 Subject: [PATCH] fix(session-recovery): handle empty content from interrupted reasoning (#6) * fix(publish): make git operations idempotent - Check for staged changes before commit - Check if tag exists before creating - Check if release exists before creating * fix(session-recovery): handle empty content from interrupted reasoning - Add THINKING_TYPES set including 'reasoning' type (OpenCode's thinking) - Add hasNonEmptyOutput() to detect messages with only thinking/meta parts - Add findEmptyContentMessage() to scan all messages for empty content - Handle step-start/step-finish meta parts in empty content detection - Patch interrupted messages with '(interrupted)' text before falling back to revert --- src/hooks/session-recovery.ts | 86 ++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/src/hooks/session-recovery.ts b/src/hooks/session-recovery.ts index ccd7cc0..ddb19d6 100644 --- a/src/hooks/session-recovery.ts +++ b/src/hooks/session-recovery.ts @@ -211,29 +211,93 @@ async function recoverThinkingDisabledViolation( return false } +const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"]) + +function hasNonEmptyOutput(msg: MessageData): boolean { + const parts = msg.parts + if (!parts || parts.length === 0) return false + + return parts.some((p) => { + if (THINKING_TYPES.has(p.type)) return false + if (p.type === "step-start" || p.type === "step-finish") return false + if (p.type === "text" && p.text && p.text.trim()) return true + if (p.type === "tool_use" && p.id) return true + if (p.type === "tool_result") return true + return false + }) +} + +function findEmptyContentMessage(msgs: MessageData[]): MessageData | null { + for (let i = 0; i < msgs.length; i++) { + const msg = msgs[i] + const isLastMessage = i === msgs.length - 1 + const isAssistant = msg.info?.role === "assistant" + + if (isLastMessage && isAssistant) continue + + if (!hasNonEmptyOutput(msg)) { + return msg + } + } + return null +} + async function recoverEmptyContentMessage( client: Client, sessionID: string, failedAssistantMsg: MessageData, directory: string ): Promise { - const messageID = failedAssistantMsg.info?.id - const parentMsgID = failedAssistantMsg.info?.parentID - - if (!messageID) { - return false - } - - // Revert to parent message (delete the empty message) - const revertTargetID = parentMsgID || messageID - try { + const messagesResp = await client.session.messages({ + path: { id: sessionID }, + query: { directory }, + }) + const msgs = (messagesResp as { data?: MessageData[] }).data + + if (!msgs || msgs.length === 0) return false + + const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg + const messageID = emptyMsg.info?.id + if (!messageID) return false + + const existingParts = emptyMsg.parts || [] + const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every( + (p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish" + ) + + if (hasOnlyThinkingOrMeta) { + const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }] + + try { + // @ts-expect-error - Experimental API + await client.message?.update?.({ + path: { id: messageID }, + body: { parts: strippedParts }, + }) + return true + } catch { + // message.update not available + } + + try { + // @ts-expect-error - Experimental API + await client.session.patch?.({ + path: { id: sessionID }, + body: { messageID, parts: strippedParts }, + }) + return true + } catch { + // session.patch not available + } + } + + const revertTargetID = emptyMsg.info?.parentID || messageID await client.session.revert({ path: { id: sessionID }, body: { messageID: revertTargetID }, query: { directory }, }) - return true } catch { return false