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:
parent
dff3a551d8
commit
62e4e57455
@ -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) {
|
||||
|
||||
@ -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 }> }
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user