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
182 lines
5.9 KiB
TypeScript
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();
|
|
});
|
|
});
|