diff --git a/src/hooks/session-recovery/constants.ts b/src/hooks/session-recovery/constants.ts new file mode 100644 index 0000000..02c2d80 --- /dev/null +++ b/src/hooks/session-recovery/constants.ts @@ -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"]) diff --git a/src/hooks/session-recovery.ts b/src/hooks/session-recovery/index.ts similarity index 58% rename from src/hooks/session-recovery.ts rename to src/hooks/session-recovery/index.ts index dbb19f9..7979988 100644 --- a/src/hooks/session-recovery.ts +++ b/src/hooks/session-recovery/index.ts @@ -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 { createOpencodeClient } from "@opencode-ai/sdk" +import { findFirstEmptyMessage, injectTextPart } from "./storage" +import type { MessageData } from "./types" type Client = ReturnType -const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage") -const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") -const PART_STORAGE = join(OPENCODE_STORAGE, "part") - -type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null +type RecoveryErrorType = + | "tool_result_missing" + | "thinking_block_order" + | "thinking_disabled_violation" + | "empty_content_message" + | null interface MessageInfo { id?: string @@ -58,11 +41,6 @@ interface MessagePart { input?: Record } -interface MessageData { - info?: MessageInfo - parts?: MessagePart[] -} - function getErrorMessage(error: unknown): string { if (!error) return "" if (typeof error === "string") return error.toLowerCase() @@ -120,7 +98,7 @@ async function recoverToolResultMissing( try { await client.session.prompt({ 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 }, }) @@ -150,26 +128,17 @@ async function recoverThinkingBlockOrder( path: { id: messageID }, body: { parts: patchedParts }, }) - return true - } catch { - // message.update not available - } + } catch {} try { // @ts-expect-error - Experimental API await client.session.patch?.({ path: { id: sessionID }, - body: { - messageID, - parts: patchedParts, - }, + body: { messageID, parts: patchedParts }, }) - return true - } catch { - // session.patch not available - } + } catch {} return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory) } @@ -197,205 +166,31 @@ async function recoverThinkingDisabledViolation( path: { id: messageID }, body: { parts: strippedParts }, }) - return true - } catch { - // message.update not available - } + } catch {} try { // @ts-expect-error - Experimental API await client.session.patch?.({ path: { id: sessionID }, - body: { - messageID, - parts: strippedParts, - }, + body: { messageID, parts: strippedParts }, }) - return true - } catch { - // session.patch not available - } + } catch {} 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( _client: Client, sessionID: string, failedAssistantMsg: MessageData, _directory: string ): Promise { - const emptyMessageID = findEmptyContentMessageFromStorage(sessionID) || failedAssistantMsg.info?.id + const emptyMessageID = findFirstEmptyMessage(sessionID) || failedAssistantMsg.info?.id if (!emptyMessageID) return false - return injectTextPartToStorage(sessionID, emptyMessageID, "(interrupted)") + return injectTextPart(sessionID, emptyMessageID, "(interrupted)") } async function fallbackRevertStrategy( @@ -508,16 +303,14 @@ export function createSessionRecoveryHook(ctx: PluginInput) { tool_result_missing: "Injecting cancelled tool results...", thinking_block_order: "Fixing message structure...", 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 .showToast({ body: { - title: toastTitle, - message: toastMessage, + title: toastTitles[errorType], + message: toastMessages[errorType], variant: "warning", duration: 3000, }, diff --git a/src/hooks/session-recovery/storage.ts b/src/hooks/session-recovery/storage.ts new file mode 100644 index 0000000..75b42ee --- /dev/null +++ b/src/hooks/session-recovery/storage.ts @@ -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 +} diff --git a/src/hooks/session-recovery/types.ts b/src/hooks/session-recovery/types.ts new file mode 100644 index 0000000..09b6985 --- /dev/null +++ b/src/hooks/session-recovery/types.ts @@ -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 + 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 + callID?: string + }> +}