Files
oh-my-pi/packages/coding-agent/test/compaction-speculation.test.ts
T
can1357 2f54e760c1 feat(coding-agent): added async compaction and in-place handoff
- Added compaction.asyncEnabled (Async Compaction, default on): when
  context enters the pre-threshold band [threshold - lead, threshold)
  with lead = clamp(threshold * 0.125, 8192, 32000), maintenance
  speculatively summarizes in the background off a branch snapshot
  (first configured LLM-backed method: remote, handoff, or soft) using
  a side session id isolated from the live turn. Crossing the threshold
  splices the armed result in instantly instead of blocking on a
  summarization round-trip. Armed results are invalidated by branch
  changes, reset boundaries, model switches that strand provider-native
  replay payloads, and context growth past keepRecentTokens (which
  re-speculates); extensions registering session_before_compact keep
  exact blocking semantics (speculation disabled).
- Reworked handoff to commit in place: /handoff and the auto handoff
  method now write the generated document as a regular compaction entry
  on the current session (summary = document + <files> tag, cut from
  prepareCompaction) instead of starting a new session. SessionHandoff
  shrank to a document generator; session_before_switch/session_switch
  no longer fire with reason "handoff"; mid-turn maintenance no longer
  suppresses the handoff preference; overflow recovery can apply an
  armed handoff result.
- Extracted the shared auto-compaction commit tail
  (#commitAutoCompactionResult / #commitCompactionEntry) used by the
  blocking production path, the armed speculative apply, manual
  compaction, and manual handoff.
- Status line pulses the auto-compact icon while a speculation runs and
  holds it in accent once a result is armed.
- Exported remotePreserveReusable from pi-agent-core/compaction for
  apply-time validation of speculative remote results.
2026-08-20 03:46:34 +02:00

245 lines
9.2 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage, Model, UserMessage } from "@oh-my-pi/pi-ai";
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 { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionMaintenance, type SessionMaintenanceHost } from "@oh-my-pi/pi-coding-agent/session/session-maintenance";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
const CONTEXT_WINDOW = 100_000;
const THRESHOLD = 50_000;
const SPECULATION_BAND_START = THRESHOLD - 8_192;
function userMessage(text: string): UserMessage {
return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() };
}
function assistantMessage(text: string, model: Model): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: model.api,
provider: model.provider,
model: model.id,
stopReason: "stop",
usage: {
input: 10_000,
output: 100,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 10_100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
};
}
describe("async speculative compaction", () => {
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let model: Model;
let sessionManager: SessionManager;
let maintenance: SessionMaintenance;
let events: string[];
function appendSummarizableConversation(): void {
const text = "conversation ".repeat(8_000);
sessionManager.appendMessage(userMessage(text));
sessionManager.appendMessage(assistantMessage("response ".repeat(8_000), model));
sessionManager.appendMessage(userMessage(text));
sessionManager.appendMessage(assistantMessage("final response", model));
}
function createMaintenance(asyncEnabled = true): SessionMaintenance {
const agent = new Agent({
initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
});
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.asyncEnabled": asyncEnabled,
"compaction.methodOrder": ["soft"],
"compaction.thresholdPercent": 50,
"compaction.keepRecentTokens": 1,
"compaction.autoContinue": false,
});
const host = {
agent,
sessionManager,
settings,
modelRegistry,
extensionRunner: undefined,
sideStreamFn: async () => {
throw new Error("The compact seam should be used instead of the side stream");
},
providerSessionState: new Map(),
preferWebsockets: undefined,
model: () => model,
thinkingLevel: () => undefined,
isDisposed: () => false,
isStreaming: () => false,
isGeneratingHandoff: () => false,
promptGeneration: () => 0,
sessionId: () => sessionManager.getSessionId(),
messages: () => agent.state.messages,
baseSystemPrompt: () => ["Test"],
goalModeState: () => undefined,
planReferencePath: () => "",
nonMessageTokenSource: () => ({}),
memoryBackendSession: () => undefined,
emitSessionEvent: async (event: { type: string }) => {
events.push(event.type);
},
emitNotice: () => {},
schedulePostPromptTask: () => {},
scheduleAgentContinue: () => {},
scheduleCompactionContinuation: () => false,
persistTurnMessagesForMidRunCompaction: async () => false,
findLastAssistantMessage: () => undefined,
disconnectFromAgent: () => {},
reconnectToAgent: () => {},
drainStrandedQueuedMessages: () => {},
buildDisplaySessionContext: () => ({ messages: [] }),
convertToLlmForSideRequest: (messages: AgentMessage[]) => messages as never,
obfuscateTextForProvider: (text: string | undefined) => text,
obfuscatePreparationForProvider: <T>(preparation: T) => preparation,
closeCodexProviderSessionsForHistoryRewrite: () => {},
resetCodexProviderAfterCompaction: () => {},
resetPlanReference: () => {},
syncTodoPhasesFromBranch: () => {},
resetAdvisorRuntimes: () => {},
rebaseAfterCompaction: () => {},
recordAnchoredHistoryRewrite: () => {},
getContextBreakdown: () => undefined,
getContextUsage: () => undefined,
shake: async () => ({ modified: false, tokensRemoved: 0 }),
dropImages: async () => ({ removed: 0 }),
generateHandoffDocument: async () => undefined,
removeAssistantMessageFromActiveContext: () => {},
dropPersistedAssistantTurn: async () => undefined,
runRecoveryCompactionWithRollback: async () => ({ deferredHandoff: false, continuationScheduled: false }),
parseRetryAfterMsFromError: () => undefined,
setModelTemporary: async () => {},
abort: async () => {},
abortHandoff: () => {},
} as unknown as SessionMaintenanceHost;
return new SessionMaintenance(host);
}
async function waitForState(state: "idle" | "running" | "armed"): Promise<void> {
for (let microtask = 0; microtask < 100 && maintenance.speculationState !== state; microtask++) {
await Promise.resolve();
}
if (maintenance.speculationState !== state) {
throw new Error(`Speculation did not become ${state}`);
}
}
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!bundled) throw new Error("Expected built-in model");
model = { ...bundled, contextWindow: CONTEXT_WINDOW };
});
beforeEach(() => {
sessionManager = SessionManager.inMemory();
events = [];
appendSummarizableConversation();
maintenance = createMaintenance();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
});
it("does not call the summarizer below the speculative band, then arms inside it", async () => {
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "speculative summary",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START - 1, CONTEXT_WINDOW);
expect(maintenance.speculationState).toBe("idle");
expect(compactSpy).not.toHaveBeenCalled();
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
expect(maintenance.speculationState).toBe("running");
await waitForState("armed");
expect(compactSpy).toHaveBeenCalledTimes(1);
});
it("commits an armed summary at threshold without paying for another summarizer call", async () => {
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "armed summary",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
await waitForState("armed");
await maintenance.runAutoCompaction("threshold", false, false, false, { triggerContextTokens: THRESHOLD });
const entry = sessionManager.getEntries().findLast(item => item.type === "compaction");
expect(entry?.type === "compaction" ? entry.summary : undefined).toBe("armed summary");
expect(compactSpy).toHaveBeenCalledTimes(1);
expect(events).toEqual(expect.arrayContaining(["auto_compaction_start", "auto_compaction_end"]));
});
it("discards an armed summary after a reset boundary and re-summarizes the new branch", async () => {
let invocation = 0;
const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: `summary ${++invocation}`,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
await waitForState("armed");
sessionManager.appendResetBoundary();
appendSummarizableConversation();
await maintenance.runAutoCompaction("threshold", false, false, false, { triggerContextTokens: THRESHOLD });
expect(compactSpy).toHaveBeenCalledTimes(2);
const entry = sessionManager.getEntries().findLast(item => item.type === "compaction");
expect(entry?.type === "compaction" ? entry.summary : undefined).toBe("summary 2");
});
it("does not start speculative work when async compaction is disabled", () => {
maintenance = createMaintenance(false);
const compactSpy = vi.spyOn(compactionModule, "compact");
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
expect(maintenance.speculationState).toBe("idle");
expect(compactSpy).not.toHaveBeenCalled();
});
it("clears an armed speculation when manual compaction starts", async () => {
vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({
summary: "manual summary",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: {},
}));
maintenance.maybeStartSpeculativeCompaction(SPECULATION_BAND_START, CONTEXT_WINDOW);
await waitForState("armed");
await maintenance.compact();
expect(maintenance.speculationState).toBe("idle");
});
});