Skip to content

Commit e4fb3cd

Browse files
committed
Refresh the context meter after idle compaction
Idle compaction with an empty continuation never marked the governor estimate authoritative, so the prompt meter kept pre-fold occupancy until the next user turn. Resume with a meter-only settle so the shrunk estimate paints without starting another inference.
1 parent 15a51ad commit e4fb3cd

7 files changed

Lines changed: 152 additions & 16 deletions

File tree

src/agent/compaction.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ describe("compaction governor", () => {
102102
expect(actions?.some((a) => a.type === "infer")).toBe(false);
103103
expect(continuations).toBe(1);
104104

105-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
106-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(false);
105+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
106+
expect(governor.resumeAfterCompact(emptyMessage())).toBeNull();
107107
});
108108

109109
test("stays inert below the threshold or with few turns", () => {
@@ -133,7 +133,7 @@ describe("compaction governor", () => {
133133
test("recovers from context overflow a bounded number of times", () => {
134134
const governor = createCompactionGovernor(() => {});
135135
expect(governor.interceptOverflow(overflowError(), capabilities)).not.toBeNull();
136-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
136+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
137137
expect(governor.interceptOverflow(overflowError(), capabilities)).not.toBeNull();
138138
expect(governor.interceptOverflow(overflowError(), capabilities)).toBeNull();
139139

@@ -161,6 +161,38 @@ describe("compaction governor", () => {
161161
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();
162162
});
163163

164+
// Idle compact with an empty continuation previously left postCompactInfer
165+
// unset, so resumeAfterCompact never fired and notePostCompact never ran —
166+
// the Ctx meter stayed on pre-compact lastTurnUsage until the next user turn.
167+
test("idle empty compact syncs the meter after shrink without a following user turn", () => {
168+
let continuations = 0;
169+
const governor = createCompactionGovernor(() => continuations++);
170+
const large = turnsOfLength(10, 200);
171+
governor.noteInferenceDone(inferenceDone(overThreshold), large);
172+
expect(governor.usingEstimate).toBe(false);
173+
const before = governor.estimatedTokens;
174+
175+
governor.noteIdleTurn(inferenceDone(overThreshold), [{ type: "reply", content: "done" }]);
176+
expect(continuations).toBe(1);
177+
178+
const actions = governor.interceptIdleContinuation(emptyMessage(), capabilities);
179+
expect(actions).toEqual([
180+
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
181+
] as ReactorAction[]);
182+
// A second continuation re-enters decide after the compact cycle so the
183+
// governor can adopt the shrunk turns — without starting a new inference.
184+
expect(continuations).toBe(2);
185+
186+
const shrunk = turnsOfLength(3, 20);
187+
// resumeAfterCompact must arm the meter-only path (not infer) for empty idle.
188+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
189+
governor.notePostCompact(shrunk);
190+
191+
expect(governor.usingEstimate).toBe(true);
192+
expect(governor.estimatedTokens).toBeLessThan(before);
193+
expect(governor.estimatedTokens).toBe(governor.syncFromTurns(shrunk));
194+
});
195+
164196
test("an operator message that races the idle continuation still compacts, then re-infers", () => {
165197
let continuations = 0;
166198
const governor = createCompactionGovernor(() => continuations++);
@@ -176,7 +208,7 @@ describe("compaction governor", () => {
176208
// A second continuation is requested so the operator message gets answered
177209
// after the compact cycle.
178210
expect(continuations).toBe(2);
179-
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
211+
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
180212
});
181213

182214
test("idle turns with follow-up work or under threshold never arm idle compaction", () => {

src/agent/compaction.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export function createCompactionGovernor(
3636
let pending = false;
3737
let idlePending = false;
3838
let postCompactInfer = false;
39+
// Idle empty compact needs a post-compact decide cycle to adopt the shrunk
40+
// turns for the meter, but must not start a new inference (there is no
41+
// operator question to answer). Distinct from postCompactInfer.
42+
let postCompactMeter = false;
3943
let overflowRecoveries = 0;
4044
// Set whenever the arming decision fell back to the local estimate because
4145
// the provider omitted usage or reported zero, so callers rendering a meter
@@ -137,12 +141,16 @@ export function createCompactionGovernor(
137141
idlePending = false;
138142
pending = false;
139143
const content = typeof event.message.content === "string" ? event.message.content : "";
140-
// An operator message that raced the continuation is already in history;
141-
// compact first, then request another continuation to answer it.
144+
// The reactor delivers no event after compact, so always request a
145+
// continuation to re-enter decide against the shrunk turns:
146+
// - raced operator content → re-infer to answer it
147+
// - empty synthetic continuation → meter-only sync (no infer)
142148
if (content.length > 0) {
143149
postCompactInfer = true;
144-
requestContinuation?.();
150+
} else {
151+
postCompactMeter = true;
145152
}
153+
requestContinuation?.();
146154
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
147155
}
148156

@@ -165,12 +173,22 @@ export function createCompactionGovernor(
165173
return [capabilities.compact(COMPACTOR_NAME, "context-overflow")];
166174
}
167175

168-
function resumeAfterCompact(event: ReactorInboundEvent): boolean {
169-
if (!postCompactInfer || event.type !== "message.received") return false;
176+
// After compact, a content-less continuation re-enters decide. "infer" means
177+
// resume the interrupted loop; "meter" means adopt the shrunk turns for the
178+
// Ctx display and stay idle (idle empty compact has nothing to answer).
179+
function resumeAfterCompact(event: ReactorInboundEvent): "infer" | "meter" | null {
180+
if (event.type !== "message.received") return null;
170181
const content = typeof event.message.content === "string" ? event.message.content : "";
171-
if (content.length > 0) return false;
172-
postCompactInfer = false;
173-
return true;
182+
if (content.length > 0) return null;
183+
if (postCompactInfer) {
184+
postCompactInfer = false;
185+
return "infer";
186+
}
187+
if (postCompactMeter) {
188+
postCompactMeter = false;
189+
return "meter";
190+
}
191+
return null;
174192
}
175193

176194
// After a successful compact, the provider-reported usage from before the

src/agent/director.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,10 +651,14 @@ class ChatDirectorImpl extends DefaultDirector {
651651
state: ReactorState,
652652
capabilities: ReactorCapabilities,
653653
): Promise<ReactorAction | ReactorAction[]> {
654-
if (this.compaction.resumeAfterCompact(event)) {
654+
const afterCompact = this.compaction.resumeAfterCompact(event);
655+
if (afterCompact !== null) {
655656
// Compacted history is the live occupancy until the next provider-
656657
// reported inference.done; paint from the estimate in the meantime.
657658
this.compaction.notePostCompact(state.turns ?? []);
659+
// Idle empty compact only needed the decide re-entry to sync the meter;
660+
// stay idle rather than starting an unprompted inference.
661+
if (afterCompact === "meter") return capabilities.wait();
658662
return capabilities.infer();
659663
}
660664
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);

src/director.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,44 @@ describe("chatDirector compaction", () => {
363363
expect(compactActions).toEqual([
364364
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
365365
]);
366+
// Idle empty compact schedules a second continuation so decide can adopt
367+
// the shrunk turns for the meter without starting a new inference.
368+
expect(continuations).toBe(2);
369+
});
370+
371+
test("idle empty compact makes the post-compact estimate authoritative without inferring", async () => {
372+
const director = createChatDirector("", [], {
373+
onTasksChange: () => {},
374+
requestContinuation: () => {},
375+
});
376+
const largeTurns = Array.from(
377+
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
378+
(_, i) => ({
379+
role: i % 2 === 0 ? "user" : "assistant",
380+
content: [{ type: "text", text: "x".repeat(200) }],
381+
timestamp: i,
382+
}),
383+
);
384+
const longState = { turns: largeTurns } as unknown as ReactorState;
385+
386+
await director.decide(textInferenceDone(999_999), longState, mockCapabilities);
387+
expect(director.getContextEstimate().isEstimate).toBe(false);
388+
const before = director.getContextEstimate().tokens;
389+
390+
await director.decide(messageReceived(""), longState, mockCapabilities);
391+
392+
// Simulate the reactor having compacted, then the meter-sync continuation.
393+
const shrunkTurns = largeTurns.slice(-3);
394+
const shrunkState = { turns: shrunkTurns } as unknown as ReactorState;
395+
const afterActions = actionsArray(
396+
await director.decide(messageReceived(""), shrunkState, mockCapabilities),
397+
);
398+
expect(afterActions.some((a) => a.type === "infer")).toBe(false);
399+
expect(afterActions.some((a) => a.type === "wait" || a.type === "reply")).toBe(true);
400+
401+
const estimate = director.getContextEstimate();
402+
expect(estimate.isEstimate).toBe(true);
403+
expect(estimate.tokens).toBeLessThan(before);
366404
});
367405

368406
// One turn past createPruningCompactor's own no-op floor (session/compactor.ts).

src/subagent/nudge-director.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,14 @@ export class SubAgentDirector extends DefaultDirector {
161161
state: ReactorState,
162162
capabilities: ReactorCapabilities,
163163
): Promise<ReactorAction | ReactorAction[]> {
164-
if (this.compaction.resumeAfterCompact(event)) {
164+
const afterCompact = this.compaction.resumeAfterCompact(event);
165+
if (afterCompact !== null) {
165166
// Compacted history is the live occupancy until the next provider-
166167
// reported inference.done; paint from the estimate in the meantime.
167168
this.compaction.notePostCompact(state.turns ?? []);
169+
// Idle empty compact only needed the decide re-entry to sync the meter;
170+
// stay idle rather than starting an unprompted inference.
171+
if (afterCompact === "meter") return capabilities.wait();
168172
return this.applyPendingNudge([capabilities.infer()], capabilities);
169173
}
170174
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);

src/tui/runner-host.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,39 @@ describe("bottom border cost run", () => {
508508
harness.destroy();
509509
}
510510
});
511+
512+
test("connector.reply refreshes the cost meter after idle compact meter-sync", async () => {
513+
const harness = await createHarness({ width: 80, height: 24 });
514+
const emitter = new EventEmitter();
515+
let percent = 90;
516+
const host = await mountRunnerHost({
517+
title: "test",
518+
eventEmitter: emitter,
519+
send: () => {},
520+
interrupt: () => {},
521+
providers: {},
522+
onModelSelect: () => {},
523+
commands: [],
524+
onCommand: () => {},
525+
chrome: () => ({ agents: [] }),
526+
subscribeChrome: () => () => {},
527+
subAgentSessions: () => [],
528+
createRenderer: async () => harness.renderer,
529+
readCostSummary: () => ({ ...fakeCostSummary(), contextPercentUsed: percent }),
530+
});
531+
try {
532+
expect(ruleOf(host.shell.promptBottomRule)).toContain("90%");
533+
534+
percent = 12;
535+
emitter.emit("event", { type: "connector.reply", data: { content: "" } });
536+
537+
expect(ruleOf(host.shell.promptBottomRule)).toContain("12%");
538+
expect(ruleOf(host.shell.promptBottomRule)).not.toContain("90%");
539+
} finally {
540+
host.dispose();
541+
harness.destroy();
542+
}
543+
});
511544
});
512545

513546
/** Resolves true when the host exited, false when it is still alive. */

src/tui/runner-host.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,16 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
309309
pushCostContext();
310310
// Completed turns update cost/context; inference.start also refreshes so a
311311
// post-compact estimate (synced in decide before the infer) paints before
312-
// the next inference.done arrives with provider usage.
312+
// the next inference.done arrives with provider usage. connector.reply
313+
// covers idle empty compact, which syncs the meter then waits (no infer).
313314
const onCostEvent = (event: { type: string }): void => {
314-
if (onTurnBoundary(event) || event.type === "inference.start") pushCostContext();
315+
if (
316+
onTurnBoundary(event) ||
317+
event.type === "inference.start" ||
318+
event.type === "connector.reply"
319+
) {
320+
pushCostContext();
321+
}
315322
};
316323
deps.eventEmitter.on("event", onCostEvent);
317324

0 commit comments

Comments
 (0)