diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts index 2b82051af..2e8772776 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts @@ -1,9 +1,12 @@ -import { describe, test, expect, mock } from "bun:test" +import { describe, test, expect, mock, beforeEach } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import type { ExperimentalConfig } from "../../config" -import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs" -import { join } from "node:path" -import { tmpdir } from "node:os" + +const attemptDeduplicationRecoveryMock = mock(async () => {}) + +mock.module("./deduplication-recovery", () => ({ + attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock, +})) function createImmediateTimeouts(): () => void { const originalSetTimeout = globalThis.setTimeout @@ -22,75 +25,14 @@ function createImmediateTimeouts(): () => void { } } -function writeJson(filePath: string, data: unknown): void { - writeFileSync(filePath, JSON.stringify(data, null, 2)) -} - describe("createAnthropicContextWindowLimitRecoveryHook", () => { - test("attempts deduplication recovery when compaction hits prompt too long errors", async () => { + beforeEach(() => { + attemptDeduplicationRecoveryMock.mockClear() + }) + + test("calls deduplication recovery when compaction is already in progress", async () => { + //#given const restoreTimeouts = createImmediateTimeouts() - const originalDataHome = process.env.XDG_DATA_HOME - const tempHome = mkdtempSync(join(tmpdir(), "omo-context-")) - process.env.XDG_DATA_HOME = tempHome - - const storageRoot = join(tempHome, "opencode", "storage") - const messageDir = join(storageRoot, "message", "session-96") - const partDir = join(storageRoot, "part", "message-1") - const partDirTwo = join(storageRoot, "part", "message-2") - - mkdirSync(messageDir, { recursive: true }) - mkdirSync(partDir, { recursive: true }) - mkdirSync(partDirTwo, { recursive: true }) - - writeJson(join(messageDir, "message-1.json"), { - parts: [ - { - type: "tool", - callID: "call-1", - tool: "read", - state: { input: { filePath: "/tmp/a.txt" } }, - }, - ], - }) - - writeJson(join(messageDir, "message-2.json"), { - parts: [ - { - type: "tool", - callID: "call-2", - tool: "read", - state: { input: { filePath: "/tmp/a.txt" } }, - }, - ], - }) - - writeJson(join(partDir, "part-1.json"), { - id: "part-1", - sessionID: "session-96", - messageID: "message-1", - type: "tool", - callID: "call-1", - tool: "read", - state: { - status: "completed", - input: { filePath: "/tmp/a.txt" }, - output: "duplicate output", - }, - }) - - writeJson(join(partDirTwo, "part-2.json"), { - id: "part-2", - sessionID: "session-96", - messageID: "message-2", - type: "tool", - callID: "call-2", - tool: "read", - state: { - status: "completed", - input: { filePath: "/tmp/a.txt" }, - output: "latest output", - }, - }) const experimental = { dynamic_context_pruning: { @@ -123,7 +65,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { const ctx = { client: mockClient, directory: "/tmp" } as PluginInput const hook = createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental }) - // given - initial token limit error schedules compaction + // first error triggers compaction (setTimeout runs immediately due to mock) await hook.event({ event: { type: "session.error", @@ -131,7 +73,7 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { }, }) - // when - compaction hits another prompt-too-long error + //#when - second error while compaction is in progress await hook.event({ event: { type: "session.error", @@ -139,17 +81,42 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => { }, }) - // then - duplicate tool output is truncated - const prunedPart = JSON.parse( - readFileSync(join(partDir, "part-1.json"), "utf-8"), - ) as { truncated?: boolean } - - expect(prunedPart.truncated).toBe(true) + //#then - deduplication recovery was called for the second error + expect(attemptDeduplicationRecoveryMock).toHaveBeenCalledTimes(1) + expect(attemptDeduplicationRecoveryMock.mock.calls[0]![0]).toBe("session-96") } finally { if (resolveSummarize) resolveSummarize() restoreTimeouts() - process.env.XDG_DATA_HOME = originalDataHome - rmSync(tempHome, { recursive: true, force: true }) } }) + + test("does not call deduplication when compaction is not in progress", async () => { + //#given + const mockClient = { + session: { + messages: mock(() => Promise.resolve({ data: [] })), + summarize: mock(() => Promise.resolve()), + revert: mock(() => Promise.resolve()), + prompt_async: mock(() => Promise.resolve()), + }, + tui: { + showToast: mock(() => Promise.resolve()), + }, + } + + const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook") + const ctx = { client: mockClient, directory: "/tmp" } as PluginInput + const hook = createAnthropicContextWindowLimitRecoveryHook(ctx) + + //#when - single error (no compaction in progress) + await hook.event({ + event: { + type: "session.error", + properties: { sessionID: "session-no-dedup", error: "some other error" }, + }, + }) + + //#then + expect(attemptDeduplicationRecoveryMock).not.toHaveBeenCalled() + }) })