From b3e2565317f78bb541310d5641022c52fbc037d6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 11:48:15 -0700 Subject: [PATCH 1/6] feat(tui): add /handoff to fold context and start the next turn Compact stops after the fold; handoff pivots, so the optional trailing instructions arm the shared operator fold and the same submit delivers the pivot as the next inbound turn. --- docs/PRODUCT.md | 2 +- src/agent/compaction.test.ts | 116 +++++++++++++++++++++ src/agent/compaction.ts | 40 +++++++- src/agent/director.ts | 18 ++++ src/session/summarizer.ts | 7 +- src/tui/commands/built-in.test.ts | 59 +++++++++++ src/tui/commands/built-in.ts | 21 ++++ src/tui/commands/registry.ts | 7 ++ src/tui/runner/commands.ts | 41 ++++++++ src/tui/runner/handoff.test.ts | 161 ++++++++++++++++++++++++++++++ 10 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 src/tui/runner/handoff.test.ts diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 35c114dce..e22a39d6b 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -105,7 +105,7 @@ the file path and parse details. ## Slash Commands (TUI) -The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/compact` (fold conversation context now, optional trailing instructions to the summarizer; does not wait for the 60% occupancy governor; idle success shows the fold and does not start a new turn), `/mcp` (enable, disable, or remove servers), and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands. +The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/compact` (fold conversation context now, optional trailing instructions to the summarizer; does not wait for the 60% occupancy governor; idle success shows the fold and does not start a new turn), `/mcp` (enable, disable, or remove servers), `/handoff [optional instructions]` (folds context through the shared operator pipeline, then immediately starts the next turn with the instructions as the inbound content — default copy when omitted; unlike `/compact`, which stops after the fold, handoff always re-infers, so the operator can pivot goals without `/clear`; a handoff issued mid-tool-batch queues behind the in-flight batch and whichever boundary fires first runs the single fold), and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands. **Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`, `/lexicon`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` classifies the target first, then dispatches a selected fleet; `/pull-request-review` is worktree checkout plus a surface pass, loading `/review` for quality rules only; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop — it does not steal planning from `/plan`. Substantial Builder work consumes a counsel / `/plan` plan first; tiny parent-DIY stays plan-optional. `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). `/lexicon` owns director-prompt drift and size against the agents repo at a pinned commit. There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `native-integration`, `typescript`, `ponytail`, and `opsh` stay `use_skill` only (`user-invocable: false`). Bake-only bars such as `idiot-proof` and `native-runtime` are not slashes and are not listed for `use_skill`. Draper and emil are not slashes; they remain closed directors via `spawn_agent(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `skill_search` (descriptions) then `use_skill` (body). Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 1e4b6c637..517b017b1 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -1317,3 +1317,119 @@ describe("provider-aware idle recompress (CL-8745)", () => { expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer"); }); }); + +describe("handoff arming (/handoff)", () => { + const pivot = (content: string): ReactorInboundEvent => + ({ + type: "message.received", + message: { content }, + }) as ReactorInboundEvent; + + test("requestHandoff arms the operator fold and keeps the pivot instructions", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + expect(governor.extraInstructions).toBe("now do the UI audit"); + }); + + test("blank instructions still arm; the fold uses the default structured summary", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff(" ")).toBe("armed"); + expect(governor.extraInstructions).toBeUndefined(); + }); + + test("requestHandoff noops at or below the fold floor and arms nothing", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(threeTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("noop"); + expect(governor.extraInstructions).toBeUndefined(); + expect( + governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ), + ).toBeNull(); + }); + + test("a tool-batch pause runs the single operator fold, then continues", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + + const actions = governor.interceptActions( + toolDone(), + inferAction, + capabilities, + ); + expect(actions).not.toBeNull(); + expect(actions?.find((a) => a.type === "compact")).toMatchObject({ + compactor: "pruning-compactor", + reason: OPERATOR_COMPACT_REASON, + }); + expect(actions?.some((a) => a.type === "infer")).toBe(false); + expect( + actions?.some( + (a) => + a.type === "emit" && a.eventType === COMPACTION_CONTINUATION_EVENT, + ), + ).toBe(true); + // Firing clears the arming: the pivot arrival must not fold twice. + expect( + governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ), + ).toBeNull(); + }); + + test("the pivot arrival folds with the operator reason, then re-infers", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + + const actions = governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ); + expect(actions).not.toBeNull(); + expect(actions?.find((a) => a.type === "compact")).toMatchObject({ + compactor: "pruning-compactor", + reason: OPERATOR_COMPACT_REASON, + }); + // Handoff always starts the next turn: a content-bearing pivot re-infers + // after the fold (never the meter-only path an idle auto-compact takes). + expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer"); + // The single operator fold is spent: a replayed arrival folds nothing. + expect( + governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ), + ).toBeNull(); + }); + + test("instructions stay sticky for the summary after the fold fires", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ); + expect(governor.extraInstructions).toBe("now do the UI audit"); + }); + + test("cancelManual disarms so the next operator message does not fold", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.cancelManual(); + expect( + governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ), + ).toBeNull(); + }); +}); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index aae7756d0..8c8f9e9b5 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -54,7 +54,7 @@ export type CompactionGovernor = ReturnType; // requestContinuation closure delivered. Subscribers that only care about // provider/connector traffic must ignore this event. export const COMPACTION_CONTINUATION_EVENT = "custom.compaction.continue"; -/** Compact reason for `/compact` (and later operator-triggered folds). */ +/** Compact reason for `/compact` and `/handoff` (operator-triggered folds). */ export const OPERATOR_COMPACT_REASON = "operator-request"; const THRESHOLD_COMPACT_REASON = "context-threshold"; @@ -68,6 +68,9 @@ export type ManualCompactOptions = { turns?: readonly ConversationTurn[]; }; +/** How `/handoff` armed the shared fold pipeline. */ +export type HandoffArming = "armed" | "noop"; + type CompactRecordLike = { strategy?: string; parameters?: Record; @@ -116,9 +119,10 @@ export function createCompactionGovernor( let pending = false; let idlePending = false; let manualPending = false; - // Sticky operator instructions from `/compact …`. Empty `/compact` still - // uses the default structured fold; a non-empty argument is kept for later - // auto-folds and written into the compact record. + // Sticky operator instructions from `/compact …` or `/handoff …`. Empty + // trailing instructions still fold with the default structured summary; a + // non-empty argument is kept for later auto-folds and written into the + // compact record. let extraInstructions: string | undefined; let postCompactInfer = false; // Idle empty compact needs a post-compact decide cycle to adopt the shrunk @@ -551,6 +555,32 @@ export function createCompactionGovernor( extraInstructions = trimmed; } + // `/handoff` folds through the same operator pipeline as above, then starts + // the next turn immediately: unlike an idle auto-compact (empty synthetic + // continuation → meter, no infer), the caller delivers the pivot message + // itself, so the idle arrival slot is always armed and there is no kick + // case. Busy sessions queue the pivot behind the in-flight batch through + // the serial send path; whichever boundary fires first — a tool pause + // (compact-then-continue) or the pivot arrival (fold, then infer) — runs + // the single operator fold, because firing clears the arming. + function requestHandoff(instructions: string): HandoffArming { + if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop"; + const trimmed = instructions.trim(); + if (trimmed.length > 0) extraInstructions = trimmed; + manualPending = true; + idlePending = true; + if (requestContinuation !== undefined) { + requestContinuation(); + } + return "armed"; + } + + // Disarm after a pivot send that never delivered: without this the next + // operator message would fold unexpectedly. + function cancelManual(): void { + clearManualArming(); + } + return { get estimatedTokens(): number { return estimate.tokens; @@ -569,6 +599,8 @@ export function createCompactionGovernor( }, requestManual, restoreExtraInstructions, + requestHandoff, + cancelManual, syncFromTurns, noteInferenceDone, notePostCompact, diff --git a/src/agent/director.ts b/src/agent/director.ts index 99a027b2a..75cef9c41 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -20,6 +20,7 @@ import { compactionContinuationAction, createCompactionGovernor, type CompactionGovernor, + type HandoffArming, type ManualCompactArming, type ManualCompactOptions, } from "./compaction.js"; @@ -824,6 +825,19 @@ class ChatDirectorImpl extends DefaultDirector { return this.compaction.compactTurnCount; } + // `/handoff` arms the shared operator fold; the caller delivers the pivot + // message itself, which triggers the fold on arrival and then the next + // infer against the folded context. + requestHandoff(instructions: string): HandoffArming { + return this.compaction.requestHandoff(instructions); + } + + // Drop a handoff arming whose pivot never delivered, so the next operator + // message does not fold unexpectedly. + cancelManualCompact(): void { + this.compaction.cancelManual(); + } + private openTaskIds(): string[] { return this.tasks .filter((t) => t.status === "todo" || t.status === "doing") @@ -1424,4 +1438,8 @@ export interface ChatDirector extends ReactorDirector { getCompactInstructions(): string | undefined; restoreCompactInstructions(value: string | undefined): void; getCompactTurnCount(): number; + /** `/handoff`: arm the shared operator fold for the pivot the caller sends. */ + requestHandoff(instructions: string): HandoffArming; + /** Drop a handoff arming whose pivot never delivered. */ + cancelManualCompact(): void; } diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 985af0bc1..888e00a6e 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -140,9 +140,10 @@ export interface SummaryContext { // activatedToolNames.list() only. activatedTools?: string[]; /** - * Optional operator guidance from `/compact [instructions]`. Sticky across - * later auto-folds: the governor holds them, and a rebuilt director restores - * them from the latest compact record. + * Optional operator guidance from `/compact [instructions]` or + * `/handoff [instructions]`. Sticky across later auto-folds: the governor + * holds them, and a rebuilt director restores them from the latest compact + * record. */ extraInstructions?: string; } diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 6955d719c..dad510051 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -376,3 +376,62 @@ describe("/compact command", () => { }); }); }); + +describe("/handoff command", () => { + it("is registered with optional instructions", () => { + expect(getCommand("handoff")).toBeDefined(); + }); + + it("passes the trailing instructions through and noops on success", () => { + const seen: string[] = []; + const ctx: CommandContext = { + signalClear: () => undefined, + requestHandoff: (instructions) => { + seen.push(instructions); + return undefined; + }, + }; + expect( + defined(getCommand("handoff"), "handoff").handler( + "now do the UI audit", + ctx, + ), + ).toEqual({ type: "noop" }); + expect(seen).toEqual(["now do the UI audit"]); + }); + + it("passes empty args through for the default pivot copy", () => { + const seen: string[] = []; + const ctx: CommandContext = { + signalClear: () => undefined, + requestHandoff: (instructions) => { + seen.push(instructions); + return undefined; + }, + }; + expect(defined(getCommand("handoff"), "handoff").handler("", ctx)).toEqual({ + type: "noop", + }); + expect(seen).toEqual([""]); + }); + + it("surfaces the runner error as a message", () => { + const ctx: CommandContext = { + signalClear: () => undefined, + requestHandoff: () => "Nothing to hand off yet.", + }; + expect(defined(getCommand("handoff"), "handoff").handler("", ctx)).toEqual({ + type: "message", + text: "Nothing to hand off yet.", + }); + }); + + it("says so when handoff is not wired", () => { + expect( + defined(getCommand("handoff"), "handoff").handler("x", makeCtx()), + ).toEqual({ + type: "message", + text: "Handoff is not available in this session.", + }); + }); +}); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 062ade22f..35b427365 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -235,6 +235,27 @@ export function registerBuiltInCommands(): void { }, }); + // Pivot: fold the context through the shared operator pipeline, then + // immediately start the next turn with the trailing instructions as the + // inbound content (default copy when omitted). Unlike `/compact`, which + // stops after the fold, handoff always re-infers. + registerCommand({ + name: "handoff", + description: "Fold context now, then continue with optional instructions", + argumentHint: "[optional instructions]", + handler: (args, ctx) => { + if (ctx.requestHandoff === undefined) { + return { + type: "message", + text: "Handoff is not available in this session.", + }; + } + const err = ctx.requestHandoff(args); + if (err !== undefined) return { type: "message", text: err }; + return { type: "noop" }; + }, + }); + // Persist as user-global default, not session-only. registerCommand({ name: "yolo", diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 87afad55a..a1a2c1723 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -34,6 +34,13 @@ export interface CommandContext { * Returns an operator-facing error, or undefined when the compact is armed. */ requestCompact?: (instructions: string) => string | undefined; + /** + * Fold conversation context now, then start the next turn with the given + * pivot instructions (default copy when empty). Unlike `/compact` this + * always answers with a fresh inference. Returns an operator-facing error, + * or undefined when the handoff pivot was queued. + */ + requestHandoff?: (instructions: string) => string | undefined; } export type CommandResult = diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts index bf656d851..10bcb6402 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -56,6 +56,13 @@ import { buildCompactionContinuationMessage } from "../../session/runtime-assemb const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); +/** + * Pivot text delivered as the next turn when `/handoff` is invoked without + * trailing instructions. The fold already wrote the structured summary, so + * this only needs to point the fresh inference at it. + */ +export const HANDOFF_DEFAULT_PIVOT = "Continue from the handoff summary above."; + /** * Populate the slash-command registry for a session: built-ins first, then * enabled plugin commands and workflows, then the hidden-command filter. @@ -255,6 +262,40 @@ export function createCommandLayer( }); return undefined; }, + requestHandoff: (instructions: string) => { + const director = services.directorHolder.instance; + if (director === undefined) { + return "Handoff is not available in this session."; + } + const send = state.sendWithAttemptIdentity; + if (send === undefined) { + return "Handoff is not available in this session."; + } + const trimmed = instructions.trim(); + const arming = director.requestHandoff(instructions); + if (arming === "noop" && trimmed.length === 0) { + return "Nothing to hand off yet — the conversation is too short to fold."; + } + // The pivot rides the serial send path, so a busy session queues it + // behind the in-flight tool batch: whichever boundary fires first runs + // the single operator fold (a tool pause compacts-then-continues, the + // pivot arrival folds-then-infers), because firing clears the arming. + // Unlike `/compact`, the pivot is always delivered, so handoff always + // starts the next assistant turn — even a "noop" fold still pivots to + // the operator's new goal without needing `/clear`. + const pivot = trimmed.length > 0 ? trimmed : HANDOFF_DEFAULT_PIVOT; + void send(userInboundMessage(pivot, [])).then( + (result) => { + // A pivot that never delivered must not leave a stale arming behind + // to fold the next innocent operator message. + if (result.status !== "accepted") director.cancelManualCompact(); + }, + () => { + director.cancelManualCompact(); + }, + ); + return undefined; + }, }; const applyCommandResult = (result: CommandResult): void => { diff --git a/src/tui/runner/handoff.test.ts b/src/tui/runner/handoff.test.ts new file mode 100644 index 000000000..92ee6b3a7 --- /dev/null +++ b/src/tui/runner/handoff.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { InboundMessage } from "@intx/types/runtime"; +import type { AgentDeliveryResult } from "../delivery-queue.js"; +import { HANDOFF_DEFAULT_PIVOT, createCommandLayer } from "./commands.js"; +import type { RunnerServices, RunnerState } from "./state.js"; + +type Arming = "armed" | "noop"; + +interface HandoffHarness { + requestHandoff: (instructions: string) => string | undefined; + sent: InboundMessage[]; + requested: string[]; + cancelled: number; + setArming: (arming: Arming) => void; + setSendResult: (result: AgentDeliveryResult) => void; + setSendThrows: (message: string) => void; +} + +function setUpHandoffHarness(options?: { + director?: boolean; + send?: boolean; +}): HandoffHarness { + const sent: InboundMessage[] = []; + const requested: string[] = []; + let cancelled = 0; + let arming: Arming = "armed"; + let sendResult: AgentDeliveryResult = { status: "accepted" }; + let sendError: string | undefined; + + const director = { + requestHandoff: (instructions: string): Arming => { + requested.push(instructions); + return arming; + }, + cancelManualCompact: () => { + cancelled++; + }, + }; + const state = { + ...(options?.send === false + ? {} + : { + sendWithAttemptIdentity: async ( + message: InboundMessage, + ): Promise => { + sent.push(message); + if (sendError !== undefined) throw new Error(sendError); + return sendResult; + }, + }), + } as unknown as RunnerState; + const services = { + directorHolder: options?.director === false ? {} : { instance: director }, + } as unknown as RunnerServices; + const { commandContext } = createCommandLayer(state, services); + const requestHandoff = (instructions: string): string | undefined => { + if (commandContext.requestHandoff === undefined) { + throw new Error("requestHandoff was not wired"); + } + return commandContext.requestHandoff(instructions); + }; + return { + requestHandoff, + sent, + requested, + get cancelled() { + return cancelled; + }, + setArming: (next: Arming) => { + arming = next; + }, + setSendResult: (result: AgentDeliveryResult) => { + sendResult = result; + }, + setSendThrows: (message: string) => { + sendError = message; + }, + }; +} + +const flushSends = async (): Promise => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("runner /handoff wiring", () => { + test("armed handoff delivers the trimmed instructions as the next turn", async () => { + const h = setUpHandoffHarness(); + expect(h.requestHandoff(" now do the UI audit ")).toBeUndefined(); + expect(h.requested).toEqual([" now do the UI audit "]); + await flushSends(); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.content).toBe("now do the UI audit"); + expect(h.cancelled).toBe(0); + }); + + test("armed handoff without instructions delivers the default pivot copy", async () => { + const h = setUpHandoffHarness(); + expect(h.requestHandoff("")).toBeUndefined(); + await flushSends(); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.content).toBe(HANDOFF_DEFAULT_PIVOT); + expect(HANDOFF_DEFAULT_PIVOT.length).toBeGreaterThan(0); + expect(h.cancelled).toBe(0); + }); + + test("noop fold with instructions still pivots to the new goal", async () => { + const h = setUpHandoffHarness(); + h.setArming("noop"); + expect(h.requestHandoff("ship the dashboard")).toBeUndefined(); + await flushSends(); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.content).toBe("ship the dashboard"); + }); + + test("noop fold without instructions reports instead of sending a blank pivot", async () => { + const h = setUpHandoffHarness(); + h.setArming("noop"); + expect(h.requestHandoff("")).toBe( + "Nothing to hand off yet — the conversation is too short to fold.", + ); + await flushSends(); + expect(h.sent).toHaveLength(0); + }); + + test("an undelivered pivot disarms so the next message does not fold", async () => { + const h = setUpHandoffHarness(); + h.setSendResult({ + status: "not-delivered", + reason: "agent-closed", + detail: "closed", + }); + expect(h.requestHandoff("now do the UI audit")).toBeUndefined(); + await flushSends(); + expect(h.sent).toHaveLength(1); + expect(h.cancelled).toBe(1); + }); + + test("a rejected pivot send disarms as well", async () => { + const h = setUpHandoffHarness(); + h.setSendThrows("boom"); + expect(h.requestHandoff("now do the UI audit")).toBeUndefined(); + await flushSends(); + expect(h.cancelled).toBe(1); + }); + + test("says so when no director is mounted", () => { + const h = setUpHandoffHarness({ director: false }); + expect(h.requestHandoff("x")).toBe( + "Handoff is not available in this session.", + ); + expect(h.sent).toHaveLength(0); + }); + + test("says so when the send path is not wired", () => { + const h = setUpHandoffHarness({ send: false }); + expect(h.requestHandoff("x")).toBe( + "Handoff is not available in this session.", + ); + }); +}); From 20460703d481a28c4460282c790946a987b9a5f8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 16:29:31 -0700 Subject: [PATCH 2/6] fix(compaction): cancel failed handoff without biasing later folds --- src/agent/compaction.test.ts | 31 +++++++++++++++++++++++++++++++ src/agent/compaction.ts | 7 ++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 517b017b1..23b89d5e2 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -1432,4 +1432,35 @@ describe("handoff arming (/handoff)", () => { ), ).toBeNull(); }); + + test("cancelManual clears sticky extraInstructions from a failed pivot", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.cancelManual(); + expect(governor.extraInstructions).toBeUndefined(); + }); + + test("threshold pending survives cancelManual so a tool pause still folds", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + + const cancelled = createCompactionGovernor(undefined); + cancelled.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(cancelled.requestHandoff("now do the UI audit")).toBe("armed"); + cancelled.cancelManual(); + const actions = cancelled.interceptActions( + toolDone(), + inferAction, + capabilities, + ); + expect(actions).not.toBeNull(); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + expect(actions?.find((a) => a.type === "compact")).toMatchObject({ + reason: "context-threshold", + }); + }); }); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 8c8f9e9b5..9e95b078b 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -576,9 +576,14 @@ export function createCompactionGovernor( } // Disarm after a pivot send that never delivered: without this the next - // operator message would fold unexpectedly. + // operator message would fold unexpectedly. Threshold `pending` is independent + // of the failed pivot and must still fire at the next tool pause. Sticky + // extraInstructions belong to a successful fold, not a cancelled one. function cancelManual(): void { + const thresholdPending = pending; clearManualArming(); + pending = thresholdPending; + extraInstructions = undefined; } return { From b4d06de49746d09c9e48a44f236a6a04feb897bd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 16:51:24 -0700 Subject: [PATCH 3/6] fix(compaction): restore idle arming and prior fold guidance on cancel --- src/agent/compaction.test.ts | 64 ++++++++++++++++++++++++++++++++++++ src/agent/compaction.ts | 15 +++++++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 23b89d5e2..6e342c79a 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -23,6 +23,7 @@ import { LEGACY_COMPACT_SPACER_TEXT, compactorNoOpFloor, } from "../session/compactor.js"; +import { buildSummaryPrompt } from "../session/summarizer.js"; const capabilities = { infer: (options?: unknown) => ({ @@ -1433,6 +1434,20 @@ describe("handoff arming (/handoff)", () => { ).toBeNull(); }); + test("cancelManual restores extraInstructions from a prior successful fold", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("keep the UI audit")).toBe("armed"); + governor.interceptIdleContinuation( + pivot("keep the UI audit"), + capabilities, + ); + expect(governor.extraInstructions).toBe("keep the UI audit"); + expect(governor.requestHandoff("failed pivot: drop this")).toBe("armed"); + governor.cancelManual(); + expect(governor.extraInstructions).toBe("keep the UI audit"); + }); + test("cancelManual clears sticky extraInstructions from a failed pivot", () => { const governor = createCompactionGovernor(undefined); governor.syncFromTurns(tenTurns); @@ -1441,6 +1456,55 @@ describe("handoff arming (/handoff)", () => { expect(governor.extraInstructions).toBeUndefined(); }); + test("failed-pivot instructions are not in a later threshold summary prompt", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.cancelManual(); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + const prompt = buildSummaryPrompt( + tenTurns, + governor.extraInstructions !== undefined + ? { extraInstructions: governor.extraInstructions } + : undefined, + ); + expect(prompt).not.toContain("now do the UI audit"); + expect(prompt).not.toContain("Operator compact instructions"); + }); + + test("cancelManual does not invent pending", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.cancelManual(); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).toBeNull(); + }); + + test("cancelManual restores idlePending so an idle threshold fold still fires", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.noteIdleTurn(inferenceDone(overThreshold), [ + { type: "reply", content: "done" }, + ]), + ).toBe(true); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.cancelManual(); + const actions = governor.interceptIdleContinuation( + emptyMessage(), + capabilities, + ); + expect(actions).not.toBeNull(); + expect(actions?.find((a) => a.type === "compact")).toMatchObject({ + reason: "context-threshold", + }); + }); + test("threshold pending survives cancelManual so a tool pause still folds", () => { const governor = createCompactionGovernor(undefined); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 9e95b078b..e36c31c75 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -124,6 +124,10 @@ export function createCompactionGovernor( // non-empty argument is kept for later auto-folds and written into the // compact record. let extraInstructions: string | undefined; + // Snapshots taken at requestHandoff so cancelManual can restore the + // pre-pivot idle arming and a prior successful fold's guidance. + let idlePendingAtHandoff = false; + let extraInstructionsAtHandoff: string | undefined; let postCompactInfer = false; // Idle empty compact needs a post-compact decide cycle to adopt the shrunk // turns for the meter, but must not start a new inference (there is no @@ -565,6 +569,8 @@ export function createCompactionGovernor( // the single operator fold, because firing clears the arming. function requestHandoff(instructions: string): HandoffArming { if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop"; + idlePendingAtHandoff = idlePending; + extraInstructionsAtHandoff = extraInstructions; const trimmed = instructions.trim(); if (trimmed.length > 0) extraInstructions = trimmed; manualPending = true; @@ -577,13 +583,16 @@ export function createCompactionGovernor( // Disarm after a pivot send that never delivered: without this the next // operator message would fold unexpectedly. Threshold `pending` is independent - // of the failed pivot and must still fire at the next tool pause. Sticky - // extraInstructions belong to a successful fold, not a cancelled one. + // of the failed pivot and must still fire at the next tool pause. Restore + // idlePending and extraInstructions from the requestHandoff snapshots so a + // cancelled pivot neither invents an idle fold nor wipes a prior successful + // fold's guidance. function cancelManual(): void { const thresholdPending = pending; clearManualArming(); pending = thresholdPending; - extraInstructions = undefined; + idlePending = idlePendingAtHandoff; + extraInstructions = extraInstructionsAtHandoff; } return { From 792a56d2e3db344795f47d6b5828d58bda6077cf Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 17:13:34 -0700 Subject: [PATCH 4/6] fix(compaction): no-op cancelManual once the handoff fold is spent Restoring snapshots after the fold (or after a noop arm) wiped sticky extras and re-armed a second idle compact. --- src/agent/compaction.test.ts | 100 +++++++++++++++++++++++++++++++++ src/agent/compaction.ts | 23 +++++--- src/tui/runner/commands.ts | 10 +++- src/tui/runner/handoff.test.ts | 24 ++++++++ 4 files changed, 146 insertions(+), 11 deletions(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 6e342c79a..1f9f27de0 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -1527,4 +1527,104 @@ describe("handoff arming (/handoff)", () => { reason: "context-threshold", }); }); + + test("cancelManual after an idle fold keeps extras and does not re-arm", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("keep the UI audit")).toBe("armed"); + expect( + governor.interceptIdleContinuation( + pivot("keep the UI audit"), + capabilities, + ), + ).not.toBeNull(); + governor.cancelManual(); + expect(governor.extraInstructions).toBe("keep the UI audit"); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); + + test("cancelManual after a tool-pause fold keeps extras and does not re-arm", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("keep the UI audit")).toBe("armed"); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + governor.cancelManual(); + expect(governor.extraInstructions).toBe("keep the UI audit"); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); + + test("cancelManual after a fired idle fold does not restore a second idle compact", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.noteIdleTurn(inferenceDone(overThreshold), [ + { type: "reply", content: "done" }, + ]), + ).toBe(true); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + expect( + governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ), + ).not.toBeNull(); + governor.cancelManual(); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); + + test("noop then cancelManual does not wipe extras from a prior fold", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("keep the UI audit")).toBe("armed"); + governor.interceptIdleContinuation( + pivot("keep the UI audit"), + capabilities, + ); + governor.syncFromTurns(threeTurns); + expect(governor.requestHandoff("wipe this")).toBe("noop"); + governor.cancelManual(); + expect(governor.extraInstructions).toBe("keep the UI audit"); + }); + + test("double requestHandoff then cancel restores committed extras, not the first uncommitted", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("keep the UI audit")).toBe("armed"); + governor.interceptIdleContinuation( + pivot("keep the UI audit"), + capabilities, + ); + expect(governor.requestHandoff("first uncommitted")).toBe("armed"); + expect(governor.requestHandoff("second uncommitted")).toBe("armed"); + governor.cancelManual(); + expect(governor.extraInstructions).toBe("keep the UI audit"); + }); + + test("empty trailing after a successful fold uses the default structured summary", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("keep the UI audit")).toBe("armed"); + governor.interceptIdleContinuation( + pivot("keep the UI audit"), + capabilities, + ); + expect(governor.requestHandoff(" ")).toBe("armed"); + expect(governor.extraInstructions).toBeUndefined(); + const prompt = buildSummaryPrompt( + tenTurns, + governor.extraInstructions !== undefined + ? { extraInstructions: governor.extraInstructions } + : undefined, + ); + expect(prompt).not.toContain("keep the UI audit"); + expect(prompt).not.toContain("Operator compact instructions"); + }); }); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index e36c31c75..f2d8fd5b4 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -569,10 +569,15 @@ export function createCompactionGovernor( // the single operator fold, because firing clears the arming. function requestHandoff(instructions: string): HandoffArming { if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop"; - idlePendingAtHandoff = idlePending; - extraInstructionsAtHandoff = extraInstructions; + // Snapshot only the committed pre-pivot state. A second request while still + // armed replaces the pending extras; cancel must not restore the first + // uncommitted pivot. + if (!manualPending) { + idlePendingAtHandoff = idlePending; + extraInstructionsAtHandoff = extraInstructions; + } const trimmed = instructions.trim(); - if (trimmed.length > 0) extraInstructions = trimmed; + extraInstructions = trimmed.length > 0 ? trimmed : undefined; manualPending = true; idlePending = true; if (requestContinuation !== undefined) { @@ -582,12 +587,14 @@ export function createCompactionGovernor( } // Disarm after a pivot send that never delivered: without this the next - // operator message would fold unexpectedly. Threshold `pending` is independent - // of the failed pivot and must still fire at the next tool pause. Restore - // idlePending and extraInstructions from the requestHandoff snapshots so a - // cancelled pivot neither invents an idle fold nor wipes a prior successful - // fold's guidance. + // operator message would fold unexpectedly. Already-fired or never-armed + // cancels are no-ops so they cannot restore snapshots over sticky extras or + // re-arm a spent idle fold. Threshold `pending` is independent of the failed + // pivot and must still fire at the next tool pause. Restore idlePending and + // extraInstructions from the requestHandoff snapshots so a cancelled pivot + // neither invents an idle fold nor wipes a prior successful fold's guidance. function cancelManual(): void { + if (!manualPending) return; const thresholdPending = pending; clearManualArming(); pending = thresholdPending; diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts index 10bcb6402..47acacf2e 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -284,14 +284,18 @@ export function createCommandLayer( // starts the next assistant turn — even a "noop" fold still pivots to // the operator's new goal without needing `/clear`. const pivot = trimmed.length > 0 ? trimmed : HANDOFF_DEFAULT_PIVOT; + const disarmOnMiss = arming === "armed"; void send(userInboundMessage(pivot, [])).then( (result) => { // A pivot that never delivered must not leave a stale arming behind - // to fold the next innocent operator message. - if (result.status !== "accepted") director.cancelManualCompact(); + // to fold the next innocent operator message. Noop never armed, so + // cancel would restore snapshots from a prior fold and wipe extras. + if (disarmOnMiss && result.status !== "accepted") { + director.cancelManualCompact(); + } }, () => { - director.cancelManualCompact(); + if (disarmOnMiss) director.cancelManualCompact(); }, ); return undefined; diff --git a/src/tui/runner/handoff.test.ts b/src/tui/runner/handoff.test.ts index 92ee6b3a7..8a85335eb 100644 --- a/src/tui/runner/handoff.test.ts +++ b/src/tui/runner/handoff.test.ts @@ -111,6 +111,7 @@ describe("runner /handoff wiring", () => { await flushSends(); expect(h.sent).toHaveLength(1); expect(h.sent[0]?.content).toBe("ship the dashboard"); + expect(h.cancelled).toBe(0); }); test("noop fold without instructions reports instead of sending a blank pivot", async () => { @@ -136,6 +137,29 @@ describe("runner /handoff wiring", () => { expect(h.cancelled).toBe(1); }); + test("an undelivered noop pivot does not cancelManualCompact", async () => { + const h = setUpHandoffHarness(); + h.setArming("noop"); + h.setSendResult({ + status: "not-delivered", + reason: "agent-closed", + detail: "closed", + }); + expect(h.requestHandoff("ship the dashboard")).toBeUndefined(); + await flushSends(); + expect(h.sent).toHaveLength(1); + expect(h.cancelled).toBe(0); + }); + + test("a rejected noop pivot send does not cancelManualCompact", async () => { + const h = setUpHandoffHarness(); + h.setArming("noop"); + h.setSendThrows("boom"); + expect(h.requestHandoff("ship the dashboard")).toBeUndefined(); + await flushSends(); + expect(h.cancelled).toBe(0); + }); + test("a rejected pivot send disarms as well", async () => { const h = setUpHandoffHarness(); h.setSendThrows("boom"); From 1594bd008ee474bd76eb6c9c81222d71004e8c92 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 17:29:07 -0700 Subject: [PATCH 5/6] fix(compaction): spend operator arming on overflow recovery Overflow compact left /handoff armed, so the pivot folded a second time. Keepers pin empty-trailing cancel, noop-after-idle cancel, and TUI uncertain noop. --- src/agent/compaction.test.ts | 53 ++++++++++++++++++++++++++++++++++ src/agent/compaction.ts | 4 ++- src/tui/runner/handoff.test.ts | 13 +++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 1f9f27de0..ba6a80105 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -1627,4 +1627,57 @@ describe("handoff arming (/handoff)", () => { expect(prompt).not.toContain("keep the UI audit"); expect(prompt).not.toContain("Operator compact instructions"); }); + + test("empty trailing then cancel restores committed extras", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("keep the UI audit")).toBe("armed"); + governor.interceptIdleContinuation( + pivot("keep the UI audit"), + capabilities, + ); + expect(governor.requestHandoff(" ")).toBe("armed"); + expect(governor.extraInstructions).toBeUndefined(); + governor.cancelManual(); + expect(governor.extraInstructions).toBe("keep the UI audit"); + }); + + test("noop then cancel after a restored idle fold has already fired does not re-arm idle", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.noteIdleTurn(inferenceDone(overThreshold), [ + { type: "reply", content: "done" }, + ]), + ).toBe(true); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.cancelManual(); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).not.toBeNull(); + governor.syncFromTurns(threeTurns); + expect(governor.requestHandoff("wipe this")).toBe("noop"); + governor.cancelManual(); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); + + test("overflow then handoff does not double-fold", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + const overflow = governor.interceptOverflow(overflowError(), capabilities); + expect(overflow).not.toBeNull(); + expect(overflow?.find((a) => a.type === "compact")).toMatchObject({ + reason: "context-overflow", + }); + expect( + governor.interceptIdleContinuation( + pivot("now do the UI audit"), + capabilities, + ), + ).toBeNull(); + expect(governor.extraInstructions).toBe("now do the UI audit"); + }); }); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index f2d8fd5b4..4cff59a80 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -480,7 +480,9 @@ export function createCompactionGovernor( } if (overflowRecoveries >= MAX_OVERFLOW_RECOVERIES) return null; overflowRecoveries++; - pending = false; + // Overflow compact spends any operator arming so a queued handoff + // pivot cannot fold again after this recovery. Sticky extras stay. + clearManualArming(); postCompactInfer = true; noteCompactIssued(); return [ diff --git a/src/tui/runner/handoff.test.ts b/src/tui/runner/handoff.test.ts index 8a85335eb..8dd814dd4 100644 --- a/src/tui/runner/handoff.test.ts +++ b/src/tui/runner/handoff.test.ts @@ -151,6 +151,19 @@ describe("runner /handoff wiring", () => { expect(h.cancelled).toBe(0); }); + test("an uncertain noop pivot does not cancelManualCompact", async () => { + const h = setUpHandoffHarness(); + h.setArming("noop"); + h.setSendResult({ + status: "uncertain", + detail: "network reset", + }); + expect(h.requestHandoff("ship the dashboard")).toBeUndefined(); + await flushSends(); + expect(h.sent).toHaveLength(1); + expect(h.cancelled).toBe(0); + }); + test("a rejected noop pivot send does not cancelManualCompact", async () => { const h = setUpHandoffHarness(); h.setArming("noop"); From fca23805ce08e90fcb0ef6c874162d4b0740f309 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Mon, 21 Sep 2026 17:41:02 -0700 Subject: [PATCH 6/6] test(compaction): name overflow keeper by handoff sequence --- src/agent/compaction.test.ts | 41 +++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index ba6a80105..f517d7f4a 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -1663,7 +1663,7 @@ describe("handoff arming (/handoff)", () => { ).toBeNull(); }); - test("overflow then handoff does not double-fold", () => { + test("handoff then overflow then pivot does not double-fold", () => { const governor = createCompactionGovernor(undefined); governor.syncFromTurns(tenTurns); expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); @@ -1680,4 +1680,43 @@ describe("handoff arming (/handoff)", () => { ).toBeNull(); expect(governor.extraInstructions).toBe("now do the UI audit"); }); + + test("handoff then overflow then interceptActions is spent", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).toBeNull(); + }); + + test("overflow then cancelManual keeps extras", () => { + const governor = createCompactionGovernor(undefined); + governor.syncFromTurns(tenTurns); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + governor.cancelManual(); + expect(governor.extraInstructions).toBe("now do the UI audit"); + }); + + test("overflow spends idle threshold arming so later empty arrival does not fold", () => { + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.noteIdleTurn(inferenceDone(overThreshold), [ + { type: "reply", content: "done" }, + ]), + ).toBe(true); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); });