fix(anthropic-auto-compact): handle empty messages at arbitrary indices

- Add messageIndex field to ParsedTokenLimitError type for tracking message position
- Extract message index from 'messages.N' format in error messages using regex
- Update fixEmptyMessages to accept optional messageIndex parameter
- Target specific empty message by index instead of fixing all empty messages
- Apply replaceEmptyTextParts before injectTextPart for better coverage
- Remove experimental flag requirement - non-empty content errors now auto-recover by default
- Fixes issue where compaction could create empty messages at positions other than the last message

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-20 14:47:42 +09:00
parent 4f24423e44
commit 8406f3d6d7
3 changed files with 76 additions and 22 deletions

View File

@@ -2,7 +2,12 @@ import type { AutoCompactState, FallbackState, RetryState, TruncateState } from
import type { ExperimentalConfig } from "../../config" import type { ExperimentalConfig } from "../../config"
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types" import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage" import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
import { findEmptyMessages, injectTextPart } from "../session-recovery/storage" import {
findEmptyMessages,
findEmptyMessageByIndex,
injectTextPart,
replaceEmptyTextParts,
} from "../session-recovery/storage"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
type Client = { type Client = {
@@ -168,30 +173,61 @@ function getOrCreateEmptyContentAttempt(
async function fixEmptyMessages( async function fixEmptyMessages(
sessionID: string, sessionID: string,
autoCompactState: AutoCompactState, autoCompactState: AutoCompactState,
client: Client client: Client,
messageIndex?: number
): Promise<boolean> { ): Promise<boolean> {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID) const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1) autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
const emptyMessageIds = findEmptyMessages(sessionID) let fixed = false
if (emptyMessageIds.length === 0) { const fixedMessageIds: string[] = []
await client.tui
.showToast({ if (messageIndex !== undefined) {
body: { const targetMessageId = findEmptyMessageByIndex(sessionID, messageIndex)
title: "Empty Content Error", if (targetMessageId) {
message: "No empty messages found in storage. Cannot auto-recover.", const replaced = replaceEmptyTextParts(targetMessageId, "[user interrupted]")
variant: "error", if (replaced) {
duration: 5000, fixed = true
}, fixedMessageIds.push(targetMessageId)
}) } else {
.catch(() => {}) const injected = injectTextPart(sessionID, targetMessageId, "[user interrupted]")
return false if (injected) {
fixed = true
fixedMessageIds.push(targetMessageId)
}
}
}
} }
let fixed = false if (!fixed) {
for (const messageID of emptyMessageIds) { const emptyMessageIds = findEmptyMessages(sessionID)
const success = injectTextPart(sessionID, messageID, "[user interrupted]") if (emptyMessageIds.length === 0) {
if (success) fixed = true await 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
}
for (const messageID of emptyMessageIds) {
const replaced = replaceEmptyTextParts(messageID, "[user interrupted]")
if (replaced) {
fixed = true
fixedMessageIds.push(messageID)
} else {
const injected = injectTextPart(sessionID, messageID, "[user interrupted]")
if (injected) {
fixed = true
fixedMessageIds.push(messageID)
}
}
}
} }
if (fixed) { if (fixed) {
@@ -199,7 +235,7 @@ async function fixEmptyMessages(
.showToast({ .showToast({
body: { body: {
title: "Session Recovery", title: "Session Recovery",
message: `Fixed ${emptyMessageIds.length} empty messages. Retrying...`, message: `Fixed ${fixedMessageIds.length} empty message(s). Retrying...`,
variant: "warning", variant: "warning",
duration: 3000, duration: 3000,
}, },
@@ -361,10 +397,15 @@ export async function executeCompact(
const retryState = getOrCreateRetryState(autoCompactState, sessionID) const retryState = getOrCreateRetryState(autoCompactState, sessionID)
if (experimental?.empty_message_recovery && errorData?.errorType?.includes("non-empty content")) { if (errorData?.errorType?.includes("non-empty content")) {
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID) const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
if (attempt < 3) { if (attempt < 3) {
const fixed = await fixEmptyMessages(sessionID, autoCompactState, client as Client) const fixed = await fixEmptyMessages(
sessionID,
autoCompactState,
client as Client,
errorData.messageIndex
)
if (fixed) { if (fixed) {
autoCompactState.compactionInProgress.delete(sessionID) autoCompactState.compactionInProgress.delete(sessionID)
setTimeout(() => { setTimeout(() => {

View File

@@ -28,6 +28,8 @@ const TOKEN_LIMIT_KEYWORDS = [
"non-empty content", "non-empty content",
] ]
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
function extractTokensFromMessage(message: string): { current: number; max: number } | null { function extractTokensFromMessage(message: string): { current: number; max: number } | null {
for (const pattern of TOKEN_LIMIT_PATTERNS) { for (const pattern of TOKEN_LIMIT_PATTERNS) {
const match = message.match(pattern) const match = message.match(pattern)
@@ -40,6 +42,14 @@ function extractTokensFromMessage(message: string): { current: number; max: numb
return null return null
} }
function extractMessageIndex(text: string): number | undefined {
const match = text.match(MESSAGE_INDEX_PATTERN)
if (match) {
return parseInt(match[1], 10)
}
return undefined
}
function isTokenLimitError(text: string): boolean { function isTokenLimitError(text: string): boolean {
const lower = text.toLowerCase() const lower = text.toLowerCase()
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase())) return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
@@ -52,6 +62,7 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
currentTokens: 0, currentTokens: 0,
maxTokens: 0, maxTokens: 0,
errorType: "non-empty content", errorType: "non-empty content",
messageIndex: extractMessageIndex(err),
} }
} }
if (isTokenLimitError(err)) { if (isTokenLimitError(err)) {
@@ -155,6 +166,7 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
currentTokens: 0, currentTokens: 0,
maxTokens: 0, maxTokens: 0,
errorType: "non-empty content", errorType: "non-empty content",
messageIndex: extractMessageIndex(combinedText),
} }
} }

View File

@@ -5,6 +5,7 @@ export interface ParsedTokenLimitError {
errorType: string errorType: string
providerID?: string providerID?: string
modelID?: string modelID?: string
messageIndex?: number
} }
export interface RetryState { export interface RetryState {