Files
oh-my-pi/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts
T
roboomp a305e68a53 fix(compaction): compact goal runs between tool turns
Active goal loops can stay inside one agent run while the model keeps
emitting tool calls, so the normal agent_end threshold maintenance never
runs. That lets context grow past the soft threshold until provider
overflow or user abort.

Run threshold maintenance from the per-turn onTurnEnd hook for active
goals, splice the compacted agent state back into the live loop message
array, and suppress queued continuations because the current run is
already continuing. Cover the mid-run tool-call path and the non-goal
control case.

Refs #3174
2026-06-22 20:02:33 +00:00

182 lines
5.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { GoalModeState } from "@oh-my-pi/pi-coding-agent/goals/state";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
function activeGoalState(): GoalModeState {
const now = Date.now();
return {
enabled: true,
mode: "active",
goal: {
id: "goal-midrun-compaction",
objective: "Ship the release",
status: "active",
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: now,
updatedAt: now,
},
};
}
function highUsage(input: number) {
return {
input,
output: 100,
cacheRead: 0,
cacheWrite: 0,
totalTokens: input + 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
describe("AgentSession mid-run goal compaction", () => {
let tempDir: TempDir;
const cleanups: Array<() => Promise<void>> = [];
beforeEach(() => {
tempDir = TempDir.createSync("@pi-agent-goal-midrun-compaction-");
cleanups.length = 0;
});
afterEach(async () => {
for (const cleanup of cleanups) await cleanup();
cleanups.length = 0;
tempDir.removeSync();
vi.restoreAllMocks();
});
async function createHarness(settingsOverride: Record<string, unknown> = {}): Promise<{
session: AgentSession;
observedContexts: string[][];
}> {
const observedContexts: string[][] = [];
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`));
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.strategy": "context-full",
"compaction.autoContinue": true,
"compaction.thresholdTokens": 1000,
"compaction.thresholdPercent": -1,
"todo.enabled": false,
"todo.reminders": false,
...settingsOverride,
});
const sessionManager = SessionManager.inMemory(tempDir.path());
const mockBashTool: AgentTool = {
name: "bash",
label: "Bash",
description: "Mock bash tool",
parameters: type({}),
execute: async () => ({ content: [{ type: "text" as const, text: "tool output" }] }),
};
let call = 0;
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [mockBashTool], messages: [] },
convertToLlm,
streamFn: (_model, context) => {
const index = call++;
observedContexts.push(context.messages.map(message => JSON.stringify(message)));
const stream = new AssistantMessageEventStream();
const isToolTurn = index === 0;
const message = isToolTurn
? {
role: "assistant" as const,
content: [
{ type: "toolCall" as const, id: `tc-${index}`, name: "bash", arguments: { cmd: "ls" } },
],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
usage: highUsage(50_000),
stopReason: "toolUse" as const,
timestamp: Date.now(),
}
: {
role: "assistant" as const,
content: [{ type: "text" as const, text: "All done." }],
api: "anthropic-messages" as const,
provider: "anthropic" as const,
model: "claude-sonnet-4-5",
usage: highUsage(200),
stopReason: "stop" as const,
timestamp: Date.now(),
};
queueMicrotask(() => {
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: message.stopReason, message });
});
return stream;
},
});
const session = new AgentSession({
agent,
sessionManager,
settings,
modelRegistry,
toolRegistry: new Map([[mockBashTool.name, mockBashTool]]),
});
cleanups.push(async () => {
await session.dispose();
authStorage.close();
});
return { session, observedContexts };
}
it("compacts in place between tool-call turns during an active goal run", async () => {
const { session, observedContexts } = await createHarness();
session.setGoalModeState(activeGoalState());
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "MID-RUN-COMPACTED",
shortSummary: undefined,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
await session.prompt("work on the release");
expect(compactSpy).toHaveBeenCalledTimes(1);
expect(observedContexts.length).toBeGreaterThanOrEqual(2);
expect(observedContexts[1].join("\n")).toContain("MID-RUN-COMPACTED");
});
it("does not compact mid-run when no goal is active", async () => {
const { session } = await createHarness();
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "SHOULD-NOT-RUN",
shortSummary: undefined,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
await session.prompt("work on the release");
expect(compactSpy).not.toHaveBeenCalled();
});
});