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

@@ -1,33 +1,16 @@
/**
* Session Recovery - Message State Error Recovery
*
* Handles FOUR specific scenarios:
* 1. tool_use block exists without tool_result
* - Recovery: inject tool_result with "cancelled" content
*
* 2. Thinking block order violation (first block must be thinking)
* - Recovery: prepend empty thinking block
*
* 3. Thinking disabled but message contains thinking blocks
* - Recovery: strip thinking/redacted_thinking blocks
*
* 4. Empty content message (non-empty content required)
* - Recovery: inject text part directly via filesystem
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { xdgData } from "xdg-basedir"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk" import type { createOpencodeClient } from "@opencode-ai/sdk"
import { findFirstEmptyMessage, injectTextPart } from "./storage"
import type { MessageData } from "./types"
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage") type RecoveryErrorType =
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") | "tool_result_missing"
const PART_STORAGE = join(OPENCODE_STORAGE, "part") | "thinking_block_order"
| "thinking_disabled_violation"
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null | "empty_content_message"
| null
interface MessageInfo { interface MessageInfo {
id?: string id?: string
@@ -58,11 +41,6 @@ interface MessagePart {
input?: Record<string, unknown> input?: Record<string, unknown>
} }
interface MessageData {
info?: MessageInfo
parts?: MessagePart[]
}
function getErrorMessage(error: unknown): string { function getErrorMessage(error: unknown): string {
if (!error) return "" if (!error) return ""
if (typeof error === "string") return error.toLowerCase() if (typeof error === "string") return error.toLowerCase()
@@ -120,7 +98,7 @@ async function recoverToolResultMissing(
try { try {
await client.session.prompt({ await client.session.prompt({
path: { id: sessionID }, path: { id: sessionID },
// @ts-expect-error - SDK types may not include tool_result parts, but runtime accepts it // @ts-expect-error - SDK types may not include tool_result parts
body: { parts: toolResultParts }, body: { parts: toolResultParts },
}) })
@@ -150,26 +128,17 @@ async function recoverThinkingBlockOrder(
path: { id: messageID }, path: { id: messageID },
body: { parts: patchedParts }, body: { parts: patchedParts },
}) })
return true return true
} catch { } catch {}
// message.update not available
}
try { try {
// @ts-expect-error - Experimental API // @ts-expect-error - Experimental API
await client.session.patch?.({ await client.session.patch?.({
path: { id: sessionID }, path: { id: sessionID },
body: { body: { messageID, parts: patchedParts },
messageID,
parts: patchedParts,
},
}) })
return true return true
} catch { } catch {}
// session.patch not available
}
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory) return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
} }
@@ -197,205 +166,31 @@ async function recoverThinkingDisabledViolation(
path: { id: messageID }, path: { id: messageID },
body: { parts: strippedParts }, body: { parts: strippedParts },
}) })
return true return true
} catch { } catch {}
// message.update not available
}
try { try {
// @ts-expect-error - Experimental API // @ts-expect-error - Experimental API
await client.session.patch?.({ await client.session.patch?.({
path: { id: sessionID }, path: { id: sessionID },
body: { body: { messageID, parts: strippedParts },
messageID,
parts: strippedParts,
},
}) })
return true return true
} catch { } catch {}
// session.patch not available
}
return false return false
} }
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
const META_TYPES = new Set(["step-start", "step-finish"])
interface StoredMessageMeta {
id: string
sessionID: string
role: string
parentID?: string
}
interface StoredPart {
id: string
sessionID: string
messageID: string
type: string
text?: string
}
function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
function getMessageDir(sessionID: string): string {
const projectHash = readdirSync(MESSAGE_STORAGE).find((dir) => {
const sessionDir = join(MESSAGE_STORAGE, dir)
try {
return readdirSync(sessionDir).some((f) => f.includes(sessionID.replace("ses_", "")))
} catch {
return false
}
})
if (projectHash) {
return join(MESSAGE_STORAGE, projectHash, sessionID)
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
return ""
}
function readMessagesFromStorage(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))
}
function readPartsFromStorage(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
}
function injectTextPartToStorage(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: StoredPart = {
id: partId,
sessionID,
messageID,
type: "text",
text,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
return true
} catch {
return false
}
}
function findEmptyContentMessageFromStorage(sessionID: string): string | null {
const messages = readMessagesFromStorage(sessionID)
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 = readPartsFromStorage(msg.id)
const hasContent = parts.some((p) => {
if (THINKING_TYPES.has(p.type)) return false
if (META_TYPES.has(p.type)) return false
if (p.type === "text" && p.text?.trim()) return true
if (p.type === "tool_use" || p.type === "tool") return true
if (p.type === "tool_result") return true
return false
})
if (!hasContent) {
return msg.id
}
}
return null
}
function hasNonEmptyOutput(msg: MessageData): boolean {
const parts = msg.parts
if (!parts || parts.length === 0) return false
return parts.some((p) => {
if (THINKING_TYPES.has(p.type)) return false
if (p.type === "step-start" || p.type === "step-finish") return false
if (p.type === "text" && p.text && p.text.trim()) return true
if ((p.type === "tool_use" || p.type === "tool") && p.id) return true
if (p.type === "tool_result") return true
return false
})
}
function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
const isLastMessage = i === msgs.length - 1
const isAssistant = msg.info?.role === "assistant"
if (isLastMessage && isAssistant) continue
if (!hasNonEmptyOutput(msg)) {
return msg
}
}
return null
}
async function recoverEmptyContentMessage( async function recoverEmptyContentMessage(
_client: Client, _client: Client,
sessionID: string, sessionID: string,
failedAssistantMsg: MessageData, failedAssistantMsg: MessageData,
_directory: string _directory: string
): Promise<boolean> { ): Promise<boolean> {
const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id const emptyMessageID = findFirstEmptyMessage(sessionID) || failedAssistantMsg.info?.id
if (!emptyMessageID) return false if (!emptyMessageID) return false
return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)") return injectTextPart(sessionID, emptyMessageID, "(interrupted)")
} }
async function fallbackRevertStrategy( async function fallbackRevertStrategy(
@@ -508,16 +303,14 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
tool_result_missing: "Injecting cancelled tool results...", tool_result_missing: "Injecting cancelled tool results...",
thinking_block_order: "Fixing message structure...", thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...", thinking_disabled_violation: "Stripping thinking blocks...",
empty_content_message: "Deleting empty message...", empty_content_message: "Fixing empty message...",
} }
const toastTitle = toastTitles[errorType]
const toastMessage = toastMessages[errorType]
await ctx.client.tui await ctx.client.tui
.showToast({ .showToast({
body: { body: {
title: toastTitle, title: toastTitles[errorType],
message: toastMessage, message: toastMessages[errorType],
variant: "warning", variant: "warning",
duration: 3000, duration: 3000,
}, },

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
}>
}