Skip to content

Commit 792a56d

Browse files
committed
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.
1 parent b4d06de commit 792a56d

4 files changed

Lines changed: 146 additions & 11 deletions

File tree

‎src/agent/compaction.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,4 +1527,104 @@ describe("handoff arming (/handoff)", () => {
15271527
reason: "context-threshold",
15281528
});
15291529
});
1530+
1531+
test("cancelManual after an idle fold keeps extras and does not re-arm", () => {
1532+
const governor = createCompactionGovernor(undefined);
1533+
governor.syncFromTurns(tenTurns);
1534+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1535+
expect(
1536+
governor.interceptIdleContinuation(
1537+
pivot("keep the UI audit"),
1538+
capabilities,
1539+
),
1540+
).not.toBeNull();
1541+
governor.cancelManual();
1542+
expect(governor.extraInstructions).toBe("keep the UI audit");
1543+
expect(
1544+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1545+
).toBeNull();
1546+
});
1547+
1548+
test("cancelManual after a tool-pause fold keeps extras and does not re-arm", () => {
1549+
const governor = createCompactionGovernor(undefined);
1550+
governor.syncFromTurns(tenTurns);
1551+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1552+
expect(
1553+
governor.interceptActions(toolDone(), inferAction, capabilities),
1554+
).not.toBeNull();
1555+
governor.cancelManual();
1556+
expect(governor.extraInstructions).toBe("keep the UI audit");
1557+
expect(
1558+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1559+
).toBeNull();
1560+
});
1561+
1562+
test("cancelManual after a fired idle fold does not restore a second idle compact", () => {
1563+
const governor = createCompactionGovernor(undefined);
1564+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
1565+
expect(
1566+
governor.noteIdleTurn(inferenceDone(overThreshold), [
1567+
{ type: "reply", content: "done" },
1568+
]),
1569+
).toBe(true);
1570+
expect(governor.requestHandoff("now do the UI audit")).toBe("armed");
1571+
expect(
1572+
governor.interceptIdleContinuation(
1573+
pivot("now do the UI audit"),
1574+
capabilities,
1575+
),
1576+
).not.toBeNull();
1577+
governor.cancelManual();
1578+
expect(
1579+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1580+
).toBeNull();
1581+
});
1582+
1583+
test("noop then cancelManual does not wipe extras from a prior fold", () => {
1584+
const governor = createCompactionGovernor(undefined);
1585+
governor.syncFromTurns(tenTurns);
1586+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1587+
governor.interceptIdleContinuation(
1588+
pivot("keep the UI audit"),
1589+
capabilities,
1590+
);
1591+
governor.syncFromTurns(threeTurns);
1592+
expect(governor.requestHandoff("wipe this")).toBe("noop");
1593+
governor.cancelManual();
1594+
expect(governor.extraInstructions).toBe("keep the UI audit");
1595+
});
1596+
1597+
test("double requestHandoff then cancel restores committed extras, not the first uncommitted", () => {
1598+
const governor = createCompactionGovernor(undefined);
1599+
governor.syncFromTurns(tenTurns);
1600+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1601+
governor.interceptIdleContinuation(
1602+
pivot("keep the UI audit"),
1603+
capabilities,
1604+
);
1605+
expect(governor.requestHandoff("first uncommitted")).toBe("armed");
1606+
expect(governor.requestHandoff("second uncommitted")).toBe("armed");
1607+
governor.cancelManual();
1608+
expect(governor.extraInstructions).toBe("keep the UI audit");
1609+
});
1610+
1611+
test("empty trailing after a successful fold uses the default structured summary", () => {
1612+
const governor = createCompactionGovernor(undefined);
1613+
governor.syncFromTurns(tenTurns);
1614+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1615+
governor.interceptIdleContinuation(
1616+
pivot("keep the UI audit"),
1617+
capabilities,
1618+
);
1619+
expect(governor.requestHandoff(" ")).toBe("armed");
1620+
expect(governor.extraInstructions).toBeUndefined();
1621+
const prompt = buildSummaryPrompt(
1622+
tenTurns,
1623+
governor.extraInstructions !== undefined
1624+
? { extraInstructions: governor.extraInstructions }
1625+
: undefined,
1626+
);
1627+
expect(prompt).not.toContain("keep the UI audit");
1628+
expect(prompt).not.toContain("Operator compact instructions");
1629+
});
15301630
});

‎src/agent/compaction.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -569,10 +569,15 @@ export function createCompactionGovernor(
569569
// the single operator fold, because firing clears the arming.
570570
function requestHandoff(instructions: string): HandoffArming {
571571
if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop";
572-
idlePendingAtHandoff = idlePending;
573-
extraInstructionsAtHandoff = extraInstructions;
572+
// Snapshot only the committed pre-pivot state. A second request while still
573+
// armed replaces the pending extras; cancel must not restore the first
574+
// uncommitted pivot.
575+
if (!manualPending) {
576+
idlePendingAtHandoff = idlePending;
577+
extraInstructionsAtHandoff = extraInstructions;
578+
}
574579
const trimmed = instructions.trim();
575-
if (trimmed.length > 0) extraInstructions = trimmed;
580+
extraInstructions = trimmed.length > 0 ? trimmed : undefined;
576581
manualPending = true;
577582
idlePending = true;
578583
if (requestContinuation !== undefined) {
@@ -582,12 +587,14 @@ export function createCompactionGovernor(
582587
}
583588

584589
// Disarm after a pivot send that never delivered: without this the next
585-
// operator message would fold unexpectedly. Threshold `pending` is independent
586-
// of the failed pivot and must still fire at the next tool pause. Restore
587-
// idlePending and extraInstructions from the requestHandoff snapshots so a
588-
// cancelled pivot neither invents an idle fold nor wipes a prior successful
589-
// fold's guidance.
590+
// operator message would fold unexpectedly. Already-fired or never-armed
591+
// cancels are no-ops so they cannot restore snapshots over sticky extras or
592+
// re-arm a spent idle fold. Threshold `pending` is independent of the failed
593+
// pivot and must still fire at the next tool pause. Restore idlePending and
594+
// extraInstructions from the requestHandoff snapshots so a cancelled pivot
595+
// neither invents an idle fold nor wipes a prior successful fold's guidance.
590596
function cancelManual(): void {
597+
if (!manualPending) return;
591598
const thresholdPending = pending;
592599
clearManualArming();
593600
pending = thresholdPending;

‎src/tui/runner/commands.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,14 +284,18 @@ export function createCommandLayer(
284284
// starts the next assistant turn — even a "noop" fold still pivots to
285285
// the operator's new goal without needing `/clear`.
286286
const pivot = trimmed.length > 0 ? trimmed : HANDOFF_DEFAULT_PIVOT;
287+
const disarmOnMiss = arming === "armed";
287288
void send(userInboundMessage(pivot, [])).then(
288289
(result) => {
289290
// A pivot that never delivered must not leave a stale arming behind
290-
// to fold the next innocent operator message.
291-
if (result.status !== "accepted") director.cancelManualCompact();
291+
// to fold the next innocent operator message. Noop never armed, so
292+
// cancel would restore snapshots from a prior fold and wipe extras.
293+
if (disarmOnMiss && result.status !== "accepted") {
294+
director.cancelManualCompact();
295+
}
292296
},
293297
() => {
294-
director.cancelManualCompact();
298+
if (disarmOnMiss) director.cancelManualCompact();
295299
},
296300
);
297301
return undefined;

‎src/tui/runner/handoff.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ describe("runner /handoff wiring", () => {
111111
await flushSends();
112112
expect(h.sent).toHaveLength(1);
113113
expect(h.sent[0]?.content).toBe("ship the dashboard");
114+
expect(h.cancelled).toBe(0);
114115
});
115116

116117
test("noop fold without instructions reports instead of sending a blank pivot", async () => {
@@ -136,6 +137,29 @@ describe("runner /handoff wiring", () => {
136137
expect(h.cancelled).toBe(1);
137138
});
138139

140+
test("an undelivered noop pivot does not cancelManualCompact", async () => {
141+
const h = setUpHandoffHarness();
142+
h.setArming("noop");
143+
h.setSendResult({
144+
status: "not-delivered",
145+
reason: "agent-closed",
146+
detail: "closed",
147+
});
148+
expect(h.requestHandoff("ship the dashboard")).toBeUndefined();
149+
await flushSends();
150+
expect(h.sent).toHaveLength(1);
151+
expect(h.cancelled).toBe(0);
152+
});
153+
154+
test("a rejected noop pivot send does not cancelManualCompact", async () => {
155+
const h = setUpHandoffHarness();
156+
h.setArming("noop");
157+
h.setSendThrows("boom");
158+
expect(h.requestHandoff("ship the dashboard")).toBeUndefined();
159+
await flushSends();
160+
expect(h.cancelled).toBe(0);
161+
});
162+
139163
test("a rejected pivot send disarms as well", async () => {
140164
const h = setUpHandoffHarness();
141165
h.setSendThrows("boom");

0 commit comments

Comments
 (0)