diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 588d4376..ac4f7014 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -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", () => { @@ -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(); @@ -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++); @@ -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", () => { @@ -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); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 2cdeff51..4a348feb 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -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 @@ -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")]; } @@ -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 { @@ -217,6 +243,7 @@ export function createCompactionGovernor( }, syncFromTurns, noteInferenceDone, + notePostCompact, noteIdleTurn, interceptActions, interceptIdleContinuation, diff --git a/src/agent/director.ts b/src/agent/director.ts index 9653ad5a..9e268322 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -651,7 +651,14 @@ class ChatDirectorImpl extends DefaultDirector { state: ReactorState, capabilities: ReactorCapabilities, ): Promise { - 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); diff --git a/src/cost/cost-summary.test.ts b/src/cost/cost-summary.test.ts index c71606c0..aec9d962 100644 --- a/src/cost/cost-summary.test.ts +++ b/src/cost/cost-summary.test.ts @@ -5,6 +5,7 @@ import { buildCostSummary, formatCostCommandOutput, formatStatusBarSegments, + maskContextMeterWhenNoTurns, } from "./cost-summary.js"; import type { CostSummaryInput } from "./cost-summary.js"; @@ -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); diff --git a/src/cost/cost-summary.ts b/src/cost/cost-summary.ts index 6066e58b..ae8d54bd 100644 --- a/src/cost/cost-summary.ts +++ b/src/cost/cost-summary.ts @@ -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; diff --git a/src/director.test.ts b/src/director.test.ts index 5283d84b..281969a5 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -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). diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 0e3edb6e..5789c470 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -179,7 +179,14 @@ export class SubAgentDirector extends DefaultDirector { state: ReactorState, capabilities: ReactorCapabilities, ): Promise { - 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); diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index ab54a770..6d8f7595 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -441,6 +441,106 @@ describe("bottom border cost run", () => { harness.destroy(); } }); + + test("session.clear paints the context meter unknown immediately", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + const emitter = new EventEmitter(); + const host = await mountRunnerHost({ + title: "test", + eventEmitter: emitter, + send: () => {}, + interrupt: () => {}, + providers: {}, + onModelSelect: () => {}, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + // Stale occupancy — refreshCostContext would re-paint this if clear + // re-read before rotation finished. + readCostSummary: () => fakeCostSummary(), + }); + try { + expect(ruleOf(host.shell.promptBottomRule)).toContain("10%"); + expect(host.shell.costContext).not.toBeNull(); + + emitter.emit("session.clear"); + + expect(host.shell.costContext).toBeNull(); + expect(ruleOf(host.shell.promptBottomRule)).not.toContain("10%"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("inference.start refreshes the cost meter from the live summary", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + const emitter = new EventEmitter(); + let percent = 10; + const host = await mountRunnerHost({ + title: "test", + eventEmitter: emitter, + send: () => {}, + interrupt: () => {}, + providers: {}, + onModelSelect: () => {}, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + readCostSummary: () => ({ ...fakeCostSummary(), contextPercentUsed: percent }), + }); + try { + expect(ruleOf(host.shell.promptBottomRule)).toContain("10%"); + + percent = 42; + emitter.emit("event", { type: "inference.start" }); + + expect(ruleOf(host.shell.promptBottomRule)).toContain("42%"); + expect(ruleOf(host.shell.promptBottomRule)).not.toContain("10%"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("connector.reply refreshes the cost meter after idle compact meter-sync", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + const emitter = new EventEmitter(); + let percent = 90; + const host = await mountRunnerHost({ + title: "test", + eventEmitter: emitter, + send: () => {}, + interrupt: () => {}, + providers: {}, + onModelSelect: () => {}, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + readCostSummary: () => ({ ...fakeCostSummary(), contextPercentUsed: percent }), + }); + try { + expect(ruleOf(host.shell.promptBottomRule)).toContain("90%"); + + percent = 12; + emitter.emit("event", { type: "connector.reply", data: { content: "" } }); + + expect(ruleOf(host.shell.promptBottomRule)).toContain("12%"); + expect(ruleOf(host.shell.promptBottomRule)).not.toContain("90%"); + } finally { + host.dispose(); + harness.destroy(); + } + }); }); /** Resolves true when the host exited, false when it is still alive. */ diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index 78bc2aca..4597a64f 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -307,13 +307,33 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise }); }; pushCostContext(); - // Every completed inference turn changes both cost and context usage; - // nothing else needs a fresher read than that. + // Completed turns update cost/context; inference.start also refreshes so a + // post-compact estimate (synced in decide before the infer) paints before + // the next inference.done arrives with provider usage. connector.reply + // covers idle empty compact, which syncs the meter then waits (no infer). const onCostEvent = (event: { type: string }): void => { - if (onTurnBoundary(event)) pushCostContext(); + if ( + onTurnBoundary(event) || + event.type === "inference.start" || + event.type === "connector.reply" + ) { + pushCostContext(); + } }; deps.eventEmitter.on("event", onCostEvent); + // Wipe the meter immediately on /clear|/new. refreshCostContext would re-read + // the still-occupied sink and restore the stale percent before rotation + // finishes. + const onSessionClear = (): void => { + setPromptCostContext(host.shell, { + contextPercentUsed: null, + costLabel: null, + contextIsEstimate: false, + }); + }; + deps.eventEmitter.on("session.clear", onSessionClear); + const stopBranchWatch = watchGitBranch({ cwd, onBranch: (branch) => setPromptWorkspace(host.shell, { branch }), @@ -328,6 +348,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const dispose = (): void => { stopBranchWatch(); deps.eventEmitter.off("event", onCostEvent); + deps.eventEmitter.off("session.clear", onSessionClear); unsubscribeChrome?.(); clearShellExitHandler(host.shell); host.dispose(); diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 26ea3a15..328c3247 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -127,7 +127,11 @@ import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js"; import { defaultPricingCachePath } from "../cost/pricing-fetcher.js"; import { getActivePricingCache } from "../cost/cost-visibility.js"; import { createFaremeter, formatCost } from "../cost/faremeter.js"; -import { buildCostSummary, type CostSummary } from "../cost/cost-summary.js"; +import { + buildCostSummary, + maskContextMeterWhenNoTurns, + type CostSummary, +} from "../cost/cost-summary.js"; import { contextTokensFromUsage } from "../provider/context-window.js"; import { advertisedToolNamesForSessionMode, @@ -1292,6 +1296,7 @@ export async function runTUI(initialConfig: Config): Promise { }); const directorHolder: { instance?: ReturnType } = {}; + const hostHolder: { instance?: Awaited> } = {}; // Owns the workflow lifecycle: slash-command starts, capability overrides, // resume, and publishing status to the App via the emitter. @@ -1871,6 +1876,9 @@ export async function runTUI(initialConfig: Config): Promise { // A fresh session drops any active workflow. workflowController.reset(); fatalBuildError = null; + // Sink and director are empty now — repaint so the meter stays hidden + // rather than showing the pre-clear occupancy until the next turn. + hostHolder.instance?.refreshCostContext(); } catch (err) { recordRunError(err); fatalBuildError = err instanceof Error ? err : new Error(String(err)); @@ -1989,7 +1997,7 @@ export async function runTUI(initialConfig: Config): Promise { // that decision rather than re-deriving it from a second usage read. const contextEstimate = directorHolder.instance?.getContextEstimate(); const isEstimate = contextEstimate !== undefined && contextEstimate.isEstimate; - return buildCostSummary({ + const summary = buildCostSummary({ modelId: config.model, baseURL: config.baseURL, pricingCache, @@ -2003,6 +2011,7 @@ export async function runTUI(initialConfig: Config): Promise { : contextTokensFromUsage(lastTurnUsage), contextIsEstimate: isEstimate, }); + return maskContextMeterWhenNoTurns(summary, runSink.getTurnCount()); }, startWorkflow: (name) => workflowController.start(name), getFleetStatus: () => fleetDigest(subAgentSessions.list(), Date.now()), @@ -2479,6 +2488,7 @@ export async function runTUI(initialConfig: Config): Promise { }, }, }); + hostHolder.instance = host; const shutdownRuntime = createRuntimeShutdown({ disposeHost: host.dispose,