fix(recovery): ignore empty summary-only assistant messages

This commit is contained in:
Ravi Tharuma
2026-03-28 08:56:36 +01:00
parent 4a029258a4
commit 4e214cba4e
3 changed files with 61 additions and 9 deletions

View File

@@ -158,7 +158,7 @@ export async function getLastAssistant(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string,
): Promise<Record<string, unknown> | null> {
): Promise<{ info: Record<string, unknown>; hasContent: boolean } | null> {
try {
const resp = await (client as Client).session.messages({
path: { id: sessionID },
@@ -175,7 +175,15 @@ export async function getLastAssistant(
return info?.role === "assistant"
})
if (!last) return null
return (last as { info?: Record<string, unknown> }).info ?? null
const message = last as SDKMessage & { info?: Record<string, unknown> }
const info = message.info
if (!info) return null
return {
info,
hasContent: messageHasContentFromSDK(message),
}
} catch {
return null
}

View File

@@ -6,8 +6,11 @@ import * as originalLogger from "../../shared/logger"
const executeCompactMock = mock(async () => {})
const getLastAssistantMock = mock(async () => ({
info: {
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
},
hasContent: true,
}))
const parseAnthropicTokenLimitErrorMock = mock(() => ({
providerID: "anthropic",
@@ -115,4 +118,43 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
restore()
}
})
test("does not treat empty summary assistant messages as successful compaction", async () => {
//#given
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()
getLastAssistantMock.mockResolvedValueOnce({
info: {
summary: true,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
},
hasContent: false,
})
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const hook = createAnthropicContextWindowLimitRecoveryHook(createMockContext())
try {
//#when
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-empty-summary", error: "prompt is too long" },
},
})
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-empty-summary" },
},
})
//#then
expect(getClearTimeoutCalls()).toEqual([1 as ReturnType<typeof setTimeout>])
expect(executeCompactMock).toHaveBeenCalledTimes(1)
expect(executeCompactMock.mock.calls[0]?.[0]).toBe("session-empty-summary")
} finally {
restore()
}
})
})

View File

@@ -72,8 +72,9 @@ export function createAnthropicContextWindowLimitRecoveryHook(
}
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
const providerID = parsed.providerID ?? (lastAssistant?.providerID as string | undefined)
const modelID = parsed.modelID ?? (lastAssistant?.modelID as string | undefined)
const lastAssistantInfo = lastAssistant?.info
const providerID = parsed.providerID ?? (lastAssistantInfo?.providerID as string | undefined)
const modelID = parsed.modelID ?? (lastAssistantInfo?.modelID as string | undefined)
await ctx.client.tui
.showToast({
@@ -136,14 +137,15 @@ export function createAnthropicContextWindowLimitRecoveryHook(
const errorData = autoCompactState.errorDataBySession.get(sessionID)
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
const lastAssistantInfo = lastAssistant?.info
if (lastAssistant?.summary === true) {
if (lastAssistantInfo?.summary === true && lastAssistant?.hasContent) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
const providerID = errorData?.providerID ?? (lastAssistant?.providerID as string | undefined)
const modelID = errorData?.modelID ?? (lastAssistant?.modelID as string | undefined)
const providerID = errorData?.providerID ?? (lastAssistantInfo?.providerID as string | undefined)
const modelID = errorData?.modelID ?? (lastAssistantInfo?.modelID as string | undefined)
await ctx.client.tui
.showToast({