2025-12-05 02:12:52 +09:00
import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
2025-12-19 02:45:59 +09:00
import type { ExperimentalConfig } from "../../config"
2025-12-08 15:00:09 +09:00
import {
findEmptyMessages ,
2025-12-10 11:07:17 +09:00
findEmptyMessageByIndex ,
2025-12-11 11:07:07 +09:00
findMessageByIndexNeedingThinking ,
2025-12-16 21:02:38 +09:00
findMessagesWithEmptyTextParts ,
2025-12-08 15:00:09 +09:00
findMessagesWithOrphanThinking ,
findMessagesWithThinkingBlocks ,
2025-12-14 22:26:58 +09:00
findMessagesWithThinkingOnly ,
2025-12-08 15:00:09 +09:00
injectTextPart ,
prependThinkingPart ,
2025-12-15 19:02:31 +09:00
readParts ,
2025-12-16 21:02:38 +09:00
replaceEmptyTextParts ,
2025-12-08 15:00:09 +09:00
stripThinkingParts ,
} from "./storage"
2025-12-19 02:45:59 +09:00
import type { MessageData , ResumeConfig } from "./types"
export interface SessionRecoveryOptions {
experimental? : ExperimentalConfig
}
2025-12-05 02:12:52 +09:00
type Client = ReturnType < typeof createOpencodeClient >
2025-12-08 11:48:08 +09:00
type RecoveryErrorType =
| "tool_result_missing"
| "thinking_block_order"
| "thinking_disabled_violation"
2025-12-26 03:36:27 +09:00
| "tool_not_found"
2025-12-08 11:48:08 +09:00
| null
2025-12-05 02:12:52 +09:00
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 MessagePart {
type : string
id? : string
text? : string
thinking? : string
name? : string
input? : Record < string , unknown >
}
2025-12-19 02:45:59 +09:00
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
function findLastUserMessage ( messages : MessageData [ ] ) : MessageData | undefined {
for ( let i = messages . length - 1 ; i >= 0 ; i -- ) {
if ( messages [ i ] . info ? . role === "user" ) {
return messages [ i ]
}
}
return undefined
}
function extractResumeConfig ( userMessage : MessageData | undefined , sessionID : string ) : ResumeConfig {
return {
sessionID ,
agent : userMessage?.info?.agent ,
model : userMessage?.info?.model ,
}
}
async function resumeSession ( client : Client , config : ResumeConfig ) : Promise < boolean > {
try {
await client . session . prompt ( {
path : { id : config.sessionID } ,
body : {
parts : [ { type : "text" , text : RECOVERY_RESUME_TEXT } ] ,
agent : config.agent ,
model : config.model ,
} ,
} )
return true
} catch {
return false
}
}
2025-12-05 02:12:52 +09:00
function getErrorMessage ( error : unknown ) : string {
if ( ! error ) return ""
if ( typeof error === "string" ) return error . toLowerCase ( )
2025-12-15 23:28:22 +09:00
const errorObj = error as Record < string , unknown >
const paths = [
errorObj . data ,
errorObj . error ,
errorObj ,
( errorObj . data as Record < string , unknown > ) ? . error ,
]
for ( const obj of paths ) {
if ( obj && typeof obj === "object" ) {
const msg = ( obj as Record < string , unknown > ) . message
if ( typeof msg === "string" && msg . length > 0 ) {
return msg . toLowerCase ( )
}
}
}
try {
return JSON . stringify ( error ) . toLowerCase ( )
} catch {
return ""
2025-12-08 16:54:39 +09:00
}
2025-12-05 02:12:52 +09:00
}
2025-12-10 11:07:17 +09:00
function extractMessageIndex ( error : unknown ) : number | null {
const message = getErrorMessage ( error )
const match = message . match ( /messages\.(\d+)/ )
return match ? parseInt ( match [ 1 ] , 10 ) : null
}
2025-12-05 02:12:52 +09:00
function detectErrorType ( error : unknown ) : RecoveryErrorType {
const message = getErrorMessage ( error )
if ( message . includes ( "tool_use" ) && message . includes ( "tool_result" ) ) {
return "tool_result_missing"
}
2025-12-05 20:01:47 +09:00
if (
message . includes ( "thinking" ) &&
2025-12-11 11:07:07 +09:00
( message . includes ( "first block" ) ||
message . includes ( "must start with" ) ||
message . includes ( "preceeding" ) ||
( message . includes ( "expected" ) && message . includes ( "found" ) ) )
2025-12-05 20:01:47 +09:00
) {
2025-12-05 02:12:52 +09:00
return "thinking_block_order"
}
if ( message . includes ( "thinking is disabled" ) && message . includes ( "cannot contain" ) ) {
return "thinking_disabled_violation"
}
2025-12-26 03:36:27 +09:00
if (
message . includes ( "tool" ) &&
( message . includes ( "not found" ) ||
message . includes ( "unknown tool" ) ||
message . includes ( "invalid tool" ) )
) {
return "tool_not_found"
}
2025-12-05 02:12:52 +09:00
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 > {
2025-12-15 19:02:31 +09:00
// Try API parts first, fallback to filesystem if empty
let parts = failedAssistantMsg . parts || [ ]
if ( parts . length === 0 && failedAssistantMsg . info ? . id ) {
const storedParts = readParts ( failedAssistantMsg . info . id )
parts = storedParts . map ( ( p ) = > ( {
type : p . type === "tool" ? "tool_use" : p . type ,
id : "callID" in p ? ( p as { callID? : string } ) . callID : p.id ,
name : "tool" in p ? ( p as { tool? : string } ) . tool : undefined ,
input : "state" in p ? ( p as { state ? : { input? : Record < string , unknown > } } ) . state?.input : undefined ,
} ) )
}
2025-12-05 02:12:52 +09:00
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 } ,
2025-12-08 11:48:08 +09:00
// @ts-expect-error - SDK types may not include tool_result parts
2025-12-05 02:12:52 +09:00
body : { parts : toolResultParts } ,
} )
return true
} catch {
return false
}
}
async function recoverThinkingBlockOrder (
2025-12-08 15:00:09 +09:00
_client : Client ,
2025-12-05 02:12:52 +09:00
sessionID : string ,
2025-12-08 15:00:09 +09:00
_failedAssistantMsg : MessageData ,
2025-12-11 11:07:07 +09:00
_directory : string ,
error : unknown
2025-12-05 02:12:52 +09:00
) : Promise < boolean > {
2025-12-11 11:07:07 +09:00
const targetIndex = extractMessageIndex ( error )
if ( targetIndex !== null ) {
const targetMessageID = findMessageByIndexNeedingThinking ( sessionID , targetIndex )
if ( targetMessageID ) {
return prependThinkingPart ( sessionID , targetMessageID )
}
}
2025-12-08 15:00:09 +09:00
const orphanMessages = findMessagesWithOrphanThinking ( sessionID )
if ( orphanMessages . length === 0 ) {
2025-12-05 02:12:52 +09:00
return false
}
2025-12-08 15:00:09 +09:00
let anySuccess = false
for ( const messageID of orphanMessages ) {
if ( prependThinkingPart ( sessionID , messageID ) ) {
anySuccess = true
}
}
2025-12-05 02:12:52 +09:00
2025-12-08 15:00:09 +09:00
return anySuccess
2025-12-05 02:12:52 +09:00
}
async function recoverThinkingDisabledViolation (
2025-12-08 15:00:09 +09:00
_client : Client ,
2025-12-05 02:12:52 +09:00
sessionID : string ,
2025-12-08 15:00:09 +09:00
_failedAssistantMsg : MessageData
2025-12-05 02:12:52 +09:00
) : Promise < boolean > {
2025-12-08 15:00:09 +09:00
const messagesWithThinking = findMessagesWithThinkingBlocks ( sessionID )
2025-12-05 02:12:52 +09:00
2025-12-08 15:00:09 +09:00
if ( messagesWithThinking . length === 0 ) {
2025-12-05 02:12:52 +09:00
return false
}
2025-12-08 15:00:09 +09:00
let anySuccess = false
for ( const messageID of messagesWithThinking ) {
if ( stripThinkingParts ( messageID ) ) {
anySuccess = true
}
}
2025-12-05 02:12:52 +09:00
2025-12-08 15:00:09 +09:00
return anySuccess
2025-12-05 02:12:52 +09:00
}
2025-12-26 03:36:27 +09:00
async function recoverToolNotFound (
client : Client ,
sessionID : string ,
failedAssistantMsg : MessageData ,
error : unknown
) : Promise < boolean > {
const errorMsg = getErrorMessage ( error )
const toolNameMatch = errorMsg . match ( /tool[:\s]+["']?([a-z0-9_-]+)["']?/i )
const toolName = toolNameMatch ? . [ 1 ] ? ? "unknown"
let parts = failedAssistantMsg . parts || [ ]
if ( parts . length === 0 && failedAssistantMsg . info ? . id ) {
const storedParts = readParts ( failedAssistantMsg . info . id )
parts = storedParts . map ( ( p ) = > ( {
type : p . type === "tool" ? "tool_use" : p . type ,
id : "callID" in p ? ( p as { callID? : string } ) . callID : p.id ,
name : "tool" in p ? ( p as { tool? : string } ) . tool : undefined ,
} ) )
}
const invalidToolUse = parts . find (
( p ) = > p . type === "tool_use" && "name" in p && p . name === toolName
)
if ( ! invalidToolUse || ! ( "id" in invalidToolUse ) ) {
return false
}
const toolResultPart = {
type : "tool_result" as const ,
tool_use_id : invalidToolUse.id ,
content : ` Error: Tool ' ${ toolName } ' does not exist. The model attempted to use a tool that is not available. This may indicate the model hallucinated the tool name or the tool was recently removed. ` ,
}
try {
await client . session . prompt ( {
path : { id : sessionID } ,
// @ts-expect-error - SDK types may not include tool_result parts
body : { parts : [ toolResultPart ] } ,
} )
return true
} catch {
return false
}
}
2025-12-14 22:26:58 +09:00
const PLACEHOLDER_TEXT = "[user interrupted]"
2025-12-05 03:54:51 +09:00
async function recoverEmptyContentMessage (
2025-12-05 23:24:20 +09:00
_client : Client ,
2025-12-05 03:54:51 +09:00
sessionID : string ,
failedAssistantMsg : MessageData ,
2025-12-10 11:07:17 +09:00
_directory : string ,
error : unknown
2025-12-05 03:54:51 +09:00
) : Promise < boolean > {
2025-12-10 11:07:17 +09:00
const targetIndex = extractMessageIndex ( error )
const failedID = failedAssistantMsg . info ? . id
2025-12-16 21:02:38 +09:00
let anySuccess = false
const messagesWithEmptyText = findMessagesWithEmptyTextParts ( sessionID )
for ( const messageID of messagesWithEmptyText ) {
if ( replaceEmptyTextParts ( messageID , PLACEHOLDER_TEXT ) ) {
anySuccess = true
}
}
2025-12-08 15:00:09 +09:00
2025-12-14 22:26:58 +09:00
const thinkingOnlyIDs = findMessagesWithThinkingOnly ( sessionID )
for ( const messageID of thinkingOnlyIDs ) {
2025-12-16 21:02:38 +09:00
if ( injectTextPart ( sessionID , messageID , PLACEHOLDER_TEXT ) ) {
anySuccess = true
}
2025-12-14 22:26:58 +09:00
}
2025-12-10 11:07:17 +09:00
if ( targetIndex !== null ) {
const targetMessageID = findEmptyMessageByIndex ( sessionID , targetIndex )
if ( targetMessageID ) {
2025-12-16 21:02:38 +09:00
if ( replaceEmptyTextParts ( targetMessageID , PLACEHOLDER_TEXT ) ) {
return true
}
if ( injectTextPart ( sessionID , targetMessageID , PLACEHOLDER_TEXT ) ) {
return true
}
2025-12-10 11:07:17 +09:00
}
2025-12-08 15:00:09 +09:00
}
2025-12-10 11:07:17 +09:00
if ( failedID ) {
2025-12-16 21:02:38 +09:00
if ( replaceEmptyTextParts ( failedID , PLACEHOLDER_TEXT ) ) {
return true
}
2025-12-14 22:26:58 +09:00
if ( injectTextPart ( sessionID , failedID , PLACEHOLDER_TEXT ) ) {
2025-12-10 11:07:17 +09:00
return true
}
}
const emptyMessageIDs = findEmptyMessages ( sessionID )
2025-12-08 15:00:09 +09:00
for ( const messageID of emptyMessageIDs ) {
2025-12-16 21:02:38 +09:00
if ( replaceEmptyTextParts ( messageID , PLACEHOLDER_TEXT ) ) {
anySuccess = true
}
2025-12-14 22:26:58 +09:00
if ( injectTextPart ( sessionID , messageID , PLACEHOLDER_TEXT ) ) {
2025-12-08 15:00:09 +09:00
anySuccess = true
}
}
2025-12-05 15:28:22 +09:00
2025-12-08 15:00:09 +09:00
return anySuccess
2025-12-05 03:54:51 +09:00
}
2025-12-08 19:01:42 +09:00
// NOTE: fallbackRevertStrategy was removed (2025-12-08)
// Reason: Function was defined but never called - no error recovery paths used it.
// All error types have dedicated recovery functions (recoverToolResultMissing,
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
2025-12-05 02:12:52 +09:00
2025-12-13 11:48:22 +09:00
export interface SessionRecoveryHook {
handleSessionRecovery : ( info : MessageInfo ) = > Promise < boolean >
isRecoverableError : ( error : unknown ) = > boolean
setOnAbortCallback : ( callback : ( sessionID : string ) = > void ) = > void
setOnRecoveryCompleteCallback : ( callback : ( sessionID : string ) = > void ) = > void
}
2025-12-19 02:45:59 +09:00
export function createSessionRecoveryHook ( ctx : PluginInput , options? : SessionRecoveryOptions ) : SessionRecoveryHook {
2025-12-05 02:12:52 +09:00
const processingErrors = new Set < string > ( )
2025-12-19 02:45:59 +09:00
const experimental = options ? . experimental
2025-12-05 02:12:52 +09:00
let onAbortCallback : ( ( sessionID : string ) = > void ) | null = null
2025-12-13 11:48:22 +09:00
let onRecoveryCompleteCallback : ( ( sessionID : string ) = > void ) | null = null
2025-12-05 02:12:52 +09:00
const setOnAbortCallback = ( callback : ( sessionID : string ) = > void ) : void = > {
onAbortCallback = callback
}
2025-12-13 11:48:22 +09:00
const setOnRecoveryCompleteCallback = ( callback : ( sessionID : string ) = > void ) : void = > {
onRecoveryCompleteCallback = callback
}
2025-12-05 02:12:52 +09:00
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 {
if ( onAbortCallback ) {
2025-12-13 11:48:22 +09:00
onAbortCallback ( sessionID ) // Mark recovering BEFORE abort
2025-12-05 02:12:52 +09:00
}
2025-12-13 11:48:22 +09:00
await ctx . client . session . abort ( { path : { id : sessionID } } ) . catch ( ( ) = > { } )
2025-12-05 02:12:52 +09:00
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" ,
2025-12-26 03:36:27 +09:00
tool_not_found : "Invalid Tool Recovery" ,
2025-12-05 02:12:52 +09:00
}
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..." ,
2025-12-26 03:36:27 +09:00
tool_not_found : "Handling invalid tool call..." ,
2025-12-05 02:12:52 +09:00
}
await ctx . client . tui
. showToast ( {
body : {
2025-12-08 11:48:08 +09:00
title : toastTitles [ errorType ] ,
message : toastMessages [ errorType ] ,
2025-12-05 02:12:52 +09:00
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" ) {
2025-12-11 11:07:07 +09:00
success = await recoverThinkingBlockOrder ( ctx . client , sessionID , failedMsg , ctx . directory , info . error )
2025-12-19 02:45:59 +09:00
if ( success && experimental ? . auto_resume ) {
const lastUser = findLastUserMessage ( msgs ? ? [ ] )
const resumeConfig = extractResumeConfig ( lastUser , sessionID )
await resumeSession ( ctx . client , resumeConfig )
}
2025-12-05 02:12:52 +09:00
} else if ( errorType === "thinking_disabled_violation" ) {
success = await recoverThinkingDisabledViolation ( ctx . client , sessionID , failedMsg )
2025-12-19 02:45:59 +09:00
if ( success && experimental ? . auto_resume ) {
const lastUser = findLastUserMessage ( msgs ? ? [ ] )
const resumeConfig = extractResumeConfig ( lastUser , sessionID )
await resumeSession ( ctx . client , resumeConfig )
}
2025-12-26 03:36:27 +09:00
} else if ( errorType === "tool_not_found" ) {
success = await recoverToolNotFound ( ctx . client , sessionID , failedMsg , info . error )
2025-12-05 02:12:52 +09:00
}
2025-12-19 02:45:59 +09:00
return success
2025-12-10 11:07:17 +09:00
} catch ( err ) {
console . error ( "[session-recovery] Recovery failed:" , err )
return false
} finally {
processingErrors . delete ( assistantMsgID )
2025-12-13 11:48:22 +09:00
// Always notify recovery complete, regardless of success or failure
if ( sessionID && onRecoveryCompleteCallback ) {
onRecoveryCompleteCallback ( sessionID )
}
2025-12-10 11:07:17 +09:00
}
2025-12-05 02:12:52 +09:00
}
return {
handleSessionRecovery ,
isRecoverableError ,
setOnAbortCallback ,
2025-12-13 11:48:22 +09:00
setOnRecoveryCompleteCallback ,
2025-12-05 02:12:52 +09:00
}
}