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:
parent
0a085adcd6
commit
dff3a551d8
195
src/hooks/session-recovery/recover-empty-content-message-sdk.ts
Normal file
195
src/hooks/session-recovery/recover-empty-content-message-sdk.ts
Normal 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
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
import { extractMessageIndex } from "./detect-error-type"
|
import { extractMessageIndex } from "./detect-error-type"
|
||||||
|
import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk"
|
||||||
import {
|
import {
|
||||||
findEmptyMessageByIndex,
|
findEmptyMessageByIndex,
|
||||||
findEmptyMessages,
|
findEmptyMessages,
|
||||||
@ -9,18 +10,30 @@ import {
|
|||||||
injectTextPart,
|
injectTextPart,
|
||||||
replaceEmptyTextParts,
|
replaceEmptyTextParts,
|
||||||
} from "./storage"
|
} 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>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
const PLACEHOLDER_TEXT = "[user interrupted]"
|
const PLACEHOLDER_TEXT = "[user interrupted]"
|
||||||
|
|
||||||
export async function recoverEmptyContentMessage(
|
export async function recoverEmptyContentMessage(
|
||||||
_client: Client,
|
client: Client,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
failedAssistantMsg: MessageData,
|
failedAssistantMsg: MessageData,
|
||||||
_directory: string,
|
_directory: string,
|
||||||
error: unknown
|
error: unknown
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (isSqliteBackend()) {
|
||||||
|
return recoverEmptyContentMessageFromSDK(client, sessionID, failedAssistantMsg, error, {
|
||||||
|
placeholderText: PLACEHOLDER_TEXT,
|
||||||
|
replaceEmptyTextPartsAsync,
|
||||||
|
injectTextPartAsync,
|
||||||
|
findMessagesWithEmptyTextPartsFromSDK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const targetIndex = extractMessageIndex(error)
|
const targetIndex = extractMessageIndex(error)
|
||||||
const failedID = failedAssistantMsg.info?.id
|
const failedID = failedAssistantMsg.info?.id
|
||||||
let anySuccess = false
|
let anySuccess = false
|
||||||
|
|||||||
@ -2,16 +2,23 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
|
|||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
import { extractMessageIndex } from "./detect-error-type"
|
import { extractMessageIndex } from "./detect-error-type"
|
||||||
import { findMessageByIndexNeedingThinking, findMessagesWithOrphanThinking, prependThinkingPart } from "./storage"
|
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>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
export async function recoverThinkingBlockOrder(
|
export async function recoverThinkingBlockOrder(
|
||||||
_client: Client,
|
client: Client,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
_failedAssistantMsg: MessageData,
|
_failedAssistantMsg: MessageData,
|
||||||
_directory: string,
|
_directory: string,
|
||||||
error: unknown
|
error: unknown
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (isSqliteBackend()) {
|
||||||
|
return recoverThinkingBlockOrderFromSDK(client, sessionID, error)
|
||||||
|
}
|
||||||
|
|
||||||
const targetIndex = extractMessageIndex(error)
|
const targetIndex = extractMessageIndex(error)
|
||||||
if (targetIndex !== null) {
|
if (targetIndex !== null) {
|
||||||
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
|
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
|
||||||
@ -34,3 +41,86 @@ export async function recoverThinkingBlockOrder(
|
|||||||
|
|
||||||
return anySuccess
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -1,14 +1,21 @@
|
|||||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
import { findMessagesWithThinkingBlocks, stripThinkingParts } from "./storage"
|
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>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
export async function recoverThinkingDisabledViolation(
|
export async function recoverThinkingDisabledViolation(
|
||||||
_client: Client,
|
client: Client,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
_failedAssistantMsg: MessageData
|
_failedAssistantMsg: MessageData
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (isSqliteBackend()) {
|
||||||
|
return recoverThinkingDisabledViolationFromSDK(client, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
|
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
|
||||||
if (messagesWithThinking.length === 0) {
|
if (messagesWithThinking.length === 0) {
|
||||||
return false
|
return false
|
||||||
@ -23,3 +30,36 @@ export async function recoverThinkingDisabledViolation(
|
|||||||
|
|
||||||
return anySuccess
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export { readParts } from "./storage/parts-reader"
|
|||||||
export { readPartsFromSDK } from "./storage/parts-reader"
|
export { readPartsFromSDK } from "./storage/parts-reader"
|
||||||
export { hasContent, messageHasContent } from "./storage/part-content"
|
export { hasContent, messageHasContent } from "./storage/part-content"
|
||||||
export { injectTextPart } from "./storage/text-part-injector"
|
export { injectTextPart } from "./storage/text-part-injector"
|
||||||
|
export { injectTextPartAsync } from "./storage/text-part-injector"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
findEmptyMessages,
|
findEmptyMessages,
|
||||||
@ -13,6 +14,7 @@ export {
|
|||||||
findFirstEmptyMessage,
|
findFirstEmptyMessage,
|
||||||
} from "./storage/empty-messages"
|
} from "./storage/empty-messages"
|
||||||
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
|
export { findMessagesWithEmptyTextParts } from "./storage/empty-text"
|
||||||
|
export { findMessagesWithEmptyTextPartsFromSDK } from "./storage/empty-text"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
findMessagesWithThinkingBlocks,
|
findMessagesWithThinkingBlocks,
|
||||||
@ -26,3 +28,7 @@ export {
|
|||||||
export { prependThinkingPart } from "./storage/thinking-prepend"
|
export { prependThinkingPart } from "./storage/thinking-prepend"
|
||||||
export { stripThinkingParts } from "./storage/thinking-strip"
|
export { stripThinkingParts } from "./storage/thinking-strip"
|
||||||
export { replaceEmptyTextParts } from "./storage/empty-text"
|
export { replaceEmptyTextParts } from "./storage/empty-text"
|
||||||
|
|
||||||
|
export { prependThinkingPartAsync } from "./storage/thinking-prepend"
|
||||||
|
export { stripThinkingPartsAsync } from "./storage/thinking-strip"
|
||||||
|
export { replaceEmptyTextPartsAsync } from "./storage/empty-text"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user