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..f517d7f4a 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) => ({ @@ -1317,3 +1318,405 @@ 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(); + }); + + 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); + expect(governor.requestHandoff("now do the UI audit")).toBe("armed"); + governor.cancelManual(); + 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); + 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", + }); + }); + + 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"); + }); + + 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("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"); + 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"); + }); + + 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(); + }); +}); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index aae7756d0..4cff59a80 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,10 +119,15 @@ 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; + // 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 @@ -472,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 [ @@ -551,6 +561,49 @@ 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"; + // 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(); + extraInstructions = trimmed.length > 0 ? trimmed : undefined; + 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. 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; + idlePending = idlePendingAtHandoff; + extraInstructions = extraInstructionsAtHandoff; + } + return { get estimatedTokens(): number { return estimate.tokens; @@ -569,6 +622,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..47acacf2e 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,44 @@ 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; + 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. Noop never armed, so + // cancel would restore snapshots from a prior fold and wipe extras. + if (disarmOnMiss && result.status !== "accepted") { + director.cancelManualCompact(); + } + }, + () => { + if (disarmOnMiss) 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..8dd814dd4 --- /dev/null +++ b/src/tui/runner/handoff.test.ts @@ -0,0 +1,198 @@ +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"); + expect(h.cancelled).toBe(0); + }); + + 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("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("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"); + 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"); + 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.", + ); + }); +});