fix: cover overflow compaction recovery path

This commit is contained in:
Josh Lehman
2026-03-09 14:45:28 -07:00
parent 3089e03529
commit 32eefd17ca
6 changed files with 145 additions and 17 deletions

View File

@@ -9,16 +9,18 @@ export function makeOverflowError(message: string = DEFAULT_OVERFLOW_ERROR_MESSA
export function makeCompactionSuccess(params: {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
}) {
return {
ok: true as const,
compacted: true as const,
result: {
summary: params.summary,
firstKeptEntryId: params.firstKeptEntryId,
tokensBefore: params.tokensBefore,
...(params.firstKeptEntryId ? { firstKeptEntryId: params.firstKeptEntryId } : {}),
...(params.tokensBefore !== undefined ? { tokensBefore: params.tokensBefore } : {}),
...(params.tokensAfter !== undefined ? { tokensAfter: params.tokensAfter } : {}),
},
};
}
@@ -55,8 +57,9 @@ type MockCompactDirect = {
compacted: true;
result: {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
firstKeptEntryId?: string;
tokensBefore?: number;
tokensAfter?: number;
};
}) => unknown;
};

View File

@@ -2,9 +2,13 @@ import "./run.overflow-compaction.mocks.shared.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isCompactionFailureError, isLikelyContextOverflowError } from "../pi-embedded-helpers.js";
vi.mock("../../utils.js", () => ({
resolveUserPath: vi.fn((p: string) => p),
}));
vi.mock(import("../../utils.js"), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
resolveUserPath: vi.fn((p: string) => p),
};
});
import { log } from "./logger.js";
import { runEmbeddedPiAgent } from "./run.js";
@@ -16,6 +20,7 @@ import {
queueOverflowAttemptWithOversizedToolOutput,
} from "./run.overflow-compaction.fixture.js";
import {
mockedContextEngine,
mockedCompactDirect,
mockedRunEmbeddedAttempt,
mockedSessionLikelyHasOversizedToolResults,
@@ -30,6 +35,11 @@ const mockedIsLikelyContextOverflowError = vi.mocked(isLikelyContextOverflowErro
describe("overflow compaction in run loop", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedRunEmbeddedAttempt.mockReset();
mockedCompactDirect.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedTruncateOversizedToolResultsInSession.mockReset();
mockedContextEngine.info.ownsCompaction = false;
mockedIsCompactionFailureError.mockImplementation((msg?: string) => {
if (!msg) {
return false;
@@ -72,7 +82,9 @@ describe("overflow compaction in run loop", () => {
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedCompactDirect).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "test-profile" }),
expect.objectContaining({
runtimeContext: expect.objectContaining({ authProfileId: "test-profile" }),
}),
);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(log.warn).toHaveBeenCalledWith(

View File

@@ -26,12 +26,35 @@ export const mockedGlobalHookRunner = {
_ctx: PluginHookAgentContext,
): Promise<PluginHookBeforeModelResolveResult | undefined> => undefined,
),
runBeforeCompaction: vi.fn(async () => undefined),
runAfterCompaction: vi.fn(async () => undefined),
};
export const mockedContextEngine = {
info: { ownsCompaction: false },
compact: vi.fn(async () => ({
ok: false as const,
compacted: false as const,
reason: "nothing to compact",
})),
};
export const mockedContextEngineCompact = mockedContextEngine.compact;
export const mockedEnsureRuntimePluginsLoaded = vi.fn();
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner),
}));
vi.mock("../../context-engine/index.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
resolveContextEngine: vi.fn(async () => mockedContextEngine),
}));
vi.mock("../runtime-plugins.js", () => ({
ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded,
}));
vi.mock("../auth-profiles.js", () => ({
isProfileInCooldown: vi.fn(() => false),
markAuthProfileFailure: vi.fn(async () => {}),
@@ -141,9 +164,13 @@ vi.mock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
}));
vi.mock("../../utils/message-channel.js", () => ({
isMarkdownCapableMessageChannel: vi.fn(() => true),
}));
vi.mock(import("../../utils/message-channel.js"), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
isMarkdownCapableMessageChannel: vi.fn(() => true),
};
});
vi.mock("../agent-paths.js", () => ({
resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"),

View File

@@ -1,5 +1,8 @@
import { vi } from "vitest";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import {
mockedContextEngine,
mockedContextEngineCompact,
} from "./run.overflow-compaction.mocks.shared.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
import {
sessionLikelyHasOversizedToolResults,
@@ -7,13 +10,14 @@ import {
} from "./tool-result-truncation.js";
export const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt);
export const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect);
export const mockedCompactDirect = mockedContextEngineCompact;
export const mockedSessionLikelyHasOversizedToolResults = vi.mocked(
sessionLikelyHasOversizedToolResults,
);
export const mockedTruncateOversizedToolResultsInSession = vi.mocked(
truncateOversizedToolResultsInSession,
);
export { mockedContextEngine };
export const overflowBaseRunParams = {
sessionId: "test-session",

View File

@@ -11,6 +11,7 @@ import {
} from "./run.overflow-compaction.fixture.js";
import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js";
import {
mockedContextEngine,
mockedCompactDirect,
mockedRunEmbeddedAttempt,
mockedSessionLikelyHasOversizedToolResults,
@@ -22,6 +23,25 @@ const mockedPickFallbackThinkingLevel = vi.mocked(pickFallbackThinkingLevel);
describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedRunEmbeddedAttempt.mockReset();
mockedCompactDirect.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedTruncateOversizedToolResultsInSession.mockReset();
mockedGlobalHookRunner.runBeforeAgentStart.mockReset();
mockedGlobalHookRunner.runBeforeCompaction.mockReset();
mockedGlobalHookRunner.runAfterCompaction.mockReset();
mockedContextEngine.info.ownsCompaction = false;
mockedCompactDirect.mockResolvedValue({
ok: false,
compacted: false,
reason: "nothing to compact",
});
mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false);
mockedTruncateOversizedToolResultsInSession.mockResolvedValue({
truncated: false,
truncatedCount: 0,
reason: "no oversized tool results",
});
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
});
@@ -81,8 +101,12 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedCompactDirect).toHaveBeenCalledWith(
expect.objectContaining({
trigger: "overflow",
authProfileId: "test-profile",
sessionId: "test-session",
sessionFile: "/tmp/session.json",
runtimeContext: expect.objectContaining({
trigger: "overflow",
authProfileId: "test-profile",
}),
}),
);
});
@@ -132,6 +156,63 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(result.meta.error?.kind).toBe("context_overflow");
});
it("fires compaction hooks during overflow recovery for ownsCompaction engines", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockedGlobalHookRunner.hasHooks.mockImplementation(
(hookName) => hookName === "before_compaction" || hookName === "after_compaction",
);
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() }))
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedCompactDirect.mockResolvedValueOnce({
ok: true,
compacted: true,
result: {
summary: "engine-owned compaction",
tokensAfter: 50,
},
});
await runEmbeddedPiAgent(overflowBaseRunParams);
expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledWith(
{ messageCount: -1, sessionFile: "/tmp/session.json" },
expect.objectContaining({
sessionKey: "test-key",
}),
);
expect(mockedGlobalHookRunner.runAfterCompaction).toHaveBeenCalledWith(
{
messageCount: -1,
compactedCount: -1,
tokenCount: 50,
sessionFile: "/tmp/session.json",
},
expect.objectContaining({
sessionKey: "test-key",
}),
);
});
it("guards thrown engine-owned overflow compaction attempts", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockedGlobalHookRunner.hasHooks.mockImplementation(
(hookName) => hookName === "before_compaction" || hookName === "after_compaction",
);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({ promptError: makeOverflowError() }),
);
mockedCompactDirect.mockRejectedValueOnce(new Error("engine boom"));
const result = await runEmbeddedPiAgent(overflowBaseRunParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledTimes(1);
expect(mockedGlobalHookRunner.runAfterCompaction).not.toHaveBeenCalled();
expect(result.meta.error?.kind).toBe("context_overflow");
expect(result.payloads?.[0]?.isError).toBe(true);
});
it("returns retry_limit when repeated retries never converge", async () => {
mockedRunEmbeddedAttempt.mockClear();
mockedCompactDirect.mockClear();