Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 55 additions & 4 deletions src/agent/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ describe("compaction governor", () => {
expect(actions?.some((a) => a.type === "infer")).toBe(false);
expect(continuations).toBe(1);

expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
expect(governor.resumeAfterCompact(emptyMessage())).toBe(false);
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
expect(governor.resumeAfterCompact(emptyMessage())).toBeNull();
});

test("stays inert below the threshold or with few turns", () => {
Expand Down Expand Up @@ -134,7 +134,7 @@ describe("compaction governor", () => {
test("recovers from context overflow a bounded number of times", () => {
const governor = createCompactionGovernor(() => {});
expect(governor.interceptOverflow(overflowError(), capabilities)).not.toBeNull();
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
expect(governor.interceptOverflow(overflowError(), capabilities)).not.toBeNull();
expect(governor.interceptOverflow(overflowError(), capabilities)).toBeNull();

Expand Down Expand Up @@ -162,6 +162,38 @@ describe("compaction governor", () => {
expect(governor.interceptIdleContinuation(emptyMessage(), capabilities)).toBeNull();
});

// Idle compact with an empty continuation previously left postCompactInfer
// unset, so resumeAfterCompact never fired and notePostCompact never ran —
// the Ctx meter stayed on pre-compact lastTurnUsage until the next user turn.
test("idle empty compact syncs the meter after shrink without a following user turn", () => {
let continuations = 0;
const governor = createCompactionGovernor(() => continuations++);
const large = turnsOfLength(10, 200);
governor.noteInferenceDone(inferenceDone(overThreshold), large);
expect(governor.usingEstimate).toBe(false);
const before = governor.estimatedTokens;

governor.noteIdleTurn(inferenceDone(overThreshold), [{ type: "reply", content: "done" }]);
expect(continuations).toBe(1);

const actions = governor.interceptIdleContinuation(emptyMessage(), capabilities);
expect(actions).toEqual([
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
] as ReactorAction[]);
// A second continuation re-enters decide after the compact cycle so the
// governor can adopt the shrunk turns — without starting a new inference.
expect(continuations).toBe(2);

const shrunk = turnsOfLength(3, 20);
// resumeAfterCompact must arm the meter-only path (not infer) for empty idle.
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
governor.notePostCompact(shrunk);

expect(governor.usingEstimate).toBe(true);
expect(governor.estimatedTokens).toBeLessThan(before);
expect(governor.estimatedTokens).toBe(governor.syncFromTurns(shrunk));
});

test("an operator message that races the idle continuation still compacts, then re-infers", () => {
let continuations = 0;
const governor = createCompactionGovernor(() => continuations++);
Expand All @@ -177,7 +209,7 @@ describe("compaction governor", () => {
// A second continuation is requested so the operator message gets answered
// after the compact cycle.
expect(continuations).toBe(2);
expect(governor.resumeAfterCompact(emptyMessage())).toBe(true);
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
});

test("idle turns with follow-up work or under threshold never arm idle compaction", () => {
Expand Down Expand Up @@ -337,6 +369,25 @@ describe("compaction governor", () => {
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
});

test("notePostCompact syncs the shrunk turns and keeps the estimate authoritative until the next inference.done", () => {
const governor = createCompactionGovernor(() => {});
const large = turnsOfLength(10, 200);
governor.noteInferenceDone(inferenceDone(overThreshold), large);
expect(governor.usingEstimate).toBe(false);
const before = governor.estimatedTokens;

const shrunk = turnsOfLength(3, 20);
governor.notePostCompact(shrunk);

expect(governor.usingEstimate).toBe(true);
expect(governor.estimatedTokens).toBeLessThan(before);
expect(governor.estimatedTokens).toBe(governor.syncFromTurns(shrunk));

// Provider-reported usage on the next turn clears the estimate flag.
governor.noteInferenceDone(inferenceDone(1000), shrunk);
expect(governor.usingEstimate).toBe(false);
});

test("does not re-arm after a compact that remains over the high watermark", () => {
const governor = createCompactionGovernor(() => {});
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
Expand Down
43 changes: 35 additions & 8 deletions src/agent/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export function createCompactionGovernor(
let pending = false;
let idlePending = false;
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
// operator question to answer). Distinct from postCompactInfer.
let postCompactMeter = false;
let overflowRecoveries = 0;
// Set whenever the arming decision fell back to the local estimate because
// the provider omitted usage or reported zero, so callers rendering a meter
Expand Down Expand Up @@ -167,13 +171,17 @@ export function createCompactionGovernor(
idlePending = false;
pending = false;
const content = typeof event.message.content === "string" ? event.message.content : "";
// An operator message that raced the continuation is already in history;
// compact first, then request another continuation to answer it.
// The reactor delivers no event after compact, so always request a
// continuation to re-enter decide against the shrunk turns:
// - raced operator content → re-infer to answer it
// - empty synthetic continuation → meter-only sync (no infer)
if (content.length > 0) {
postCompactInfer = true;
requestContinuation?.();
} else {
postCompactMeter = true;
}
noteCompactIssued();
requestContinuation?.();
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
}

Expand All @@ -197,12 +205,30 @@ export function createCompactionGovernor(
return [capabilities.compact(COMPACTOR_NAME, "context-overflow")];
}

function resumeAfterCompact(event: ReactorInboundEvent): boolean {
if (!postCompactInfer || event.type !== "message.received") return false;
// After compact, a content-less continuation re-enters decide. "infer" means
// resume the interrupted loop; "meter" means adopt the shrunk turns for the
// Ctx display and stay idle (idle empty compact has nothing to answer).
function resumeAfterCompact(event: ReactorInboundEvent): "infer" | "meter" | null {
if (event.type !== "message.received") return null;
const content = typeof event.message.content === "string" ? event.message.content : "";
if (content.length > 0) return false;
postCompactInfer = false;
return true;
if (content.length > 0) return null;
if (postCompactInfer) {
postCompactInfer = false;
return "infer";
}
if (postCompactMeter) {
postCompactMeter = false;
return "meter";
}
return null;
}

// After a successful compact, the provider-reported usage from before the
// shrink is stale. Re-sync from the compacted turns and treat the local
// estimate as authoritative until the next real inference.done.
function notePostCompact(turns: readonly ConversationTurn[]): void {
syncFromTurns(turns);
usingEstimate = true;
}

return {
Expand All @@ -217,6 +243,7 @@ export function createCompactionGovernor(
},
syncFromTurns,
noteInferenceDone,
notePostCompact,
noteIdleTurn,
interceptActions,
interceptIdleContinuation,
Expand Down
9 changes: 8 additions & 1 deletion src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,14 @@ class ChatDirectorImpl extends DefaultDirector {
state: ReactorState,
capabilities: ReactorCapabilities,
): Promise<ReactorAction | ReactorAction[]> {
if (this.compaction.resumeAfterCompact(event)) {
const afterCompact = this.compaction.resumeAfterCompact(event);
if (afterCompact !== null) {
// Compacted history is the live occupancy until the next provider-
// reported inference.done; paint from the estimate in the meantime.
this.compaction.notePostCompact(state.turns ?? []);
// Idle empty compact only needed the decide re-entry to sync the meter;
// stay idle rather than starting an unprompted inference.
if (afterCompact === "meter") return capabilities.wait();
return capabilities.infer();
}
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);
Expand Down
20 changes: 20 additions & 0 deletions src/cost/cost-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildCostSummary,
formatCostCommandOutput,
formatStatusBarSegments,
maskContextMeterWhenNoTurns,
} from "./cost-summary.js";
import type { CostSummaryInput } from "./cost-summary.js";

Expand Down Expand Up @@ -64,6 +65,25 @@ describe("buildCostSummary", () => {
});
});

describe("maskContextMeterWhenNoTurns", () => {
it("hides the meter on a zero-turn session even when contextTokens are non-zero", () => {
const summary = buildCostSummary(baseInput);
expect(summary.contextPercentUsed).toBe(50);

const masked = maskContextMeterWhenNoTurns(summary, 0);
expect(masked.contextPercentUsed).toBeNull();
expect(masked.contextIsEstimate).toBe(false);
// Cost totals stay untouched — only occupancy display is suppressed.
expect(masked.totalCost).toBe(summary.totalCost);
expect(masked.formattedCost).toBe(summary.formattedCost);
});

it("leaves a session with turns unchanged", () => {
const summary = buildCostSummary(baseInput);
expect(maskContextMeterWhenNoTurns(summary, 1)).toEqual(summary);
});
});

describe("formatStatusBarSegments", () => {
it("includes both cost and context when cost is not hidden", () => {
const summary = buildCostSummary(baseInput);
Expand Down
9 changes: 9 additions & 0 deletions src/cost/cost-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export function buildCostSummary(input: CostSummaryInput): CostSummary {
};
}

// Zero-turn sessions (fresh launch, post-/clear, post-/new) have no occupancy
// to report. Hide the meter rather than showing 0% or the new director's
// system-prompt/tool-schema overhead as if it were live usage. Cost totals
// stay untouched.
export function maskContextMeterWhenNoTurns(summary: CostSummary, turnCount: number): CostSummary {
if (turnCount > 0) return summary;
return { ...summary, contextPercentUsed: null, contextIsEstimate: false };
}

export interface StatusBarCostSegments {
costLabel?: string;
contextLabel: string;
Expand Down
38 changes: 38 additions & 0 deletions src/director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,44 @@ describe("chatDirector compaction", () => {
expect(compactActions).toEqual([
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
]);
// Idle empty compact schedules a second continuation so decide can adopt
// the shrunk turns for the meter without starting a new inference.
expect(continuations).toBe(2);
});

test("idle empty compact makes the post-compact estimate authoritative without inferring", async () => {
const director = createChatDirector("", [], {
onTasksChange: () => {},
requestContinuation: () => {},
});
const largeTurns = Array.from(
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
(_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: [{ type: "text", text: "x".repeat(200) }],
timestamp: i,
}),
);
const longState = { turns: largeTurns } as unknown as ReactorState;

await director.decide(textInferenceDone(999_999), longState, mockCapabilities);
expect(director.getContextEstimate().isEstimate).toBe(false);
const before = director.getContextEstimate().tokens;

await director.decide(messageReceived(""), longState, mockCapabilities);

// Simulate the reactor having compacted, then the meter-sync continuation.
const shrunkTurns = largeTurns.slice(-3);
const shrunkState = { turns: shrunkTurns } as unknown as ReactorState;
const afterActions = actionsArray(
await director.decide(messageReceived(""), shrunkState, mockCapabilities),
);
expect(afterActions.some((a) => a.type === "infer")).toBe(false);
expect(afterActions.some((a) => a.type === "wait" || a.type === "reply")).toBe(true);

const estimate = director.getContextEstimate();
expect(estimate.isEstimate).toBe(true);
expect(estimate.tokens).toBeLessThan(before);
});

// One turn past createPruningCompactor's own no-op floor (session/compactor.ts).
Expand Down
9 changes: 8 additions & 1 deletion src/subagent/nudge-director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,14 @@ export class SubAgentDirector extends DefaultDirector {
state: ReactorState,
capabilities: ReactorCapabilities,
): Promise<ReactorAction | ReactorAction[]> {
if (this.compaction.resumeAfterCompact(event)) {
const afterCompact = this.compaction.resumeAfterCompact(event);
if (afterCompact !== null) {
// Compacted history is the live occupancy until the next provider-
// reported inference.done; paint from the estimate in the meantime.
this.compaction.notePostCompact(state.turns ?? []);
// Idle empty compact only needed the decide re-entry to sync the meter;
// stay idle rather than starting an unprompted inference.
if (afterCompact === "meter") return capabilities.wait();
return this.applyPendingNudge([capabilities.infer()], capabilities);
}
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);
Expand Down
Loading
Loading