From 1aaa6e6ba2e3c04c781779e7223ee1a2316bb58c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 14 Dec 2025 22:26:58 +0900 Subject: [PATCH] fix(session-recovery): Add placeholder message for thinking-only messages - Add findMessagesWithThinkingOnly() to detect orphan thinking messages - Inject [user interrupted] placeholder for thinking-only messages - Expand index offset handling from 2 to 3 attempts for better error recovery - Use constant PLACEHOLDER_TEXT for consistency across recovery functions --- src/hooks/session-recovery/index.ts | 16 +++++++++--- src/hooks/session-recovery/storage.ts | 37 +++++++++++++++++++-------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/hooks/session-recovery/index.ts b/src/hooks/session-recovery/index.ts index 3e426bd..575b20a 100644 --- a/src/hooks/session-recovery/index.ts +++ b/src/hooks/session-recovery/index.ts @@ -6,6 +6,7 @@ import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, findMessagesWithThinkingBlocks, + findMessagesWithThinkingOnly, injectTextPart, prependThinkingPart, stripThinkingParts, @@ -177,6 +178,8 @@ async function recoverThinkingDisabledViolation( return anySuccess } +const PLACEHOLDER_TEXT = "[user interrupted]" + async function recoverEmptyContentMessage( _client: Client, sessionID: string, @@ -187,23 +190,28 @@ async function recoverEmptyContentMessage( const targetIndex = extractMessageIndex(error) const failedID = failedAssistantMsg.info?.id + const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID) + for (const messageID of thinkingOnlyIDs) { + injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT) + } + if (targetIndex !== null) { const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) if (targetMessageID) { - return injectTextPart(sessionID, targetMessageID, "(interrupted)") + return injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT) } } if (failedID) { - if (injectTextPart(sessionID, failedID, "(interrupted)")) { + if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) { return true } } const emptyMessageIDs = findEmptyMessages(sessionID) - let anySuccess = false + let anySuccess = thinkingOnlyIDs.length > 0 for (const messageID of emptyMessageIDs) { - if (injectTextPart(sessionID, messageID, "(interrupted)")) { + if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { anySuccess = true } } diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts index 4f978f8..3f50e94 100644 --- a/src/hooks/session-recovery/storage.ts +++ b/src/hooks/session-recovery/storage.ts @@ -133,20 +133,15 @@ export function findEmptyMessages(sessionID: string): string[] { export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null { const messages = readMessages(sessionID) - - // Try multiple indices to handle system message offset - // API includes system message at index 0, storage may not - const indicesToTry = [targetIndex, targetIndex - 1] - + + // API index may differ from storage index due to system messages + const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2] + for (const idx of indicesToTry) { if (idx < 0 || idx >= messages.length) continue const targetMsg = messages[idx] - - // NOTE: Do NOT skip last assistant message here - // If API returned an error, this message is NOT the final assistant message - // (the API only allows empty content for the ACTUAL final assistant message) - + if (!messageHasContent(targetMsg.id)) { return targetMsg.id } @@ -177,6 +172,28 @@ export function findMessagesWithThinkingBlocks(sessionID: string): string[] { return result } +export function findMessagesWithThinkingOnly(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + if (msg.role !== "assistant") continue + + const parts = readParts(msg.id) + if (parts.length === 0) continue + + const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type)) + const hasTextContent = parts.some(hasContent) + + // Has thinking but no text content = orphan thinking + if (hasThinking && !hasTextContent) { + result.push(msg.id) + } + } + + return result +} + export function findMessagesWithOrphanThinking(sessionID: string): string[] { const messages = readMessages(sessionID) const result: string[] = []