refactor(session-recovery): extract storage utilities to separate module

Split session-recovery.ts into modular structure:
- types.ts: SDK-aligned type definitions
- constants.ts: storage paths and part type sets
- storage.ts: reusable read/write operations
- index.ts: main recovery hook logic
This commit is contained in:
YeonGyu-Kim
2025-12-08 11:48:08 +09:00
parent 0df7e9b10b
commit 64b3564760
4 changed files with 250 additions and 227 deletions

View File

@@ -0,0 +1,10 @@
import { join } from "node:path"
import { xdgData } from "xdg-basedir"
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
export const META_TYPES = new Set(["step-start", "step-finish"])
export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"])

View File

@@ -0,0 +1,345 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
import { findFirstEmptyMessage, injectTextPart } from "./storage"
import type { MessageData } from "./types"
type Client = ReturnType<typeof createOpencodeClient>
type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
| "empty_content_message"
| null
interface MessageInfo {
id?: string
role?: string
sessionID?: string
parentID?: string
error?: unknown
}
interface ToolUsePart {
type: "tool_use"
id: string
name: string
input: Record<string, unknown>
}
interface ThinkingPart {
type: "thinking"
thinking: string
}
interface MessagePart {
type: string
id?: string
text?: string
thinking?: string
name?: string
input?: Record<string, unknown>
}
function getErrorMessage(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error.toLowerCase()
const errorObj = error as { data?: { message?: string }; message?: string }
return (errorObj.data?.message || errorObj.message || "").toLowerCase()
}
function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (message.includes("tool_use") && message.includes("tool_result")) {
return "tool_result_missing"
}
if (
message.includes("thinking") &&
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
) {
return "thinking_block_order"
}
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
return "thinking_disabled_violation"
}
if (message.includes("non-empty content") || message.includes("must have non-empty content")) {
return "empty_content_message"
}
return null
}
function extractToolUseIds(parts: MessagePart[]): string[] {
return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id)
}
async function recoverToolResultMissing(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
const parts = failedAssistantMsg.parts || []
const toolUseIds = extractToolUseIds(parts)
if (toolUseIds.length === 0) {
return false
}
const toolResultParts = toolUseIds.map((id) => ({
type: "tool_result" as const,
tool_use_id: id,
content: "Operation cancelled by user (ESC pressed)",
}))
try {
await client.session.prompt({
path: { id: sessionID },
// @ts-expect-error - SDK types may not include tool_result parts
body: { parts: toolResultParts },
})
return true
} catch {
return false
}
}
async function recoverThinkingBlockOrder(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
directory: string
): Promise<boolean> {
const messageID = failedAssistantMsg.info?.id
if (!messageID) {
return false
}
const existingParts = failedAssistantMsg.parts || []
const patchedParts: MessagePart[] = [{ type: "thinking", thinking: "" } as ThinkingPart, ...existingParts]
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)
}
async function recoverThinkingDisabledViolation(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
const messageID = failedAssistantMsg.info?.id
if (!messageID) {
return false
}
const existingParts = failedAssistantMsg.parts || []
const strippedParts = existingParts.filter((p) => p.type !== "thinking" && p.type !== "redacted_thinking")
if (strippedParts.length === 0) {
return false
}
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
}
async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string
): Promise<boolean> {
const emptyMessageID = findFirstEmptyMessage(sessionID) || failedAssistantMsg.info?.id
if (!emptyMessageID) return false
return injectTextPart(sessionID, emptyMessageID, "(interrupted)")
}
async function fallbackRevertStrategy(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
directory: string
): Promise<boolean> {
const parentMsgID = failedAssistantMsg.info?.parentID
const messagesResp = await client.session.messages({
path: { id: sessionID },
query: { directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data
if (!msgs || msgs.length === 0) {
return false
}
let targetUserMsg: MessageData | null = null
if (parentMsgID) {
targetUserMsg = msgs.find((m) => m.info?.id === parentMsgID) ?? null
}
if (!targetUserMsg) {
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].info?.role === "user") {
targetUserMsg = msgs[i]
break
}
}
}
if (!targetUserMsg?.parts?.length) {
return false
}
await client.session.revert({
path: { id: sessionID },
body: { messageID: targetUserMsg.info?.id ?? "" },
query: { directory },
})
const textParts = targetUserMsg.parts
.filter((p) => p.type === "text" && p.text)
.map((p) => ({ type: "text" as const, text: p.text ?? "" }))
if (textParts.length === 0) {
return false
}
await client.session.prompt({
path: { id: sessionID },
body: { parts: textParts },
query: { directory },
})
return true
}
export function createSessionRecoveryHook(ctx: PluginInput) {
const processingErrors = new Set<string>()
let onAbortCallback: ((sessionID: string) => void) | null = null
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
onAbortCallback = callback
}
const isRecoverableError = (error: unknown): boolean => {
return detectErrorType(error) !== null
}
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
if (!info || info.role !== "assistant" || !info.error) return false
const errorType = detectErrorType(info.error)
if (!errorType) return false
const sessionID = info.sessionID
const assistantMsgID = info.id
if (!sessionID || !assistantMsgID) return false
if (processingErrors.has(assistantMsgID)) return false
processingErrors.add(assistantMsgID)
try {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
if (onAbortCallback) {
onAbortCallback(sessionID)
}
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const msgs = (messagesResp as { data?: MessageData[] }).data
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
if (!failedMsg) {
return false
}
const toastTitles: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Tool Crash Recovery",
thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery",
empty_content_message: "Empty Message Recovery",
}
const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...",
thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...",
empty_content_message: "Fixing empty message...",
}
await ctx.client.tui
.showToast({
body: {
title: toastTitles[errorType],
message: toastMessages[errorType],
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
let success = false
if (errorType === "tool_result_missing") {
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory)
} 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)
}
return success
} catch {
return false
} finally {
processingErrors.delete(assistantMsgID)
}
}
return {
handleSessionRecovery,
isRecoverableError,
setOnAbortCallback,
}
}

View File

@@ -0,0 +1,138 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, 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"
export function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
export function getMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) return ""
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) {
return directPath
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
return ""
}
export function readMessages(sessionID: string): StoredMessageMeta[] {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: StoredMessageMeta[] = []
for (const file of readdirSync(messageDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(messageDir, file), "utf-8")
messages.push(JSON.parse(content))
} catch {
continue
}
}
return messages.sort((a, b) => a.id.localeCompare(b.id))
}
export function readParts(messageID: string): StoredPart[] {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: StoredPart[] = []
for (const file of readdirSync(partDir)) {
if (!file.endsWith(".json")) continue
try {
const content = readFileSync(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
return parts
}
export function hasContent(part: StoredPart): boolean {
if (THINKING_TYPES.has(part.type)) return false
if (META_TYPES.has(part.type)) return false
if (part.type === "text") {
const textPart = part as StoredTextPart
return !!(textPart.text?.trim())
}
if (part.type === "tool" || part.type === "tool_use") {
return true
}
if (part.type === "tool_result") {
return true
}
return false
}
export function messageHasContent(messageID: string): boolean {
const parts = readParts(messageID)
return parts.some(hasContent)
}
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const partId = generatePartId()
const part: StoredTextPart = {
id: partId,
sessionID,
messageID,
type: "text",
text,
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
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]
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
}
return emptyIds
}
export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null
}

View File

@@ -0,0 +1,82 @@
export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning"
export type MetaPartType = "step-start" | "step-finish"
export type ContentPartType = "text" | "tool" | "tool_use" | "tool_result"
export interface StoredMessageMeta {
id: string
sessionID: string
role: "user" | "assistant"
parentID?: string
time?: {
created: number
completed?: number
}
error?: unknown
}
export interface StoredTextPart {
id: string
sessionID: string
messageID: string
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
}
export interface StoredToolPart {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: {
status: "pending" | "running" | "completed" | "error"
input: Record<string, unknown>
output?: string
error?: string
}
}
export interface StoredReasoningPart {
id: string
sessionID: string
messageID: string
type: "reasoning"
text: string
}
export interface StoredStepPart {
id: string
sessionID: string
messageID: string
type: "step-start" | "step-finish"
}
export type StoredPart = StoredTextPart | StoredToolPart | StoredReasoningPart | StoredStepPart | {
id: string
sessionID: string
messageID: string
type: string
[key: string]: unknown
}
export interface MessageData {
info?: {
id?: string
role?: string
sessionID?: string
parentID?: string
error?: unknown
}
parts?: Array<{
type: string
id?: string
text?: string
thinking?: string
name?: string
input?: Record<string, unknown>
callID?: string
}>
}