feat(hooks): add aggressive truncation strategy tests for context-window recovery
This commit is contained in:
+190
@@ -0,0 +1,190 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { AutoCompactState } from "./types"
|
||||||
|
|
||||||
|
type PromptAsyncCall = {
|
||||||
|
path: { id: string }
|
||||||
|
body: {
|
||||||
|
auto?: boolean
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
parts?: unknown
|
||||||
|
}
|
||||||
|
query: { directory: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncateUntilTargetTokensMock = mock(async () => ({
|
||||||
|
truncatedCount: 1,
|
||||||
|
totalBytesRemoved: 1000,
|
||||||
|
truncatedTools: [{ toolName: "bash" }],
|
||||||
|
sufficient: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("./storage", () => ({
|
||||||
|
truncateUntilTargetTokens: truncateUntilTargetTokensMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const findNearestMessageWithFieldsFromSDKMock = mock(async () => null)
|
||||||
|
const findNearestMessageWithFieldsMock = mock(() => null)
|
||||||
|
|
||||||
|
mock.module("../../features/hook-message-injector", () => ({
|
||||||
|
findNearestMessageWithFieldsFromSDK: findNearestMessageWithFieldsFromSDKMock,
|
||||||
|
findNearestMessageWithFields: findNearestMessageWithFieldsMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sessionAgentMap = new Map<string, string>()
|
||||||
|
const resolveRegisteredAgentNameMock = mock((name: string | undefined) => name)
|
||||||
|
|
||||||
|
mock.module("../../features/claude-code-session-state/state", () => ({
|
||||||
|
_resetForTesting: () => { sessionAgentMap.clear() },
|
||||||
|
setSessionAgent: (sessionID: string, agent: string) => { sessionAgentMap.set(sessionID, agent) },
|
||||||
|
getSessionAgent: (sessionID: string) => sessionAgentMap.get(sessionID),
|
||||||
|
resolveRegisteredAgentName: resolveRegisteredAgentNameMock,
|
||||||
|
registerAgentName: () => {},
|
||||||
|
isAgentRegistered: () => false,
|
||||||
|
resolveInheritedPromptTools: () => undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy"
|
||||||
|
|
||||||
|
type FakeClient = {
|
||||||
|
session: { promptAsync: (input: PromptAsyncCall) => Promise<unknown> }
|
||||||
|
tui: { showToast: (input: unknown) => Promise<unknown> }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecordingClient(): { client: FakeClient; calls: PromptAsyncCall[] } {
|
||||||
|
const calls: PromptAsyncCall[] = []
|
||||||
|
const client: FakeClient = {
|
||||||
|
session: {
|
||||||
|
promptAsync: async (input: PromptAsyncCall) => {
|
||||||
|
calls.push(input)
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tui: {
|
||||||
|
showToast: async () => undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { client, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutoCompactState(): AutoCompactState {
|
||||||
|
return {
|
||||||
|
pendingCompact: new Set<string>(),
|
||||||
|
errorDataBySession: new Map(),
|
||||||
|
retryStateBySession: new Map(),
|
||||||
|
retryTimerBySession: new Map(),
|
||||||
|
truncateStateBySession: new Map(),
|
||||||
|
emptyContentAttemptBySession: new Map(),
|
||||||
|
compactionInProgress: new Set<string>(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushDeferredPrompt(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runAggressiveTruncationStrategy - pins agent/model/variant on recovered promptAsync", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionAgentMap.clear()
|
||||||
|
truncateUntilTargetTokensMock.mockClear()
|
||||||
|
findNearestMessageWithFieldsFromSDKMock.mockClear()
|
||||||
|
findNearestMessageWithFieldsMock.mockClear()
|
||||||
|
resolveRegisteredAgentNameMock.mockClear()
|
||||||
|
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue(null)
|
||||||
|
findNearestMessageWithFieldsMock.mockReturnValue(null)
|
||||||
|
resolveRegisteredAgentNameMock.mockImplementation((name: string | undefined) => name)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionAgentMap.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes the session's resolved agent on promptAsync when agent is known", async () => {
|
||||||
|
// given
|
||||||
|
const { client, calls } = createRecordingClient()
|
||||||
|
const sessionID = "session-truncation-agent"
|
||||||
|
sessionAgentMap.set(sessionID, "sisyphus-junior")
|
||||||
|
|
||||||
|
// when
|
||||||
|
await runAggressiveTruncationStrategy({
|
||||||
|
sessionID,
|
||||||
|
autoCompactState: createAutoCompactState(),
|
||||||
|
client: client as never,
|
||||||
|
directory: "/tmp/test-truncation",
|
||||||
|
truncateAttempt: 0,
|
||||||
|
currentTokens: 250_000,
|
||||||
|
maxTokens: 200_000,
|
||||||
|
})
|
||||||
|
await flushDeferredPrompt()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0].path.id).toBe(sessionID)
|
||||||
|
expect(calls[0].body.agent).toBe("sisyphus-junior")
|
||||||
|
expect(calls[0].body.auto).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("pins provider/model/variant resolved from the nearest prior assistant message", async () => {
|
||||||
|
// given
|
||||||
|
const { client, calls } = createRecordingClient()
|
||||||
|
const sessionID = "session-truncation-model"
|
||||||
|
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue({
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" },
|
||||||
|
tools: undefined,
|
||||||
|
} as never)
|
||||||
|
findNearestMessageWithFieldsMock.mockReturnValue({
|
||||||
|
agent: "atlas",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" },
|
||||||
|
tools: undefined,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await runAggressiveTruncationStrategy({
|
||||||
|
sessionID,
|
||||||
|
autoCompactState: createAutoCompactState(),
|
||||||
|
client: client as never,
|
||||||
|
directory: "/tmp/test-truncation",
|
||||||
|
truncateAttempt: 0,
|
||||||
|
currentTokens: 250_000,
|
||||||
|
maxTokens: 200_000,
|
||||||
|
})
|
||||||
|
await flushDeferredPrompt()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0].body.agent).toBe("atlas")
|
||||||
|
expect(calls[0].body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
|
||||||
|
expect(calls[0].body.variant).toBe("high")
|
||||||
|
expect(calls[0].body.auto).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("omits agent/model/variant when the session has nothing resolvable", async () => {
|
||||||
|
// given
|
||||||
|
const { client, calls } = createRecordingClient()
|
||||||
|
const sessionID = "session-truncation-empty"
|
||||||
|
|
||||||
|
// when
|
||||||
|
await runAggressiveTruncationStrategy({
|
||||||
|
sessionID,
|
||||||
|
autoCompactState: createAutoCompactState(),
|
||||||
|
client: client as never,
|
||||||
|
directory: "/tmp/test-truncation",
|
||||||
|
truncateAttempt: 0,
|
||||||
|
currentTokens: 250_000,
|
||||||
|
maxTokens: 200_000,
|
||||||
|
})
|
||||||
|
await flushDeferredPrompt()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0].body.agent).toBeUndefined()
|
||||||
|
expect(calls[0].body.model).toBeUndefined()
|
||||||
|
expect(calls[0].body.variant).toBeUndefined()
|
||||||
|
expect(calls[0].body.auto).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
+29
-2
@@ -5,7 +5,18 @@ import type { Client } from "./client"
|
|||||||
import { clearSessionState } from "./state"
|
import { clearSessionState } from "./state"
|
||||||
import { formatBytes } from "./message-builder"
|
import { formatBytes } from "./message-builder"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { resolveInheritedPromptTools } from "../../shared"
|
import {
|
||||||
|
getMessageDir,
|
||||||
|
resolveInheritedPromptTools,
|
||||||
|
} from "../../shared"
|
||||||
|
import {
|
||||||
|
getSessionAgent,
|
||||||
|
resolveRegisteredAgentName,
|
||||||
|
} from "../../features/claude-code-session-state/state"
|
||||||
|
import {
|
||||||
|
findNearestMessageWithFields,
|
||||||
|
findNearestMessageWithFieldsFromSDK,
|
||||||
|
} from "../../features/hook-message-injector"
|
||||||
|
|
||||||
export async function runAggressiveTruncationStrategy(params: {
|
export async function runAggressiveTruncationStrategy(params: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -62,11 +73,27 @@ export async function runAggressiveTruncationStrategy(params: {
|
|||||||
clearSessionState(params.autoCompactState, params.sessionID)
|
clearSessionState(params.autoCompactState, params.sessionID)
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const inheritedTools = resolveInheritedPromptTools(params.sessionID)
|
const sdkMessage = await findNearestMessageWithFieldsFromSDK(params.client, params.sessionID)
|
||||||
|
const previousMessage = sdkMessage ?? (() => {
|
||||||
|
const messageDir = getMessageDir(params.sessionID)
|
||||||
|
return messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const agentName = getSessionAgent(params.sessionID) ?? previousMessage?.agent
|
||||||
|
const launchAgent = resolveRegisteredAgentName(agentName)
|
||||||
|
const launchModel = previousMessage?.model?.providerID && previousMessage.model.modelID
|
||||||
|
? { providerID: previousMessage.model.providerID, modelID: previousMessage.model.modelID }
|
||||||
|
: undefined
|
||||||
|
const launchVariant = previousMessage?.model?.variant
|
||||||
|
const inheritedTools = resolveInheritedPromptTools(params.sessionID, previousMessage?.tools)
|
||||||
|
|
||||||
await params.client.session.promptAsync({
|
await params.client.session.promptAsync({
|
||||||
path: { id: params.sessionID },
|
path: { id: params.sessionID },
|
||||||
body: {
|
body: {
|
||||||
auto: true,
|
auto: true,
|
||||||
|
...(launchAgent ? { agent: launchAgent } : {}),
|
||||||
|
...(launchModel ? { model: launchModel } : {}),
|
||||||
|
...(launchVariant ? { variant: launchVariant } : {}),
|
||||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||||
} as never,
|
} as never,
|
||||||
query: { directory: params.directory },
|
query: { directory: params.directory },
|
||||||
|
|||||||
Reference in New Issue
Block a user