fix(session-recovery): Replace empty text parts before injecting new ones

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)
This commit is contained in:
YeonGyu-Kim
2025-12-16 21:02:38 +09:00
parent cb360e0d05
commit 838f49bc42
2 changed files with 74 additions and 3 deletions

View File

@@ -4,12 +4,14 @@ import {
findEmptyMessages, findEmptyMessages,
findEmptyMessageByIndex, findEmptyMessageByIndex,
findMessageByIndexNeedingThinking, findMessageByIndexNeedingThinking,
findMessagesWithEmptyTextParts,
findMessagesWithOrphanThinking, findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks, findMessagesWithThinkingBlocks,
findMessagesWithThinkingOnly, findMessagesWithThinkingOnly,
injectTextPart, injectTextPart,
prependThinkingPart, prependThinkingPart,
readParts, readParts,
replaceEmptyTextParts,
stripThinkingParts, stripThinkingParts,
} from "./storage" } from "./storage"
import type { MessageData } from "./types" import type { MessageData } from "./types"
@@ -222,28 +224,48 @@ async function recoverEmptyContentMessage(
): Promise<boolean> { ): Promise<boolean> {
const targetIndex = extractMessageIndex(error) const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id 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) const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
for (const messageID of thinkingOnlyIDs) { for (const messageID of thinkingOnlyIDs) {
injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT) if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
} }
if (targetIndex !== null) { if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) { 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 (failedID) {
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
return true
}
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) { if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
return true return true
} }
} }
const emptyMessageIDs = findEmptyMessages(sessionID) const emptyMessageIDs = findEmptyMessages(sessionID)
let anySuccess = thinkingOnlyIDs.length > 0
for (const messageID of emptyMessageIDs) { for (const messageID of emptyMessageIDs) {
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
anySuccess = true
}
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) { if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
anySuccess = true anySuccess = true
} }

View File

@@ -271,6 +271,55 @@ export function stripThinkingParts(messageID: string): boolean {
return anyRemoved 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 { export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID) const messages = readMessages(sessionID)