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

- empty-content-recovery: isSqliteBackend() branch delegating to extracted
  empty-content-recovery-sdk.ts with SDK message scanning
- message-builder: sanitizeEmptyMessagesBeforeSummarize now async with SDK path
  using replaceEmptyTextPartsAsync/injectTextPartAsync
- target-token-truncation: truncateUntilTargetTokens now async with SDK path
  using findToolResultsBySizeFromSDK/truncateToolResultAsync
- aggressive-truncation-strategy: passes client to truncateUntilTargetTokens
- summarize-retry-strategy: await sanitizeEmptyMessagesBeforeSummarize
- client.ts: derive Client from PluginInput['client'] instead of manual defs
- executor.test.ts: .mockReturnValue() → .mockResolvedValue() for async fns
- storage.test.ts: add await for async truncateUntilTargetTokens
This commit is contained in:
YeonGyu-Kim 2026-02-15 15:53:29 +09:00
parent dff3a551d8
commit 62e4e57455
9 changed files with 436 additions and 27 deletions

View File

@ -25,12 +25,13 @@ export async function runAggressiveTruncationStrategy(params: {
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
})
const aggressiveResult = truncateUntilTargetTokens(
const aggressiveResult = await truncateUntilTargetTokens(
params.sessionID,
params.currentTokens,
params.maxTokens,
TRUNCATE_CONFIG.targetTokenRatio,
TRUNCATE_CONFIG.charsPerToken,
params.client,
)
if (aggressiveResult.truncatedCount <= 0) {

View File

@ -1,19 +1,7 @@
export type Client = {
import type { PluginInput } from "@opencode-ai/plugin"
export type Client = PluginInput["client"] & {
session: {
messages: (opts: {
path: { id: string }
query?: { directory?: string }
}) => Promise<unknown>
summarize: (opts: {
path: { id: string }
body: { providerID: string; modelID: string }
query: { directory: string }
}) => Promise<unknown>
revert: (opts: {
path: { id: string }
body: { messageID: string; partID?: string }
query: { directory: string }
}) => Promise<unknown>
prompt_async: (opts: {
path: { id: string }
body: { parts: Array<{ type: string; text: string }> }

View File

@ -0,0 +1,185 @@
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
import type { Client } from "./client"
interface SDKPart {
id?: string
type?: string
text?: string
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKPart[]
}
const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
function messageHasContentFromSDK(message: SDKMessage): boolean {
const parts = message.parts
if (!parts || parts.length === 0) return false
for (const part of parts) {
const type = part.type
if (!type) continue
if (IGNORE_TYPES.has(type)) continue
if (type === "text") {
if (part.text?.trim()) return true
continue
}
if (TOOL_TYPES.has(type)) return true
return true
}
return false
}
function getSdkMessages(response: unknown): SDKMessage[] {
if (typeof response !== "object" || response === null) return []
const record = response as Record<string, unknown>
const data = record["data"]
return Array.isArray(data) ? (data as SDKMessage[]) : []
}
async function findEmptyMessagesFromSDK(client: Client, sessionID: string): Promise<string[]> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = getSdkMessages(response)
const emptyIds: string[] = []
for (const message of messages) {
const messageID = message.info?.id
if (!messageID) continue
if (!messageHasContentFromSDK(message)) {
emptyIds.push(messageID)
}
}
return emptyIds
} catch {
return []
}
}
async function findEmptyMessageByIndexFromSDK(
client: Client,
sessionID: string,
targetIndex: number,
): Promise<string | null> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = getSdkMessages(response)
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]
const targetMessageId = targetMessage?.info?.id
if (!targetMessageId) continue
if (!messageHasContentFromSDK(targetMessage)) {
return targetMessageId
}
}
return null
} catch {
return null
}
}
export async function fixEmptyMessagesWithSDK(params: {
sessionID: string
client: Client
placeholderText: string
messageIndex?: number
}): Promise<{ fixed: boolean; fixedMessageIds: string[]; scannedEmptyCount: number }> {
let fixed = false
const fixedMessageIds: string[] = []
if (params.messageIndex !== undefined) {
const targetMessageId = await findEmptyMessageByIndexFromSDK(
params.client,
params.sessionID,
params.messageIndex,
)
if (targetMessageId) {
const replaced = await replaceEmptyTextPartsAsync(
params.client,
params.sessionID,
targetMessageId,
params.placeholderText,
)
if (replaced) {
fixed = true
fixedMessageIds.push(targetMessageId)
} else {
const injected = await injectTextPartAsync(
params.client,
params.sessionID,
targetMessageId,
params.placeholderText,
)
if (injected) {
fixed = true
fixedMessageIds.push(targetMessageId)
}
}
}
}
if (fixed) {
return { fixed, fixedMessageIds, scannedEmptyCount: 0 }
}
const emptyMessageIds = await findEmptyMessagesFromSDK(params.client, params.sessionID)
if (emptyMessageIds.length === 0) {
return { fixed: false, fixedMessageIds: [], scannedEmptyCount: 0 }
}
for (const messageID of emptyMessageIds) {
const replaced = await replaceEmptyTextPartsAsync(
params.client,
params.sessionID,
messageID,
params.placeholderText,
)
if (replaced) {
fixed = true
fixedMessageIds.push(messageID)
} else {
const injected = await injectTextPartAsync(
params.client,
params.sessionID,
messageID,
params.placeholderText,
)
if (injected) {
fixed = true
fixedMessageIds.push(messageID)
}
}
}
return { fixed, fixedMessageIds, scannedEmptyCount: emptyMessageIds.length }
}

View File

@ -4,10 +4,12 @@ import {
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import type { AutoCompactState } from "./types"
import type { Client } from "./client"
import { PLACEHOLDER_TEXT } from "./message-builder"
import { incrementEmptyContentAttempt } from "./state"
import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk"
export async function fixEmptyMessages(params: {
sessionID: string
@ -20,6 +22,44 @@ export async function fixEmptyMessages(params: {
let fixed = false
const fixedMessageIds: string[] = []
if (isSqliteBackend()) {
const result = await fixEmptyMessagesWithSDK({
sessionID: params.sessionID,
client: params.client,
placeholderText: PLACEHOLDER_TEXT,
messageIndex: params.messageIndex,
})
if (!result.fixed && result.scannedEmptyCount === 0) {
await params.client.tui
.showToast({
body: {
title: "Empty Content Error",
message: "No empty messages found in storage. Cannot auto-recover.",
variant: "error",
duration: 5000,
},
})
.catch(() => {})
return false
}
if (result.fixed) {
await params.client.tui
.showToast({
body: {
title: "Session Recovery",
message: `Fixed ${result.fixedMessageIds.length} empty message(s). Retrying...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {})
}
return result.fixed
}
if (params.messageIndex !== undefined) {
const targetMessageId = findEmptyMessageByIndex(params.sessionID, params.messageIndex)
if (targetMessageId) {

View File

@ -313,7 +313,7 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({
success: true,
sufficient: false,
truncatedCount: 3,
@ -354,7 +354,7 @@ describe("executeCompact lock management", () => {
maxTokens: 200000,
})
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockResolvedValue({
success: true,
sufficient: true,
truncatedCount: 5,

View File

@ -1,14 +1,113 @@
import { log } from "../../shared/logger"
import type { PluginInput } from "@opencode-ai/plugin"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import {
findEmptyMessages,
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
import type { Client } from "./client"
export const PLACEHOLDER_TEXT = "[user interrupted]"
export function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
type OpencodeClient = PluginInput["client"]
interface SDKPart {
type?: string
text?: string
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKPart[]
}
const IGNORE_TYPES = new Set(["thinking", "redacted_thinking", "meta"])
const TOOL_TYPES = new Set(["tool", "tool_use", "tool_result"])
function messageHasContentFromSDK(message: SDKMessage): boolean {
const parts = message.parts
if (!parts || parts.length === 0) return false
for (const part of parts) {
const type = part.type
if (!type) continue
if (IGNORE_TYPES.has(type)) continue
if (type === "text") {
if (part.text?.trim()) return true
continue
}
if (TOOL_TYPES.has(type)) return true
return true
}
return false
}
async function findEmptyMessageIdsFromSDK(
client: OpencodeClient,
sessionID: string,
): Promise<string[]> {
try {
const response = (await client.session.messages({
path: { id: sessionID },
})) as { data?: SDKMessage[] }
const messages = response.data ?? []
const emptyIds: string[] = []
for (const message of messages) {
const messageID = message.info?.id
if (!messageID) continue
if (!messageHasContentFromSDK(message)) {
emptyIds.push(messageID)
}
}
return emptyIds
} catch {
return []
}
}
export async function sanitizeEmptyMessagesBeforeSummarize(
sessionID: string,
client?: OpencodeClient,
): Promise<number> {
if (client && isSqliteBackend()) {
const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID)
if (emptyMessageIds.length === 0) {
return 0
}
let fixedCount = 0
for (const messageID of emptyMessageIds) {
const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
if (replaced) {
fixedCount++
} else {
const injected = await injectTextPartAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
if (injected) {
fixedCount++
}
}
}
if (fixedCount > 0) {
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
sessionID,
fixedCount,
totalEmpty: emptyMessageIds.length,
})
}
return fixedCount
}
const emptyMessageIds = findEmptyMessages(sessionID)
if (emptyMessageIds.length === 0) {
return 0

View File

@ -21,7 +21,7 @@ describe("truncateUntilTargetTokens", () => {
truncateToolResult.mockReset()
})
test("truncates only until target is reached", () => {
test("truncates only until target is reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// given: Two tool results, each 1000 chars. Target reduction is 500 chars.
@ -39,7 +39,7 @@ describe("truncateUntilTargetTokens", () => {
// when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// charsPerToken=1 for simplicity in test
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// then: Should only truncate the first tool
expect(result.truncatedCount).toBe(1)
@ -49,7 +49,7 @@ describe("truncateUntilTargetTokens", () => {
expect(result.sufficient).toBe(true)
})
test("truncates all if target not reached", () => {
test("truncates all if target not reached", async () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// given: Two tool results, each 100 chars. Target reduction is 500 chars.
@ -66,7 +66,7 @@ describe("truncateUntilTargetTokens", () => {
}))
// when: reduce 500 chars
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
const result = await truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// then: Should truncate both
expect(result.truncatedCount).toBe(2)

View File

@ -61,7 +61,7 @@ export async function runSummarizeRetryStrategy(params: {
if (providerID && modelID) {
try {
sanitizeEmptyMessagesBeforeSummarize(params.sessionID)
await sanitizeEmptyMessagesBeforeSummarize(params.sessionID, params.client)
await params.client.tui
.showToast({

View File

@ -1,5 +1,24 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { AggressiveTruncateResult } from "./tool-part-types"
import { findToolResultsBySize, truncateToolResult } from "./tool-result-storage"
import { findToolResultsBySizeFromSDK, truncateToolResultAsync } from "./tool-result-storage-sdk"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
type OpencodeClient = PluginInput["client"]
interface SDKToolPart {
id: string
type: string
tool?: string
state?: { output?: string }
truncated?: boolean
originalSize?: number
}
interface SDKMessage {
info?: { id?: string }
parts?: SDKToolPart[]
}
function calculateTargetBytesToRemove(
currentTokens: number,
@ -13,13 +32,14 @@ function calculateTargetBytesToRemove(
return { tokensToReduce, targetBytesToRemove }
}
export function truncateUntilTargetTokens(
export async function truncateUntilTargetTokens(
sessionID: string,
currentTokens: number,
maxTokens: number,
targetRatio: number = 0.8,
charsPerToken: number = 4
): AggressiveTruncateResult {
charsPerToken: number = 4,
client?: OpencodeClient
): Promise<AggressiveTruncateResult> {
const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove(
currentTokens,
maxTokens,
@ -38,6 +58,82 @@ export function truncateUntilTargetTokens(
}
}
if (client && isSqliteBackend()) {
let toolPartsByKey = new Map<string, SDKToolPart>()
try {
const response = (await client.session.messages({
path: { id: sessionID },
})) as { data?: SDKMessage[] }
const messages = response.data ?? []
toolPartsByKey = new Map<string, SDKToolPart>()
for (const message of messages) {
const messageID = message.info?.id
if (!messageID || !message.parts) continue
for (const part of message.parts) {
if (part.type !== "tool") continue
toolPartsByKey.set(`${messageID}:${part.id}`, part)
}
}
} catch {
toolPartsByKey = new Map<string, SDKToolPart>()
}
const results = await findToolResultsBySizeFromSDK(client, sessionID)
if (results.length === 0) {
return {
success: false,
sufficient: false,
truncatedCount: 0,
totalBytesRemoved: 0,
targetBytesToRemove,
truncatedTools: [],
}
}
let totalRemoved = 0
let truncatedCount = 0
const truncatedTools: Array<{ toolName: string; originalSize: number }> = []
for (const result of results) {
const part = toolPartsByKey.get(`${result.messageID}:${result.partId}`)
if (!part) continue
const truncateResult = await truncateToolResultAsync(
client,
sessionID,
result.messageID,
result.partId,
part
)
if (truncateResult.success) {
truncatedCount++
const removedSize = truncateResult.originalSize ?? result.outputSize
totalRemoved += removedSize
truncatedTools.push({
toolName: truncateResult.toolName ?? result.toolName,
originalSize: removedSize,
})
if (totalRemoved >= targetBytesToRemove) {
break
}
}
}
const sufficient = totalRemoved >= targetBytesToRemove
return {
success: truncatedCount > 0,
sufficient,
truncatedCount,
totalBytesRemoved: totalRemoved,
targetBytesToRemove,
truncatedTools,
}
}
const results = findToolResultsBySize(sessionID)
if (results.length === 0) {