From 62e4e57455d6cd3ebea54f379809158fd13ea6e0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 15 Feb 2026 15:53:29 +0900 Subject: [PATCH] feat: wire context-window-recovery callers to async SDK/HTTP variants on SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../aggressive-truncation-strategy.ts | 3 +- .../client.ts | 18 +- .../empty-content-recovery-sdk.ts | 185 ++++++++++++++++++ .../empty-content-recovery.ts | 40 ++++ .../executor.test.ts | 4 +- .../message-builder.ts | 101 +++++++++- .../storage.test.ts | 8 +- .../summarize-retry-strategy.ts | 2 +- .../target-token-truncation.ts | 102 +++++++++- 9 files changed, 436 insertions(+), 27 deletions(-) create mode 100644 src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts index 709cb0db..2c159486 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts @@ -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) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/client.ts b/src/hooks/anthropic-context-window-limit-recovery/client.ts index 13bef9ae..c323dafe 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/client.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/client.ts @@ -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 - summarize: (opts: { - path: { id: string } - body: { providerID: string; modelID: string } - query: { directory: string } - }) => Promise - revert: (opts: { - path: { id: string } - body: { messageID: string; partID?: string } - query: { directory: string } - }) => Promise prompt_async: (opts: { path: { id: string } body: { parts: Array<{ type: string; text: string }> } diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts new file mode 100644 index 00000000..a2260a93 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts @@ -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 + const data = record["data"] + return Array.isArray(data) ? (data as SDKMessage[]) : [] +} + +async function findEmptyMessagesFromSDK(client: Client, sessionID: string): Promise { + 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 { + 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 } +} diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts index 140d98aa..f6f407e8 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts @@ -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) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts index aa1fea43..4c2f2d2d 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.test.ts @@ -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, diff --git a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts index cb600ca2..9c47d652 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts @@ -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 { + 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 { + 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 diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts index d5797590..ffe1fabc 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts @@ -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) diff --git a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts index 41db33d0..7c57c841 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts @@ -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({ diff --git a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts index 6e5ea6c2..c743be7f 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts @@ -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 { const { tokensToReduce, targetBytesToRemove } = calculateTargetBytesToRemove( currentTokens, maxTokens, @@ -38,6 +58,82 @@ export function truncateUntilTargetTokens( } } + if (client && isSqliteBackend()) { + let toolPartsByKey = new Map() + try { + const response = (await client.session.messages({ + path: { id: sessionID }, + })) as { data?: SDKMessage[] } + const messages = response.data ?? [] + toolPartsByKey = new Map() + + 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() + } + + 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) {