diff --git a/src/hooks/session-recovery/index.ts b/src/hooks/session-recovery/index.ts index 09ac738..c8d0d19 100644 --- a/src/hooks/session-recovery/index.ts +++ b/src/hooks/session-recovery/index.ts @@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { createOpencodeClient } from "@opencode-ai/sdk" import { findEmptyMessages, + findEmptyMessageByIndex, findMessagesWithOrphanThinking, findMessagesWithThinkingBlocks, injectTextPart, @@ -54,6 +55,12 @@ function getErrorMessage(error: unknown): string { 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 { const message = getErrorMessage(error) @@ -161,16 +168,26 @@ async function recoverEmptyContentMessage( _client: Client, sessionID: string, failedAssistantMsg: MessageData, - _directory: string + _directory: string, + error: unknown ): Promise { - const emptyMessageIDs = findEmptyMessages(sessionID) + const targetIndex = extractMessageIndex(error) + const failedID = failedAssistantMsg.info?.id - if (emptyMessageIDs.length === 0) { - const fallbackID = failedAssistantMsg.info?.id - if (!fallbackID) return false - return injectTextPart(sessionID, fallbackID, "(interrupted)") + if (targetIndex !== null) { + const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex) + if (targetMessageID) { + return injectTextPart(sessionID, targetMessageID, "(interrupted)") + } } + if (failedID) { + if (injectTextPart(sessionID, failedID, "(interrupted)")) { + return true + } + } + + const emptyMessageIDs = findEmptyMessages(sessionID) let anySuccess = false for (const messageID of emptyMessageIDs) { if (injectTextPart(sessionID, messageID, "(interrupted)")) { @@ -262,15 +279,16 @@ export function createSessionRecoveryHook(ctx: PluginInput) { } else if (errorType === "thinking_disabled_violation") { success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg) } 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 - } catch { - return false - } finally { - processingErrors.delete(assistantMsgID) - } + return success + } catch (err) { + console.error("[session-recovery] Recovery failed:", err) + return false + } finally { + processingErrors.delete(assistantMsgID) + } } return { diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts index b6e14f2..42ac2a3 100644 --- a/src/hooks/session-recovery/storage.ts +++ b/src/hooks/session-recovery/storage.ts @@ -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[] { @@ -117,13 +122,9 @@ export function findEmptyMessages(sessionID: string): string[] { const messages = readMessages(sessionID) const emptyIds: string[] = [] - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] + for (const msg of messages) { if (msg.role !== "assistant") continue - const isLastMessage = i === messages.length - 1 - if (isLastMessage) continue - if (!messageHasContent(msg.id)) { emptyIds.push(msg.id) } @@ -132,6 +133,18 @@ export function findEmptyMessages(sessionID: string): string[] { 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 { const emptyIds = findEmptyMessages(sessionID) return emptyIds.length > 0 ? emptyIds[0] : null