fix(hooks): improve session recovery for empty content messages

- Extract message index from Anthropic error messages (messages.N format)
- Sort messages by time.created instead of id for accurate ordering
- Remove last message skip logic that prevented recovery
- Prioritize recovery targets: index-matched > failedMsg > all empty
- Add error logging for debugging recovery failures

Fixes issue where 'messages.83: all messages must have non-empty content' errors were not being recovered properly due to incorrect message ordering and overly restrictive filtering.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-10 11:07:17 +09:00
parent 7b19177c8a
commit 4f019f8fe5
2 changed files with 50 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk" import type { createOpencodeClient } from "@opencode-ai/sdk"
import { import {
findEmptyMessages, findEmptyMessages,
findEmptyMessageByIndex,
findMessagesWithOrphanThinking, findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks, findMessagesWithThinkingBlocks,
injectTextPart, injectTextPart,
@@ -54,6 +55,12 @@ function getErrorMessage(error: unknown): string {
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase() return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
} }
function extractMessageIndex(error: unknown): number | null {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
}
function detectErrorType(error: unknown): RecoveryErrorType { function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error) const message = getErrorMessage(error)
@@ -161,16 +168,26 @@ async function recoverEmptyContentMessage(
_client: Client, _client: Client,
sessionID: string, sessionID: string,
failedAssistantMsg: MessageData, failedAssistantMsg: MessageData,
_directory: string _directory: string,
error: unknown
): Promise<boolean> { ): Promise<boolean> {
const emptyMessageIDs = findEmptyMessages(sessionID) const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
if (emptyMessageIDs.length === 0) { if (targetIndex !== null) {
const fallbackID = failedAssistantMsg.info?.id const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (!fallbackID) return false if (targetMessageID) {
return injectTextPart(sessionID, fallbackID, "(interrupted)") return injectTextPart(sessionID, targetMessageID, "(interrupted)")
}
} }
if (failedID) {
if (injectTextPart(sessionID, failedID, "(interrupted)")) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
let anySuccess = false let anySuccess = false
for (const messageID of emptyMessageIDs) { for (const messageID of emptyMessageIDs) {
if (injectTextPart(sessionID, messageID, "(interrupted)")) { if (injectTextPart(sessionID, messageID, "(interrupted)")) {
@@ -262,15 +279,16 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
} else if (errorType === "thinking_disabled_violation") { } else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
} else if (errorType === "empty_content_message") { } else if (errorType === "empty_content_message") {
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory) success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
} }
return success return success
} catch { } catch (err) {
return false console.error("[session-recovery] Recovery failed:", err)
} finally { return false
processingErrors.delete(assistantMsgID) } finally {
} processingErrors.delete(assistantMsgID)
}
} }
return { return {

View File

@@ -42,7 +42,12 @@ export function readMessages(sessionID: string): StoredMessageMeta[] {
} }
} }
return messages.sort((a, b) => a.id.localeCompare(b.id)) return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
} }
export function readParts(messageID: string): StoredPart[] { export function readParts(messageID: string): StoredPart[] {
@@ -117,13 +122,9 @@ export function findEmptyMessages(sessionID: string): string[] {
const messages = readMessages(sessionID) const messages = readMessages(sessionID)
const emptyIds: string[] = [] const emptyIds: string[] = []
for (let i = 0; i < messages.length; i++) { for (const msg of messages) {
const msg = messages[i]
if (msg.role !== "assistant") continue if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
if (!messageHasContent(msg.id)) { if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id) emptyIds.push(msg.id)
} }
@@ -132,6 +133,18 @@ export function findEmptyMessages(sessionID: string): string[] {
return emptyIds return emptyIds
} }
export function findEmptyMessageByIndex(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
if (messageHasContent(targetMsg.id)) return null
return targetMsg.id
}
export function findFirstEmptyMessage(sessionID: string): string | null { export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID) const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null return emptyIds.length > 0 ? emptyIds[0] : null