838f49bc42
Directly modify empty text parts in storage files before attempting to inject new parts. This ensures that existing empty text parts are replaced with placeholder text, fixing the issue where Anthropic API returns 'messages.X: all messages must have non-empty content' error even after recovery. - Added replaceEmptyTextParts function to directly replace empty text parts - Added findMessagesWithEmptyTextParts function to identify affected messages - Modified recoverEmptyContentMessage to prioritize replacing existing empty parts 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
|
import {
|
|
findEmptyMessages,
|
|
findEmptyMessageByIndex,
|
|
findMessageByIndexNeedingThinking,
|
|
findMessagesWithEmptyTextParts,
|
|
findMessagesWithOrphanThinking,
|
|
findMessagesWithThinkingBlocks,
|
|
findMessagesWithThinkingOnly,
|
|
injectTextPart,
|
|
prependThinkingPart,
|
|
readParts,
|
|
replaceEmptyTextParts,
|
|
stripThinkingParts,
|
|
} 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 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 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 ""
|
|
}
|
|
}
|
|
|
|
function extractMessageIndex(error: unknown): number | null {
|
|
const message = getErrorMessage(error)
|
|
const match = message.match(/messages\.(\d+)/)
|
|
return match ? parseInt(match[1], 10) : null
|
|
}
|
|
|
|
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") ||
|
|
(message.includes("expected") && message.includes("found")))
|
|
) {
|
|
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") ||
|
|
(message.includes("content") && message.includes("is empty")) ||
|
|
(message.includes("content field") && message.includes("empty"))
|
|
) {
|
|
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> {
|
|
// 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,
|
|
}))
|
|
}
|
|
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,
|
|
error: unknown
|
|
): Promise<boolean> {
|
|
const targetIndex = extractMessageIndex(error)
|
|
if (targetIndex !== null) {
|
|
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
|
|
if (targetMessageID) {
|
|
return prependThinkingPart(sessionID, targetMessageID)
|
|
}
|
|
}
|
|
|
|
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
|
|
|
|
if (orphanMessages.length === 0) {
|
|
return false
|
|
}
|
|
|
|
let anySuccess = false
|
|
for (const messageID of orphanMessages) {
|
|
if (prependThinkingPart(sessionID, messageID)) {
|
|
anySuccess = true
|
|
}
|
|
}
|
|
|
|
return anySuccess
|
|
}
|
|
|
|
async function recoverThinkingDisabledViolation(
|
|
_client: Client,
|
|
sessionID: string,
|
|
_failedAssistantMsg: MessageData
|
|
): Promise<boolean> {
|
|
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
|
|
|
|
if (messagesWithThinking.length === 0) {
|
|
return false
|
|
}
|
|
|
|
let anySuccess = false
|
|
for (const messageID of messagesWithThinking) {
|
|
if (stripThinkingParts(messageID)) {
|
|
anySuccess = true
|
|
}
|
|
}
|
|
|
|
return anySuccess
|
|
}
|
|
|
|
const PLACEHOLDER_TEXT = "[user interrupted]"
|
|
|
|
async function recoverEmptyContentMessage(
|
|
_client: Client,
|
|
sessionID: string,
|
|
failedAssistantMsg: MessageData,
|
|
_directory: string,
|
|
error: unknown
|
|
): Promise<boolean> {
|
|
const targetIndex = extractMessageIndex(error)
|
|
const failedID = failedAssistantMsg.info?.id
|
|
let anySuccess = false
|
|
|
|
const messagesWithEmptyText = findMessagesWithEmptyTextParts(sessionID)
|
|
for (const messageID of messagesWithEmptyText) {
|
|
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
|
|
anySuccess = true
|
|
}
|
|
}
|
|
|
|
const thinkingOnlyIDs = findMessagesWithThinkingOnly(sessionID)
|
|
for (const messageID of thinkingOnlyIDs) {
|
|
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
|
|
anySuccess = true
|
|
}
|
|
}
|
|
|
|
if (targetIndex !== null) {
|
|
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
|
|
if (targetMessageID) {
|
|
if (replaceEmptyTextParts(targetMessageID, PLACEHOLDER_TEXT)) {
|
|
return true
|
|
}
|
|
if (injectTextPart(sessionID, targetMessageID, PLACEHOLDER_TEXT)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failedID) {
|
|
if (replaceEmptyTextParts(failedID, PLACEHOLDER_TEXT)) {
|
|
return true
|
|
}
|
|
if (injectTextPart(sessionID, failedID, PLACEHOLDER_TEXT)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
const emptyMessageIDs = findEmptyMessages(sessionID)
|
|
for (const messageID of emptyMessageIDs) {
|
|
if (replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT)) {
|
|
anySuccess = true
|
|
}
|
|
if (injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT)) {
|
|
anySuccess = true
|
|
}
|
|
}
|
|
|
|
return anySuccess
|
|
}
|
|
|
|
// 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).
|
|
|
|
export interface SessionRecoveryHook {
|
|
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
|
|
isRecoverableError: (error: unknown) => boolean
|
|
setOnAbortCallback: (callback: (sessionID: string) => void) => void
|
|
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
|
|
}
|
|
|
|
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook {
|
|
const processingErrors = new Set<string>()
|
|
let onAbortCallback: ((sessionID: string) => void) | null = null
|
|
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
|
|
|
|
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
|
|
onAbortCallback = callback
|
|
}
|
|
|
|
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
|
|
onRecoveryCompleteCallback = 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 {
|
|
if (onAbortCallback) {
|
|
onAbortCallback(sessionID) // Mark recovering BEFORE abort
|
|
}
|
|
|
|
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
|
|
|
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, info.error)
|
|
} 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, info.error)
|
|
}
|
|
|
|
return success
|
|
} catch (err) {
|
|
console.error("[session-recovery] Recovery failed:", err)
|
|
return false
|
|
} finally {
|
|
processingErrors.delete(assistantMsgID)
|
|
|
|
// Always notify recovery complete, regardless of success or failure
|
|
if (sessionID && onRecoveryCompleteCallback) {
|
|
onRecoveryCompleteCallback(sessionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
handleSessionRecovery,
|
|
isRecoverableError,
|
|
setOnAbortCallback,
|
|
setOnRecoveryCompleteCallback,
|
|
}
|
|
}
|