refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks
- Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.) - Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files - Decompose session-recovery into separate recovery strategy files per error type - Extract todo-continuation-enforcer from monolith to directory with dedicated modules - Split background-task/tools.ts into individual tool creator files - Extract command-executor, tmux-utils into focused sub-modules - Split config/schema.ts into domain-specific schema files - Decompose cli/config-manager.ts into focused modules - Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests - Fix all import path depths for moved files (../../ -> ../../../) - Add explicit type annotations to resolve TS7006 implicit any errors Typecheck: 0 errors Tests: 2359 pass, 5 fail (all pre-existing)
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
export type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| null
|
||||
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMessageIndex(error: unknown): number | null {
|
||||
const message = getErrorMessage(error)
|
||||
const match = message.match(/messages\.(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
message.includes("final block") ||
|
||||
message.includes("cannot be thinking") ||
|
||||
(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("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import { log } from "../../shared/logger"
|
||||
import { detectErrorType } from "./detect-error-type"
|
||||
import type { RecoveryErrorType } from "./detect-error-type"
|
||||
import type { MessageData } from "./types"
|
||||
import { recoverToolResultMissing } from "./recover-tool-result-missing"
|
||||
import { recoverThinkingBlockOrder } from "./recover-thinking-block-order"
|
||||
import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation"
|
||||
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface SessionRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
|
||||
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, options?: SessionRecoveryOptions): SessionRecoveryHook {
|
||||
const processingErrors = new Set<string>()
|
||||
const experimental = options?.experimental
|
||||
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)
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
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...",
|
||||
}
|
||||
|
||||
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)
|
||||
if (success && experimental?.auto_resume) {
|
||||
const lastUser = findLastUserMessage(msgs ?? [])
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
await resumeSession(ctx.client, resumeConfig)
|
||||
}
|
||||
} else if (errorType === "thinking_disabled_violation") {
|
||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||
if (success && experimental?.auto_resume) {
|
||||
const lastUser = findLastUserMessage(msgs ?? [])
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
await resumeSession(ctx.client, resumeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return success
|
||||
} catch (err) {
|
||||
log("[session-recovery] Recovery failed:", err)
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
|
||||
if (sessionID && onRecoveryCompleteCallback) {
|
||||
onRecoveryCompleteCallback(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSessionRecovery,
|
||||
isRecoverableError,
|
||||
setOnAbortCallback,
|
||||
setOnRecoveryCompleteCallback,
|
||||
}
|
||||
}
|
||||
@@ -1,436 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { ExperimentalConfig } from "../../config"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
findMessageByIndexNeedingThinking,
|
||||
findMessagesWithEmptyTextParts,
|
||||
findMessagesWithOrphanThinking,
|
||||
findMessagesWithThinkingBlocks,
|
||||
findMessagesWithThinkingOnly,
|
||||
injectTextPart,
|
||||
prependThinkingPart,
|
||||
readParts,
|
||||
replaceEmptyTextParts,
|
||||
stripThinkingParts,
|
||||
} from "./storage"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
export { createSessionRecoveryHook } from "./hook"
|
||||
export type { SessionRecoveryHook, SessionRecoveryOptions } from "./hook"
|
||||
|
||||
export interface SessionRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
export { detectErrorType } from "./detect-error-type"
|
||||
export type { RecoveryErrorType } from "./detect-error-type"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| 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>
|
||||
}
|
||||
|
||||
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.promptAsync({
|
||||
path: { id: config.sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
|
||||
agent: config.agent,
|
||||
model: config.model,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
// IMPORTANT: Check thinking_block_order BEFORE tool_result_missing
|
||||
// because Anthropic's extended thinking error messages contain "tool_use" and "tool_result"
|
||||
// in the documentation URL, which would incorrectly match tool_result_missing
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
message.includes("final block") ||
|
||||
message.includes("cannot be thinking") ||
|
||||
(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("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
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.promptAsync({
|
||||
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, options?: SessionRecoveryOptions): SessionRecoveryHook {
|
||||
const processingErrors = new Set<string>()
|
||||
const experimental = options?.experimental
|
||||
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",
|
||||
}
|
||||
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...",
|
||||
}
|
||||
|
||||
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)
|
||||
if (success && experimental?.auto_resume) {
|
||||
const lastUser = findLastUserMessage(msgs ?? [])
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
await resumeSession(ctx.client, resumeConfig)
|
||||
}
|
||||
} else if (errorType === "thinking_disabled_violation") {
|
||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||
if (success && experimental?.auto_resume) {
|
||||
const lastUser = findLastUserMessage(msgs ?? [])
|
||||
const resumeConfig = extractResumeConfig(lastUser, sessionID)
|
||||
await resumeSession(ctx.client, resumeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return success
|
||||
} catch (err) {
|
||||
log("[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,
|
||||
}
|
||||
}
|
||||
export type { MessageData, ResumeConfig } from "./types"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { extractMessageIndex } from "./detect-error-type"
|
||||
import {
|
||||
findEmptyMessageByIndex,
|
||||
findEmptyMessages,
|
||||
findMessagesWithEmptyTextParts,
|
||||
findMessagesWithThinkingOnly,
|
||||
injectTextPart,
|
||||
replaceEmptyTextParts,
|
||||
} from "./storage"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
const PLACEHOLDER_TEXT = "[user interrupted]"
|
||||
|
||||
export 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
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { extractMessageIndex } from "./detect-error-type"
|
||||
import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
export 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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
export 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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData } from "./types"
|
||||
import { readParts } from "./storage"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
function extractToolUseIds(parts: MessagePart[]): string[] {
|
||||
return parts.filter((part): part is ToolUsePart => part.type === "tool_use" && !!part.id).map((part) => part.id)
|
||||
}
|
||||
|
||||
export async function recoverToolResultMissing(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
let parts = failedAssistantMsg.parts || []
|
||||
if (parts.length === 0 && failedAssistantMsg.info?.id) {
|
||||
const storedParts = readParts(failedAssistantMsg.info.id)
|
||||
parts = storedParts.map((part) => ({
|
||||
type: part.type === "tool" ? "tool_use" : part.type,
|
||||
id: "callID" in part ? (part as { callID?: string }).callID : part.id,
|
||||
}))
|
||||
}
|
||||
|
||||
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)",
|
||||
}))
|
||||
|
||||
const promptInput = {
|
||||
path: { id: sessionID },
|
||||
body: { parts: toolResultParts },
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - SDK types may not include tool_result parts
|
||||
await client.session.promptAsync(promptInput)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
|
||||
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function extractResumeConfig(userMessage: MessageData | undefined, sessionID: string): ResumeConfig {
|
||||
return {
|
||||
sessionID,
|
||||
agent: userMessage?.info?.agent,
|
||||
model: userMessage?.info?.model,
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
|
||||
try {
|
||||
await client.session.promptAsync({
|
||||
path: { id: config.sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
|
||||
agent: config.agent,
|
||||
model: config.model,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,390 +1,26 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, 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) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return 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 (const msg of messages) {
|
||||
if (!messageHasContent(msg.id)) {
|
||||
emptyIds.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyIds
|
||||
}
|
||||
|
||||
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
// API index may differ from storage index due to system messages
|
||||
const indicesToTry = [
|
||||
targetIndex,
|
||||
targetIndex - 1,
|
||||
targetIndex + 1,
|
||||
targetIndex - 2,
|
||||
targetIndex + 2,
|
||||
targetIndex - 3,
|
||||
targetIndex - 4,
|
||||
targetIndex - 5,
|
||||
]
|
||||
|
||||
for (const idx of indicesToTry) {
|
||||
if (idx < 0 || idx >= messages.length) continue
|
||||
|
||||
const targetMsg = messages[idx]
|
||||
|
||||
if (!messageHasContent(targetMsg.id)) {
|
||||
return targetMsg.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findFirstEmptyMessage(sessionID: string): string | null {
|
||||
const emptyIds = findEmptyMessages(sessionID)
|
||||
return emptyIds.length > 0 ? emptyIds[0] : null
|
||||
}
|
||||
|
||||
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
if (hasThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
const hasTextContent = parts.some(hasContent)
|
||||
|
||||
// Has thinking but no text content = orphan thinking
|
||||
if (hasThinking && !hasTextContent) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
// NOTE: Removed isLastMessage skip - recovery needs to fix last message too
|
||||
// when "thinking must start with" errors occur on final assistant message
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
// NOTE: Changed condition - if first part is not thinking, it's orphan
|
||||
// regardless of whether thinking blocks exist elsewhere in the message
|
||||
if (!firstIsThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent thinking content from previous assistant messages
|
||||
* Following Anthropic's recommendation to include thinking blocks from previous turns
|
||||
*/
|
||||
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
// Find the index of the current message
|
||||
const currentIndex = messages.findIndex(m => m.id === beforeMessageID)
|
||||
if (currentIndex === -1) return ""
|
||||
|
||||
// Search backwards through previous assistant messages
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
// Look for thinking parts in this message
|
||||
const parts = readParts(msg.id)
|
||||
for (const part of parts) {
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
// Found thinking content - return it
|
||||
// Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property
|
||||
const thinking = (part as { thinking?: string; text?: string }).thinking
|
||||
const reasoning = (part as { thinking?: string; text?: string }).text
|
||||
const content = thinking || reasoning
|
||||
if (content && content.trim().length > 0) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Try to get thinking content from previous turns (Anthropic's recommendation)
|
||||
const previousThinking = findLastThinkingContent(sessionID, messageID)
|
||||
|
||||
const partId = `prt_0000000000_thinking`
|
||||
const part = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: previousThinking || "[Continuing from previous reasoning]",
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function stripThinkingParts(messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return false
|
||||
|
||||
let anyRemoved = false
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const filePath = join(partDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredPart
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
unlinkSync(filePath)
|
||||
anyRemoved = true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return anyRemoved
|
||||
}
|
||||
|
||||
export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return false
|
||||
|
||||
let anyReplaced = false
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const filePath = join(partDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredPart
|
||||
|
||||
if (part.type === "text") {
|
||||
const textPart = part as StoredTextPart
|
||||
if (!textPart.text?.trim()) {
|
||||
textPart.text = replacementText
|
||||
textPart.synthetic = true
|
||||
writeFileSync(filePath, JSON.stringify(textPart, null, 2))
|
||||
anyReplaced = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return anyReplaced
|
||||
}
|
||||
|
||||
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
const parts = readParts(msg.id)
|
||||
const hasEmptyTextPart = parts.some((p) => {
|
||||
if (p.type !== "text") return false
|
||||
const textPart = p as StoredTextPart
|
||||
return !textPart.text?.trim()
|
||||
})
|
||||
|
||||
if (hasEmptyTextPart) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= messages.length) return null
|
||||
|
||||
const targetMsg = messages[targetIndex]
|
||||
if (targetMsg.role !== "assistant") return null
|
||||
|
||||
const parts = readParts(targetMsg.id)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
if (!firstIsThinking) {
|
||||
return targetMsg.id
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
export { generatePartId } from "./storage/part-id"
|
||||
export { getMessageDir } from "./storage/message-dir"
|
||||
export { readMessages } from "./storage/messages-reader"
|
||||
export { readParts } from "./storage/parts-reader"
|
||||
export { hasContent, messageHasContent } from "./storage/part-content"
|
||||
export { injectTextPart } from "./storage/text-part-injector"
|
||||
|
||||
export {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
findFirstEmptyMessage,
|
||||
} from "./storage/empty-messages"
|
||||
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
|
||||
|
||||
export {
|
||||
findMessagesWithThinkingBlocks,
|
||||
findMessagesWithThinkingOnly,
|
||||
} from "./storage/thinking-block-search"
|
||||
export {
|
||||
findMessagesWithOrphanThinking,
|
||||
findMessageByIndexNeedingThinking,
|
||||
} from "./storage/orphan-thinking-search"
|
||||
|
||||
export { prependThinkingPart } from "./storage/thinking-prepend"
|
||||
export { stripThinkingParts } from "./storage/thinking-strip"
|
||||
export { replaceEmptyTextParts } from "./storage/empty-text"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { messageHasContent } from "./part-content"
|
||||
import { readMessages } from "./messages-reader"
|
||||
|
||||
export function findEmptyMessages(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const emptyIds: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!messageHasContent(msg.id)) {
|
||||
emptyIds.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyIds
|
||||
}
|
||||
|
||||
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
const indicesToTry = [
|
||||
targetIndex,
|
||||
targetIndex - 1,
|
||||
targetIndex + 1,
|
||||
targetIndex - 2,
|
||||
targetIndex + 2,
|
||||
targetIndex - 3,
|
||||
targetIndex - 4,
|
||||
targetIndex - 5,
|
||||
]
|
||||
|
||||
for (const index of indicesToTry) {
|
||||
if (index < 0 || index >= messages.length) continue
|
||||
|
||||
const targetMessage = messages[index]
|
||||
|
||||
if (!messageHasContent(targetMessage.id)) {
|
||||
return targetMessage.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findFirstEmptyMessage(sessionID: string): string | null {
|
||||
const emptyIds = findEmptyMessages(sessionID)
|
||||
return emptyIds.length > 0 ? emptyIds[0] : null
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { PART_STORAGE } from "../constants"
|
||||
import type { StoredPart, StoredTextPart } from "../types"
|
||||
import { readMessages } from "./messages-reader"
|
||||
import { readParts } from "./parts-reader"
|
||||
|
||||
export function replaceEmptyTextParts(messageID: string, replacementText: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return false
|
||||
|
||||
let anyReplaced = false
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const filePath = join(partDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredPart
|
||||
|
||||
if (part.type === "text") {
|
||||
const textPart = part as StoredTextPart
|
||||
if (!textPart.text?.trim()) {
|
||||
textPart.text = replacementText
|
||||
textPart.synthetic = true
|
||||
writeFileSync(filePath, JSON.stringify(textPart, null, 2))
|
||||
anyReplaced = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return anyReplaced
|
||||
}
|
||||
|
||||
export function findMessagesWithEmptyTextParts(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
const parts = readParts(msg.id)
|
||||
const hasEmptyTextPart = parts.some((part) => {
|
||||
if (part.type !== "text") return false
|
||||
const textPart = part as StoredTextPart
|
||||
return !textPart.text?.trim()
|
||||
})
|
||||
|
||||
if (hasEmptyTextPart) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE } from "../constants"
|
||||
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { StoredMessageMeta } from "../types"
|
||||
import { getMessageDir } from "./message-dir"
|
||||
|
||||
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) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { THINKING_TYPES } from "../constants"
|
||||
import { readMessages } from "./messages-reader"
|
||||
import { readParts } from "./parts-reader"
|
||||
|
||||
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
if (!firstIsThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= messages.length) return null
|
||||
|
||||
const targetMessage = messages[targetIndex]
|
||||
if (targetMessage.role !== "assistant") return null
|
||||
|
||||
const parts = readParts(targetMessage.id)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
return firstIsThinking ? null : targetMessage.id
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { THINKING_TYPES, META_TYPES } from "../constants"
|
||||
import type { StoredPart, StoredTextPart } from "../types"
|
||||
import { readParts } from "./parts-reader"
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function generatePartId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `prt_${timestamp}${random}`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { PART_STORAGE } from "../constants"
|
||||
import type { StoredPart } from "../types"
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { PART_STORAGE } from "../constants"
|
||||
import type { StoredTextPart } from "../types"
|
||||
import { generatePartId } from "./part-id"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { THINKING_TYPES } from "../constants"
|
||||
import { hasContent } from "./part-content"
|
||||
import { readMessages } from "./messages-reader"
|
||||
import { readParts } from "./parts-reader"
|
||||
|
||||
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type))
|
||||
if (hasThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessagesWithThinkingOnly(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
const hasThinking = parts.some((part) => THINKING_TYPES.has(part.type))
|
||||
const hasTextContent = parts.some(hasContent)
|
||||
|
||||
if (hasThinking && !hasTextContent) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { PART_STORAGE, THINKING_TYPES } from "../constants"
|
||||
import { readMessages } from "./messages-reader"
|
||||
import { readParts } from "./parts-reader"
|
||||
|
||||
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
const currentIndex = messages.findIndex((message) => message.id === beforeMessageID)
|
||||
if (currentIndex === -1) return ""
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (message.role !== "assistant") continue
|
||||
|
||||
const parts = readParts(message.id)
|
||||
for (const part of parts) {
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
const thinking = (part as { thinking?: string; text?: string }).thinking
|
||||
const reasoning = (part as { thinking?: string; text?: string }).text
|
||||
const content = thinking || reasoning
|
||||
if (content && content.trim().length > 0) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const previousThinking = findLastThinkingContent(sessionID, messageID)
|
||||
|
||||
const partId = "prt_0000000000_thinking"
|
||||
const part = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: previousThinking || "[Continuing from previous reasoning]",
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { PART_STORAGE, THINKING_TYPES } from "../constants"
|
||||
import type { StoredPart } from "../types"
|
||||
|
||||
export function stripThinkingParts(messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return false
|
||||
|
||||
let anyRemoved = false
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const filePath = join(partDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredPart
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
unlinkSync(filePath)
|
||||
anyRemoved = true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return anyRemoved
|
||||
}
|
||||
Reference in New Issue
Block a user