refactor(session-recovery): process entire message history for empty/thinking block recovery

- Scan all non-final assistant messages for empty content, orphan thinking blocks, and disabled thinking
- Add storage utility functions: findMessagesWithThinkingBlocks, findMessagesWithOrphanThinking, stripThinkingParts, prependThinkingPart
- Fix: Previously only processed single failed message, now handles multiple broken messages in history
- Improve: Use filesystem-based recovery instead of unreliable SDK APIs
This commit is contained in:
YeonGyu-Kim
2025-12-08 15:00:09 +09:00
parent c5f651c0a9
commit 16806da615
2 changed files with 145 additions and 59 deletions

View File

@@ -1,6 +1,13 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
import { findFirstEmptyMessage, injectTextPart } from "./storage"
import {
findEmptyMessages,
findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks,
injectTextPart,
prependThinkingPart,
stripThinkingParts,
} from "./storage"
import type { MessageData } from "./types"
type Client = ReturnType<typeof createOpencodeClient>
@@ -109,76 +116,46 @@ async function recoverToolResultMissing(
}
async function recoverThinkingBlockOrder(
client: Client,
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
directory: string
_failedAssistantMsg: MessageData,
_directory: string
): Promise<boolean> {
const messageID = failedAssistantMsg.info?.id
if (!messageID) {
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
if (orphanMessages.length === 0) {
return false
}
const existingParts = failedAssistantMsg.parts || []
const patchedParts: MessagePart[] = [{ type: "thinking", thinking: "" } as ThinkingPart, ...existingParts]
let anySuccess = false
for (const messageID of orphanMessages) {
if (prependThinkingPart(sessionID, messageID)) {
anySuccess = true
}
}
try {
// @ts-expect-error - Experimental API
await client.message?.update?.({
path: { id: messageID },
body: { parts: patchedParts },
})
return true
} catch {}
try {
// @ts-expect-error - Experimental API
await client.session.patch?.({
path: { id: sessionID },
body: { messageID, parts: patchedParts },
})
return true
} catch {}
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
return anySuccess
}
async function recoverThinkingDisabledViolation(
client: Client,
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData
_failedAssistantMsg: MessageData
): Promise<boolean> {
const messageID = failedAssistantMsg.info?.id
if (!messageID) {
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
if (messagesWithThinking.length === 0) {
return false
}
const existingParts = failedAssistantMsg.parts || []
const strippedParts = existingParts.filter((p) => p.type !== "thinking" && p.type !== "redacted_thinking")
if (strippedParts.length === 0) {
return false
let anySuccess = false
for (const messageID of messagesWithThinking) {
if (stripThinkingParts(messageID)) {
anySuccess = true
}
}
try {
// @ts-expect-error - Experimental API
await client.message?.update?.({
path: { id: messageID },
body: { parts: strippedParts },
})
return true
} catch {}
try {
// @ts-expect-error - Experimental API
await client.session.patch?.({
path: { id: sessionID },
body: { messageID, parts: strippedParts },
})
return true
} catch {}
return false
return anySuccess
}
async function recoverEmptyContentMessage(
@@ -187,10 +164,22 @@ async function recoverEmptyContentMessage(
failedAssistantMsg: MessageData,
_directory: string
): Promise<boolean> {
const emptyMessageID = findFirstEmptyMessage(sessionID) || failedAssistantMsg.info?.id
if (!emptyMessageID) return false
const emptyMessageIDs = findEmptyMessages(sessionID)
return injectTextPart(sessionID, emptyMessageID, "(interrupted)")
if (emptyMessageIDs.length === 0) {
const fallbackID = failedAssistantMsg.info?.id
if (!fallbackID) return false
return injectTextPart(sessionID, fallbackID, "(interrupted)")
}
let anySuccess = false
for (const messageID of emptyMessageIDs) {
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
anySuccess = true
}
}
return anySuccess
}
async function fallbackRevertStrategy(

View File

@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
@@ -136,3 +136,100 @@ export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null
}
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
const parts = readParts(msg.id)
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
if (hasThinking) {
result.push(msg.id)
}
}
return result
}
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
const messages = readMessages(sessionID)
const result: string[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
const parts = readParts(msg.id)
if (parts.length === 0) continue
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
if (hasThinking && !firstIsThinking) {
result.push(msg.id)
}
}
return result
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const partId = `prt_0000000000_thinking`
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: "",
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
export function stripThinkingParts(messageID: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return false
let anyRemoved = 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 (THINKING_TYPES.has(part.type)) {
unlinkSync(filePath)
anyRemoved = true
}
} catch {
continue
}
}
return anyRemoved
}