From 838f49bc42fd0378c7dcb03181accf8fecb7e3c7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 16 Dec 2025 21:02:38 +0900 Subject: [PATCH] fix(session-recovery): Replace empty text parts before injecting new ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directly modify empty text parts in storage files before attempting to inject new parts. This ensures that existing empty text parts are replaced with placeholder text, fixing the issue where Anthropic API returns 'messages.X: all messages must have non-empty content' error even after recovery. - Added replaceEmptyTextParts function to directly replace empty text parts - Added findMessagesWithEmptyTextParts function to identify affected messages - Modified recoverEmptyContentMessage to prioritize replacing existing empty parts 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/session-recovery/index.ts | 28 +++++++++++++-- src/hooks/session-recovery/storage.ts | 49 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/hooks/session-recovery/index.ts b/src/hooks/session-recovery/index.ts index 70cd0b1..6027907 100644 --- a/src/hooks/session-recovery/index.ts +++ b/src/hooks/session-recovery/index.ts @@ -4,12 +4,14 @@ import { findEmptyMessages, findEmptyMessageByIndex, findMessageByIndexNeedingThinking, + findMessagesWithEmptyTextParts, findMessagesWithOrphanThinking, findMessagesWithThinkingBlocks, findMessagesWithThinkingOnly, injectTextPart, prependThinkingPart, readParts, + replaceEmptyTextParts, stripThinkingParts, } from "./storage" import type { MessageData } from "./types" @@ -222,28 +224,48 @@ async function recoverEmptyContentMessage( ): Promise { const targetIndex = extractMessageIndex(error) const failedID = failedAssistantMsg.info?.id + let anySuccess = false + + const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID) + for (const messageID of messagesWithEmptyText) { + if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } + } const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID) for (const messageID of thinkingOnlyIDs) { - injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT) + if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } } if (targetIndex !== null) { const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) if (targetMessageID) { - return injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT) + if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) { + return true + } + if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) { + return true + } } } if (failedID) { + if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) { + return true + } if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) { return true } } const emptyMessageIDs = findEmptyMessages(sessionID) - let anySuccess = thinkingOnlyIDs.length > 0 for (const messageID of emptyMessageIDs) { + if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) { + anySuccess = true + } 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 3f50e94..c67f1fd 100644 --- a/src/hooks/session-recovery/storage.ts +++ b/src/hooks/session-recovery/storage.ts @@ -271,6 +271,55 @@ export function stripThinkingParts(messageID: string): boolean { return anyRemoved } +export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean { + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) return false + + let anyReplaced = false + for (const file of readdirSync(partDir)) { + if (!file.endsWith(".json")) continue + try { + const filePath = join(partDir, file) + const content = readFileSync(filePath, "utf-8") + const part = JSON.parse(content) as StoredPart + + if (part.type === "text") { + const textPart = part as StoredTextPart + if (!textPart.text?.trim()) { + textPart.text = replacementText + textPart.synthetic = true + writeFileSync(filePath, JSON.stringify(textPart, null, 2)) + anyReplaced = true + } + } + } catch { + continue + } + } + + return anyReplaced +} + +export function findMessagesWithEmptyTextParts(sessionID: string): string[] { + const messages = readMessages(sessionID) + const result: string[] = [] + + for (const msg of messages) { + const parts = readParts(msg.id) + const hasEmptyTextPart = parts.some((p) => { + if (p.type !== "text") return false + const textPart = p as StoredTextPart + return !textPart.text?.trim() + }) + + if (hasEmptyTextPart) { + result.push(msg.id) + } + } + + return result +} + export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null { const messages = readMessages(sessionID)