2f54e760c1
- 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.
245 lines
9.2 KiB
TypeScript
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");
|
|
});
|
|
});
|