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.
This commit is contained in:
can1357
2026-08-20 03:45:44 +02:00
parent eced7ab08a
commit 2f54e760c1
26 changed files with 1218 additions and 1043 deletions
@@ -50,6 +50,7 @@ describe("AgentSession auto-compaction progress guard", () => {
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let compactHookEnabled = true;
const NOTICE_SOURCE = "compaction";
const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress";
@@ -61,6 +62,7 @@ describe("AgentSession auto-compaction progress guard", () => {
});
beforeEach(() => {
compactHookEnabled = true;
sessionManager = SessionManager.inMemory();
// The progress-guard tests exercise AgentSession's post-compaction state
@@ -68,7 +70,7 @@ describe("AgentSession auto-compaction progress guard", () => {
// while returning the same short-circuit result without compiling a
// temporary extension for every test.
const extensionRunner = {
hasHandlers: (type: string) => type === "session_before_compact",
hasHandlers: (type: string) => compactHookEnabled && type === "session_before_compact",
emit: async (event: { type: string; preparation?: CompactionPreparation }) => {
if (event.type !== "session_before_compact" || !event.preparation) return undefined;
return {
@@ -858,12 +860,34 @@ describe("AgentSession auto-compaction progress guard", () => {
);
});
it("does not restore a length stop after handoff recovery commits", async () => {
it("drops a length stop and retries after handoff recovery commits", async () => {
session.settings.set("compaction.methodOrder", ["handoff", "soft"]);
session.settings.set("compaction.enabled", true);
session.settings.set("compaction.keepRecentTokens", 1);
compactHookEnabled = false;
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "completed seed" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
});
session.settings.set("contextPromotion.enabled", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined as never);
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
const handoffSpy = vi.spyOn(session, "handoff").mockResolvedValue({ document: "handoff document" });
const generateHandoffSpy = vi
.spyOn(compactionModule, "generateHandoffFromContext")
.mockResolvedValue("handoff document");
const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers<void>();
session.subscribe(event => {
@@ -887,15 +911,20 @@ describe("AgentSession auto-compaction progress guard", () => {
},
timestamp: Date.now(),
};
sessionManager.appendMessage(assistantMsg);
session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg });
session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] });
await compactionDone;
await session.waitForIdle();
expect(promptSpy).toHaveBeenCalledTimes(1);
expect(handoffSpy).toHaveBeenCalledTimes(1);
expect(continueSpy).not.toHaveBeenCalled();
expect(promptSpy).not.toHaveBeenCalled();
expect(generateHandoffSpy).toHaveBeenCalledTimes(1);
expect(continueSpy).toHaveBeenCalledTimes(1);
expect(sessionManager.getBranch().at(-1)).toMatchObject({
type: "compaction",
summary: "handoff document",
});
expect(sessionManager.getBranch()).not.toContainEqual(
expect.objectContaining({
type: "message",