feat: wire session-recovery callers to async SDK/HTTP variants on SQLite

- recover-thinking-disabled-violation: isSqliteBackend() branch using
  stripThinkingPartsAsync() with SDK message enumeration
- recover-thinking-block-order: isSqliteBackend() branch using
  prependThinkingPartAsync() with SDK orphan thinking detection
- recover-empty-content-message: isSqliteBackend() branch delegating to
  extracted recover-empty-content-message-sdk.ts (200 LOC limit)
- storage.ts barrel: add async variant exports for all SDK functions
This commit is contained in:
YeonGyu-Kim
2026-02-15 15:53:18 +09:00
parent 0a085adcd6
commit dff3a551d8
5 changed files with 347 additions and 3 deletions

View File

@@ -0,0 +1,195 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import { META_TYPES, THINKING_TYPES } from "./constants"
type Client = ReturnType<typeof createOpencodeClient>
type ReplaceEmptyTextPartsAsync = (
client: Client,
sessionID: string,
messageID: string,
replacementText: string
) => Promise<boolean>
type InjectTextPartAsync = (
client: Client,
sessionID: string,
messageID: string,
text: string
) => Promise<boolean>
type FindMessagesWithEmptyTextPartsFromSDK = (
client: Client,
sessionID: string
) => Promise<string[]>
export async function recoverEmptyContentMessageFromSDK(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
error: unknown,
dependencies: {
placeholderText: string
replaceEmptyTextPartsAsync: ReplaceEmptyTextPartsAsync
injectTextPartAsync: InjectTextPartAsync
findMessagesWithEmptyTextPartsFromSDK: FindMessagesWithEmptyTextPartsFromSDK
}
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false
const messagesWithEmptyText = await dependencies.findMessagesWithEmptyTextPartsFromSDK(client, sessionID)
for (const messageID of messagesWithEmptyText) {
if (
await dependencies.replaceEmptyTextPartsAsync(
client,
sessionID,
messageID,
dependencies.placeholderText
)
) {
anySuccess = true
}
}
const messages = await readMessagesFromSDK(client, sessionID)
const thinkingOnlyIDs = findMessagesWithThinkingOnlyFromSDK(messages)
for (const messageID of thinkingOnlyIDs) {
if (await dependencies.injectTextPartAsync(client, sessionID, messageID, dependencies.placeholderText)) {
anySuccess = true
}
}
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndexFromSDK(messages, targetIndex)
if (targetMessageID) {
if (
await dependencies.replaceEmptyTextPartsAsync(
client,
sessionID,
targetMessageID,
dependencies.placeholderText
)
) {
return true
}
if (await dependencies.injectTextPartAsync(client, sessionID, targetMessageID, dependencies.placeholderText)) {
return true
}
}
}
if (failedID) {
if (await dependencies.replaceEmptyTextPartsAsync(client, sessionID, failedID, dependencies.placeholderText)) {
return true
}
if (await dependencies.injectTextPartAsync(client, sessionID, failedID, dependencies.placeholderText)) {
return true
}
}
const emptyMessageIDs = findEmptyMessagesFromSDK(messages)
for (const messageID of emptyMessageIDs) {
if (
await dependencies.replaceEmptyTextPartsAsync(
client,
sessionID,
messageID,
dependencies.placeholderText
)
) {
anySuccess = true
}
if (await dependencies.injectTextPartAsync(client, sessionID, messageID, dependencies.placeholderText)) {
anySuccess = true
}
}
return anySuccess
}
type SdkPart = NonNullable<MessageData["parts"]>[number]
function sdkPartHasContent(part: SdkPart): boolean {
if (THINKING_TYPES.has(part.type)) return false
if (META_TYPES.has(part.type)) return false
if (part.type === "text") {
return !!part.text?.trim()
}
if (part.type === "tool" || part.type === "tool_use" || part.type === "tool_result") {
return true
}
return false
}
function sdkMessageHasContent(message: MessageData): boolean {
return (message.parts ?? []).some(sdkPartHasContent)
}
async function readMessagesFromSDK(client: Client, sessionID: string): Promise<MessageData[]> {
const response = await client.session.messages({ path: { id: sessionID } })
return (response.data ?? []) as MessageData[]
}
function findMessagesWithThinkingOnlyFromSDK(messages: MessageData[]): string[] {
const result: string[] = []
for (const msg of messages) {
if (msg.info?.role !== "assistant") continue
if (!msg.info?.id) continue
if (!msg.parts || msg.parts.length === 0) continue
const hasThinking = msg.parts.some((part) => THINKING_TYPES.has(part.type))
const hasContent = msg.parts.some(sdkPartHasContent)
if (hasThinking && !hasContent) {
result.push(msg.info.id)
}
}
return result
}
function findEmptyMessagesFromSDK(messages: MessageData[]): string[] {
const emptyIds: string[] = []
for (const msg of messages) {
if (!msg.info?.id) continue
if (!sdkMessageHasContent(msg)) {
emptyIds.push(msg.info.id)
}
}
return emptyIds
}
function findEmptyMessageByIndexFromSDK(messages: MessageData[], targetIndex: number): string | null {
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 (!targetMessage.info?.id) continue
if (!sdkMessageHasContent(targetMessage)) {
return targetMessage.info.id
}
}
return null
}

View File

@@ -1,6 +1,7 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
import {
findEmptyMessageByIndex,
findEmptyMessages,
@@ -9,18 +10,30 @@ import {
injectTextPart,
replaceEmptyTextParts,
} from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { replaceEmptyTextPartsAsync, findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
import { injectTextPartAsync } from "./storage/text-part-injector"
type Client = ReturnType<typeof createOpencodeClient>
const PLACEHOLDER_TEXT = "[user interrupted]"
export async function recoverEmptyContentMessage(
_client: Client,
client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
if (isSqliteBackend()) {
return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, {
placeholderText: PLACEHOLDER_TEXT,
replaceEmptyTextPartsAsync,
injectTextPartAsync,
findMessagesWithEmptyTextPartsFromSDK,
})
}
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
let anySuccess = false

View File

@@ -2,16 +2,23 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { extractMessageIndex } from "./detect-error-type"
import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { prependThinkingPartAsync } from "./storage/thinking-prepend"
import { THINKING_TYPES } from "./constants"
type Client = ReturnType<typeof createOpencodeClient>
export async function recoverThinkingBlockOrder(
_client: Client,
client: Client,
sessionID: string,
_failedAssistantMsg: MessageData,
_directory: string,
error: unknown
): Promise<boolean> {
if (isSqliteBackend()) {
return recoverThinkingBlockOrderFromSDK(client, sessionID, error)
}
const targetIndex = extractMessageIndex(error)
if (targetIndex !== null) {
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
@@ -34,3 +41,86 @@ export async function recoverThinkingBlockOrder(
return anySuccess
}
async function recoverThinkingBlockOrderFromSDK(
client: Client,
sessionID: string,
error: unknown
): Promise<boolean> {
const targetIndex = extractMessageIndex(error)
if (targetIndex !== null) {
const targetMessageID = await findMessageByIndexNeedingThinkingFromSDK(client, sessionID, targetIndex)
if (targetMessageID) {
return prependThinkingPartAsync(client, sessionID, targetMessageID)
}
}
const orphanMessages = await findMessagesWithOrphanThinkingFromSDK(client, sessionID)
if (orphanMessages.length === 0) {
return false
}
let anySuccess = false
for (const messageID of orphanMessages) {
if (await prependThinkingPartAsync(client, sessionID, messageID)) {
anySuccess = true
}
}
return anySuccess
}
async function findMessagesWithOrphanThinkingFromSDK(
client: Client,
sessionID: string
): Promise<string[]> {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = (response.data ?? []) as MessageData[]
const result: string[] = []
for (const msg of messages) {
if (msg.info?.role !== "assistant") continue
if (!msg.info?.id) continue
if (!msg.parts || msg.parts.length === 0) continue
const partsWithIds = msg.parts.filter(
(part): part is { id: string; type: string } => typeof part.id === "string"
)
if (partsWithIds.length === 0) continue
const sortedParts = [...partsWithIds].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
if (!THINKING_TYPES.has(firstPart.type)) {
result.push(msg.info.id)
}
}
return result
}
async function findMessageByIndexNeedingThinkingFromSDK(
client: Client,
sessionID: string,
targetIndex: number
): Promise<string | null> {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = (response.data ?? []) as MessageData[]
if (targetIndex < 0 || targetIndex >= messages.length) return null
const targetMessage = messages[targetIndex]
if (targetMessage.info?.role !== "assistant") return null
if (!targetMessage.info?.id) return null
if (!targetMessage.parts || targetMessage.parts.length === 0) return null
const partsWithIds = targetMessage.parts.filter(
(part): part is { id: string; type: string } => typeof part.id === "string"
)
if (partsWithIds.length === 0) return null
const sortedParts = [...partsWithIds].sort((a, b) => a.id.localeCompare(b.id))
const firstPart = sortedParts[0]
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
return firstIsThinking ? null : targetMessage.info.id
}

View File

@@ -1,14 +1,21 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData } from "./types"
import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { stripThinkingPartsAsync } from "./storage/thinking-strip"
import { THINKING_TYPES } from "./constants"
type Client = ReturnType<typeof createOpencodeClient>
export async function recoverThinkingDisabledViolation(
_client: Client,
client: Client,
sessionID: string,
_failedAssistantMsg: MessageData
): Promise<boolean> {
if (isSqliteBackend()) {
return recoverThinkingDisabledViolationFromSDK(client, sessionID)
}
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
if (messagesWithThinking.length === 0) {
return false
@@ -23,3 +30,36 @@ export async function recoverThinkingDisabledViolation(
return anySuccess
}
async function recoverThinkingDisabledViolationFromSDK(
client: Client,
sessionID: string
): Promise<boolean> {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = (response.data ?? []) as MessageData[]
const messageIDsWithThinking: string[] = []
for (const msg of messages) {
if (msg.info?.role !== "assistant") continue
if (!msg.info?.id) continue
if (!msg.parts) continue
const hasThinking = msg.parts.some((part) => THINKING_TYPES.has(part.type))
if (hasThinking) {
messageIDsWithThinking.push(msg.info.id)
}
}
if (messageIDsWithThinking.length === 0) {
return false
}
let anySuccess = false
for (const messageID of messageIDsWithThinking) {
if (await stripThinkingPartsAsync(client, sessionID, messageID)) {
anySuccess = true
}
}
return anySuccess
}

View File

@@ -6,6 +6,7 @@ export { readParts } from "./storage/parts-reader"
export { readPartsFromSDK } from "./storage/parts-reader"
export { hasContent, messageHasContent } from "./storage/part-content"
export { injectTextPart } from "./storage/text-part-injector"
export { injectTextPartAsync } from "./storage/text-part-injector"
export {
findEmptyMessages,
@@ -13,6 +14,7 @@ export {
findFirstEmptyMessage,
} from "./storage/empty-messages"
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
export { findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
export {
findMessagesWithThinkingBlocks,
@@ -26,3 +28,7 @@ export {
export { prependThinkingPart } from "./storage/thinking-prepend"
export { stripThinkingParts } from "./storage/thinking-strip"
export { replaceEmptyTextParts } from "./storage/empty-text"
export { prependThinkingPartAsync } from "./storage/thinking-prepend"
export { stripThinkingPartsAsync } from "./storage/thinking-strip"
export { replaceEmptyTextPartsAsync } from "./storage/empty-text"