Skip to content

Commit ae5dba9

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 cdb71f5 commit ae5dba9

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
@@ -997,4 +997,104 @@ describe("handoff arming (/handoff)", () => {
997997
reason: "context-threshold",
998998
});
999999
});
1000+
1001+
test("cancelManual after an idle fold keeps extras and does not re-arm", () => {
1002+
const governor = createCompactionGovernor(undefined);
1003+
governor.syncFromTurns(tenTurns);
1004+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1005+
expect(
1006+
governor.interceptIdleContinuation(
1007+
pivot("keep the UI audit"),
1008+
capabilities,
1009+
),
1010+
).not.toBeNull();
1011+
governor.cancelManual();
1012+
expect(governor.extraInstructions).toBe("keep the UI audit");
1013+
expect(
1014+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1015+
).toBeNull();
1016+
});
1017+
1018+
test("cancelManual after a tool-pause fold keeps extras and does not re-arm", () => {
1019+
const governor = createCompactionGovernor(undefined);
1020+
governor.syncFromTurns(tenTurns);
1021+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1022+
expect(
1023+
governor.interceptActions(toolDone(), inferAction, capabilities),
1024+
).not.toBeNull();
1025+
governor.cancelManual();
1026+
expect(governor.extraInstructions).toBe("keep the UI audit");
1027+
expect(
1028+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1029+
).toBeNull();
1030+
});
1031+
1032+
test("cancelManual after a fired idle fold does not restore a second idle compact", () => {
1033+
const governor = createCompactionGovernor(undefined);
1034+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
1035+
expect(
1036+
governor.noteIdleTurn(inferenceDone(overThreshold), [
1037+
{ type: "reply", content: "done" },
1038+
]),
1039+
).toBe(true);
1040+
expect(governor.requestHandoff("now do the UI audit")).toBe("armed");
1041+
expect(
1042+
governor.interceptIdleContinuation(
1043+
pivot("now do the UI audit"),
1044+
capabilities,
1045+
),
1046+
).not.toBeNull();
1047+
governor.cancelManual();
1048+
expect(
1049+
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1050+
).toBeNull();
1051+
});
1052+
1053+
test("noop then cancelManual does not wipe extras from a prior fold", () => {
1054+
const governor = createCompactionGovernor(undefined);
1055+
governor.syncFromTurns(tenTurns);
1056+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1057+
governor.interceptIdleContinuation(
1058+
pivot("keep the UI audit"),
1059+
capabilities,
1060+
);
1061+
governor.syncFromTurns(threeTurns);
1062+
expect(governor.requestHandoff("wipe this")).toBe("noop");
1063+
governor.cancelManual();
1064+
expect(governor.extraInstructions).toBe("keep the UI audit");
1065+
});
1066+
1067+
test("double requestHandoff then cancel restores committed extras, not the first uncommitted", () => {
1068+
const governor = createCompactionGovernor(undefined);
1069+
governor.syncFromTurns(tenTurns);
1070+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1071+
governor.interceptIdleContinuation(
1072+
pivot("keep the UI audit"),
1073+
capabilities,
1074+
);
1075+
expect(governor.requestHandoff("first uncommitted")).toBe("armed");
1076+
expect(governor.requestHandoff("second uncommitted")).toBe("armed");
1077+
governor.cancelManual();
1078+
expect(governor.extraInstructions).toBe("keep the UI audit");
1079+
});
1080+
1081+
test("empty trailing after a successful fold uses the default structured summary", () => {
1082+
const governor = createCompactionGovernor(undefined);
1083+
governor.syncFromTurns(tenTurns);
1084+
expect(governor.requestHandoff("keep the UI audit")).toBe("armed");
1085+
governor.interceptIdleContinuation(
1086+
pivot("keep the UI audit"),
1087+
capabilities,
1088+
);
1089+
expect(governor.requestHandoff(" ")).toBe("armed");
1090+
expect(governor.extraInstructions).toBeUndefined();
1091+
const prompt = buildSummaryPrompt(
1092+
tenTurns,
1093+
governor.extraInstructions !== undefined
1094+
? { extraInstructions: governor.extraInstructions }
1095+
: undefined,
1096+
);
1097+
expect(prompt).not.toContain("keep the UI audit");
1098+
expect(prompt).not.toContain("Operator compact instructions");
1099+
});
10001100
});

‎src/agent/compaction.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -390,10 +390,15 @@ export function createCompactionGovernor(
390390
// the single operator fold, because firing clears the arming.
391391
function requestHandoff(instructions: string): HandoffArming {
392392
if (turnCount <= MIN_TURNS_TO_COMPACT) return "noop";
393-
idlePendingAtHandoff = idlePending;
394-
extraInstructionsAtHandoff = extraInstructions;
393+
// Snapshot only the committed pre-pivot state. A second request while still
394+
// armed replaces the pending extras; cancel must not restore the first
395+
// uncommitted pivot.
396+
if (!manualPending) {
397+
idlePendingAtHandoff = idlePending;
398+
extraInstructionsAtHandoff = extraInstructions;
399+
}
395400
const trimmed = instructions.trim();
396-
if (trimmed.length > 0) extraInstructions = trimmed;
401+
extraInstructions = trimmed.length > 0 ? trimmed : undefined;
397402
manualPending = true;
398403
idlePending = true;
399404
if (requestContinuation !== undefined) {
@@ -403,12 +408,14 @@ export function createCompactionGovernor(
403408
}
404409

405410
// Disarm after a pivot send that never delivered: without this the next
406-
// operator message would fold unexpectedly. Threshold `pending` is independent
407-
// of the failed pivot and must still fire at the next tool pause. Restore
408-
// idlePending and extraInstructions from the requestHandoff snapshots so a
409-
// cancelled pivot neither invents an idle fold nor wipes a prior successful
410-
// fold's guidance.
411+
// operator message would fold unexpectedly. Already-fired or never-armed
412+
// cancels are no-ops so they cannot restore snapshots over sticky extras or
413+
// re-arm a spent idle fold. Threshold `pending` is independent of the failed
414+
// pivot and must still fire at the next tool pause. Restore idlePending and
415+
// extraInstructions from the requestHandoff snapshots so a cancelled pivot
416+
// neither invents an idle fold nor wipes a prior successful fold's guidance.
411417
function cancelManual(): void {
418+
if (!manualPending) return;
412419
const thresholdPending = pending;
413420
clearManualArming();
414421
pending = thresholdPending;

‎src/tui/runner/commands.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,14 +228,18 @@ export function createCommandLayer(
228228
// starts the next assistant turn — even a "noop" fold still pivots to
229229
// the operator's new goal without needing `/clear`.
230230
const pivot = trimmed.length > 0 ? trimmed : HANDOFF_DEFAULT_PIVOT;
231+
const disarmOnMiss = arming === "armed";
231232
void send(userInboundMessage(pivot, [])).then(
232233
(result) => {
233234
// A pivot that never delivered must not leave a stale arming behind
234-
// to fold the next innocent operator message.
235-
if (result.status !== "accepted") director.cancelManualCompact();
235+
// to fold the next innocent operator message. Noop never armed, so
236+
// cancel would restore snapshots from a prior fold and wipe extras.
237+
if (disarmOnMiss && result.status !== "accepted") {
238+
director.cancelManualCompact();
239+
}
236240
},
237241
() => {
238-
director.cancelManualCompact();
242+
if (disarmOnMiss) director.cancelManualCompact();
239243
},
240244
);
241245
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)