From b1d3c46c5344900709bdd5303ea5686d21dc1277 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 28 Sep 2026 00:02:17 +0800 Subject: [PATCH 01/11] fix(ui): show unknown context usage after compaction Treat measurements taken before a successful compaction as stale until a newer request settles. Update the composer display, localized tooltip, tests, and changelog. Generated-by: Codex --- CHANGELOG.md | 11 + .../main/__tests__/conversation-owner.test.ts | 3 +- .../__tests__/latest-request-usage.test.ts | 195 ++++++++++++++++-- .../main/__tests__/live-context-usage.test.ts | 27 +-- .../session-inspector/latest-request-usage.ts | 87 +++++--- .../session-inspector/live-context-usage.ts | 13 ++ .../src/renderer/chat-composer-region.tsx | 126 +++++------ .../model/conversation-workspace.ts | 14 +- .../conversation/ui/conversation-readers.tsx | 7 +- .../features/workhub/ui/workhub-root.tsx | 17 +- apps/desktop/stories/app-shell.stories.tsx | 4 +- .../__tests__/composer-context-usage.test.tsx | 20 +- packages/ui/src/composer.tsx | 32 ++- packages/ui/src/context-usage-reading.ts | 29 +++ packages/ui/src/conversation-copy.ts | 5 + packages/ui/src/index.ts | 1 + 16 files changed, 435 insertions(+), 156 deletions(-) create mode 100644 packages/ui/src/context-usage-reading.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 298d272dbe1..948362cc073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,17 @@ ### Fixed +- The composer's context gauge now says so instead of holding the pre-compaction + figure after a fold. A compaction replaces the prompt the last measured request + described, and no provider has tokenized the replacement yet, so the newest real + count is stale rather than current — showing it read as a live measurement of + what the session was about to send. The gauge walks the transcript backwards and + whichever fact comes first decides: a `context_compacted` note newer than every + measurement renders `?` with a tooltip saying why, and a measurement newer than + the note stands. The live per-settled-request snapshot still wins when it landed + after the boundary, so a mid-turn fold recovers as soon as the next step settles + rather than waiting for the turn to end. A failed-open fold is not a boundary: + that request went out with its full raw history. - Fixed a renderer crash dialog reporting React error #185 ("Maximum update depth exceeded") coming from the composer's prompt-history inline completion (#4117): the offer engine the 0.1.11 composer fed could flip-flop its announcement state on diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index 2b51777956c..b92b8441c72 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -27,6 +27,7 @@ import type { DesktopSessionSummary } from '../../shared/desktop-session-project import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, ConversationComposerRegion, useAppShellSessionUiState, type ConversationObservationServices, type ConversationServices } from '../../renderer/features/conversation/index.js'; import { renderConversationMarkdown, stubComposerGateInputs, stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; +import type { LatestRequestUsage } from '../../renderer/application/contracts/session-inspector/latest-request-usage.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { withComposerSubmission } from './composer-submission-fixture.js'; @@ -97,7 +98,7 @@ function harness(options: { let setVisible!: (visible: boolean) => void; let setCovered!: (covered: boolean) => void; function Transcript(props: NonNullable) { transcript = props; transcriptRenders += 1; return null; } - function Composer(_props: { processing: boolean; pendingMessages?: readonly TransientUserMessageProjection[]; latestRequestUsageTokens?: number }) { + function Composer(_props: { processing: boolean; pendingMessages?: readonly TransientUserMessageProjection[]; latestRequestUsage?: LatestRequestUsage }) { composerRenders += 1; useEffect(() => { composerMounts += 1; return () => { composerUnmounts += 1; }; }, []); return null; diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index 301f8aeffa3..d2038ed08e8 100644 --- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -19,22 +19,36 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { selectLatestRequestUsage } from '../../renderer/chat-composer-region.js'; +import { + resolveContextUsage, + selectLatestRequestUsage, +} from '../../renderer/application/contracts/session-inspector/latest-request-usage.js'; const ROUTE = { llmConnectionId: 'conn-a' }; const MODEL = 'model-a'; -function usage(anchor?: { - inputTokens: number; - outputTokens?: number; - modelId?: string; - connectionId?: string; -}) { - return { type: 'token_usage', ...(anchor ? { lastRequestAnchor: anchor } : {}) }; +function usage( + anchor?: { + inputTokens: number; + outputTokens?: number; + modelId?: string; + connectionId?: string; + }, + ts?: number, +) { + return { + type: 'token_usage', + ...(ts !== undefined ? { ts } : {}), + ...(anchor ? { lastRequestAnchor: anchor } : {}), + }; +} + +function compactionNote(kind: string, ts?: number) { + return { type: 'system_note', kind, ...(ts !== undefined ? { ts } : {}) }; } test('reads the newest anchor on the active route', () => { - const tokens = selectLatestRequestUsage( + const reading = selectLatestRequestUsage( [ usage({ inputTokens: 10, outputTokens: 2, modelId: MODEL, connectionId: 'conn-a' }), { type: 'assistant' }, @@ -43,14 +57,14 @@ test('reads the newest anchor on the active route', () => { MODEL, ROUTE, ); - assert.equal(tokens, 120); + assert.deepEqual(reading, { kind: 'tokens', tokens: 120 }); }); test('scans past an anchorless usage row, which is what manual compaction writes', () => { // `/compact` appends a synthetic `token_usage` with no anchor. The runtime's // own reader skips it and keeps the last real request; stopping there would - // blank the indicator after every manual compaction. - const tokens = selectLatestRequestUsage( + // read the fold's own record as a count of zero. + const reading = selectLatestRequestUsage( [ usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), usage(), @@ -58,37 +72,85 @@ test('scans past an anchorless usage row, which is what manual compaction writes MODEL, ROUTE, ); - assert.equal(tokens, 120); + assert.deepEqual(reading, { kind: 'tokens', tokens: 120 }); +}); + +test('a compaction boundary newer than every measurement supersedes it', () => { + // The fold replaced the prompt the newest count described, and nothing has + // measured the replacement. The stale figure must not be shown as a live + // reading of what the session is about to send. + const reading = selectLatestRequestUsage( + [ + usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000), + compactionNote('context_compacted', 2_000), + usage(undefined, 2_100), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 2_000 }); +}); + +test('a measurement newer than the boundary stands, which is the post-fold reading', () => { + const reading = selectLatestRequestUsage( + [ + usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000), + compactionNote('context_compacted', 2_000), + usage({ inputTokens: 30, outputTokens: 5, modelId: MODEL, connectionId: 'conn-a' }, 3_000), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'tokens', tokens: 35 }); +}); + +test('a failed-open fold is not a boundary', () => { + // The fold was refused and the request went out with its full raw history, so + // the measurement behind the note still describes what was sent. + const reading = selectLatestRequestUsage( + [ + usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), + compactionNote('context_compaction_failed_open'), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'tokens', tokens: 120 }); +}); + +test('a boundary with no measurement behind it is still a superseded reading', () => { + const reading = selectLatestRequestUsage([compactionNote('context_compacted')], MODEL, ROUTE); + assert.deepEqual(reading, { kind: 'compacted' }); }); test('refuses an anchor from another model', () => { // A token count is a number in one model's tokenizer. Pairing model A's // count with model B's window produces a precise-looking figure about a // request the user is not making. - const tokens = selectLatestRequestUsage( + const reading = selectLatestRequestUsage( [usage({ inputTokens: 100_000, modelId: 'model-b', connectionId: 'conn-a' })], MODEL, ROUTE, ); - assert.equal(tokens, undefined); + assert.equal(reading, undefined); }); test('refuses an anchor from another connection', () => { - const tokens = selectLatestRequestUsage( + const reading = selectLatestRequestUsage( [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-b' })], MODEL, ROUTE, ); - assert.equal(tokens, undefined); + assert.equal(reading, undefined); }); test('refuses an anchor written before anchors carried their route', () => { - const tokens = selectLatestRequestUsage( + const reading = selectLatestRequestUsage( [usage({ inputTokens: 100, outputTokens: 20 })], MODEL, ROUTE, ); - assert.equal(tokens, undefined); + assert.equal(reading, undefined); }); test('refuses when there is no active route yet', () => { @@ -98,10 +160,101 @@ test('refuses when there is no active route yet', () => { }); test('refuses a non-positive input count', () => { - const tokens = selectLatestRequestUsage( + const reading = selectLatestRequestUsage( [usage({ inputTokens: 0, modelId: MODEL, connectionId: 'conn-a' })], MODEL, ROUTE, ); - assert.equal(tokens, undefined); + assert.equal(reading, undefined); +}); + +test('the snapshot is the reading when it is the newer answer', () => { + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'tokens', tokens: 120 }, + live: { usageTokens: 130, completedAt: 1_500 }, + }), + { kind: 'measured', tokens: 130 }, + ); + // No snapshot at all leaves the anchor standing. + assert.deepEqual( + resolveContextUsage({ latestRequestUsage: { kind: 'tokens', tokens: 120 } }), + { kind: 'measured', tokens: 120 }, + ); + // The snapshot can still vouch when the transcript established nothing. + assert.deepEqual( + resolveContextUsage({ latestRequestUsage: undefined, live: { usageTokens: 130 } }), + { kind: 'measured', tokens: 130 }, + ); + assert.deepEqual(resolveContextUsage({ latestRequestUsage: undefined }), { + kind: 'unavailable', + }); +}); + +test('a boundary supersedes the snapshot it landed after', () => { + // The manual `/compact` case: the snapshot still describes the pre-fold + // prompt, so the gauge says unknown rather than holding that figure. + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'compacted', at: 2_000 }, + live: { usageTokens: 90_000, completedAt: 1_000 }, + }), + { kind: 'stale', reason: 'compaction' }, + ); + assert.deepEqual( + resolveContextUsage({ latestRequestUsage: { kind: 'compacted', at: 2_000 } }), + { kind: 'stale', reason: 'compaction' }, + ); + // An untimed snapshot cannot be shown to be newer than a boundary, and a + // guess in that position is the precise-looking lie this state exists to + // refuse. + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'compacted', at: 2_000 }, + live: { usageTokens: 90_000 }, + }), + { kind: 'stale', reason: 'compaction' }, + ); +}); + +test('a snapshot newer than the boundary is the post-fold reading', () => { + // A mid-turn fold is followed by steps that really do measure the smaller + // prompt, so the gauge recovers without waiting for the turn to end. + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'compacted', at: 2_000 }, + live: { usageTokens: 30_000, completedAt: 2_500 }, + }), + { kind: 'measured', tokens: 30_000 }, + ); +}); + + +test('a selected live measurement carries only its own metered window', () => { + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'tokens', tokens: 120 }, + live: { usageTokens: 130, contextWindow: 1_000, completedAt: 1_500 }, + }), + { kind: 'measured', tokens: 130, meteredWindow: 1_000 }, + ); + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'compacted', at: 2_000 }, + live: { usageTokens: 130, contextWindow: 1_000, completedAt: 1_500 }, + }), + { kind: 'stale', reason: 'compaction' }, + ); +}); + +test('equal or missing boundary times cannot establish a post-fold measurement', () => { + for (const at of [undefined, 2_000]) { + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'compacted', at }, + live: { usageTokens: 130, completedAt: 2_000 }, + }), + { kind: 'stale', reason: 'compaction' }, + ); + } }); diff --git a/apps/desktop/src/main/__tests__/live-context-usage.test.ts b/apps/desktop/src/main/__tests__/live-context-usage.test.ts index 774ffb2f7a5..1c8f5428539 100644 --- a/apps/desktop/src/main/__tests__/live-context-usage.test.ts +++ b/apps/desktop/src/main/__tests__/live-context-usage.test.ts @@ -83,6 +83,7 @@ function scriptedQuery() { describe('liveContextUsageFromDiagnostics', () => { it('maps a matching snapshot onto the gauge, window included', () => { assert.deepEqual(liveContextUsageFromDiagnostics(available(), ROUTE), { + completedAt: 1, usageTokens: 79_436, contextWindow: 128_000, }); @@ -113,7 +114,7 @@ describe('liveContextUsageFromDiagnostics', () => { it('stands alone without a window', () => { assert.deepEqual( liveContextUsageFromDiagnostics(available({ contextWindow: undefined }), ROUTE), - { usageTokens: 79_436 }, + { completedAt: 1, usageTokens: 79_436 }, ); }); }); @@ -136,7 +137,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); // The leading `undefined` is the aim itself: whatever stood on screen // before cannot answer for this target, so it clears before the read. - assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]); tracker.dispose(); }); @@ -182,12 +183,12 @@ describe('createLiveContextUsageTracker', () => { assert.equal(query.pending.length, 1); timer.fire(); assert.equal(query.pending.length, 2); - query.pending[1]!.resolve(available({ inputTokens: 52_000 })); + query.pending[1]!.resolve(available({ inputTokens: 52_000, completedAt: 2 })); await Promise.resolve(); assert.deepEqual(seen, [ undefined, - { usageTokens: 40_000, contextWindow: 128_000 }, - { usageTokens: 52_000, contextWindow: 128_000 }, + { completedAt: 1, usageTokens: 40_000, contextWindow: 128_000 }, + { completedAt: 2, usageTokens: 52_000, contextWindow: 128_000 }, ]); tracker.dispose(); }); @@ -231,7 +232,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); query.pending[0]!.resolve(available({ inputTokens: 10_000 })); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { usageTokens: 60_000, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 60_000, contextWindow: 128_000 }]); tracker.dispose(); }); @@ -258,7 +259,7 @@ describe('createLiveContextUsageTracker', () => { timer.fire(); query.pending[1]!.resolve(available({ inputTokens: 60_000 })); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { usageTokens: 60_000, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 60_000, contextWindow: 128_000 }]); tracker.dispose(); }); @@ -281,7 +282,7 @@ describe('createLiveContextUsageTracker', () => { query.pending[1]!.reject(new Error('host not ready')); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]); tracker.dispose(); }); @@ -303,14 +304,14 @@ describe('createLiveContextUsageTracker', () => { // Switching sessions makes the standing number unanswerable: it must // leave the screen BEFORE the new target's first read lands… tracker.setTarget({ sessionId: 's2', route: ROUTE }); - assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }, undefined]); + assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, undefined]); // …and a rejected first read on the new target keeps it cleared, rather // than pinning the previous session's number in place indefinitely. query.pending[1]!.reject(new Error('host not ready')); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }, undefined]); + assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, undefined]); tracker.dispose(); }); @@ -337,7 +338,7 @@ describe('createLiveContextUsageTracker', () => { query.pending[1]!.reject(new Error('host not ready')); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]); tracker.dispose(); }); @@ -382,7 +383,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); // Aiming, then leaving s1 clears its (never-landed) reading, then s2's // lands; the stale s1 read resolving late must not overwrite it. - assert.deepEqual(seen, [undefined, undefined, { usageTokens: 5_000, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, undefined, { completedAt: 1, usageTokens: 5_000, contextWindow: 128_000 }]); tracker.dispose(); }); @@ -408,7 +409,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); assert.deepEqual(seen, [ undefined, - { usageTokens: 79_436, contextWindow: 128_000 }, + { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, undefined, undefined, ]); diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index ce32b827fe9..2078f8c1900 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -17,27 +17,9 @@ * under the License. */ -/** - * The session's latest provider-counted request, or nothing. - * - * A token count belongs to one request on one route: it is a number in that - * model's tokenizer, and it is only the session's latest if nothing newer - * exists. The runtime enforces both when it reads an anchor back, refusing one - * whose run header names another model or connection. A control that shows the - * number has to enforce the same two facts or it will display a precise-looking - * figure about a request the user is not making — model A's tokens against - * model B's window, or a historical range's usage presented as current. - * - * So this refuses rather than approximates, and the three refusals are the - * three normal states that break the pairing: - * - * - the loaded transcript range is not the session tail, so a newer request may - * exist that this range cannot see; - * - the newest usage row carries no anchor, which is what manual `/compact` - * writes, so the scan continues past it exactly as the runtime's does; - * - the anchor names a different route than the active one, or names none at - * all because it was written before anchors carried their route. - */ +import type { ContextUsageReading } from '@maka/ui'; +import type { LiveContextUsage } from './live-context-usage.js'; + export interface LatestRequestUsageAnchor { inputTokens: number; outputTokens?: number; @@ -45,22 +27,77 @@ export interface LatestRequestUsageAnchor { connectionId?: string; } +export interface LatestRequestUsageRow { + readonly type: string; + readonly ts?: number; + readonly kind?: string; + readonly lastRequestAnchor?: LatestRequestUsageAnchor; +} + +export type LatestRequestUsage = + | { readonly kind: 'tokens'; readonly tokens: number } + | { readonly kind: 'compacted'; readonly at?: number } + | undefined; + +/** + * Read the newest route-matching measurement or compaction from the session tail. + * Anchorless usage rows (including manual compaction usage) carry no measurement. + * A compaction invalidates earlier measurements until a later request settles. + */ export function selectLatestRequestUsage( - messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[], + messages: readonly LatestRequestUsageRow[], model: string | undefined, route: { llmConnectionId?: string } | undefined, -): number | undefined { +): LatestRequestUsage { const connectionId = route?.llmConnectionId; - if (model === undefined || connectionId === undefined) return undefined; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; + // Ledger order decides whether the latest anchor has been superseded. + if (message?.type === 'system_note' && message.kind === 'context_compacted') { + return { kind: 'compacted', ...(message.ts !== undefined ? { at: message.ts } : {}) }; + } if (message?.type !== 'token_usage') continue; const anchor = message.lastRequestAnchor; if (!anchor) continue; + if (model === undefined || connectionId === undefined) return undefined; if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined; if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined; const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0; - return anchor.inputTokens + output; + return { + kind: 'tokens', + tokens: anchor.inputTokens + output, + }; } return undefined; } + +/** + * Prefer the per-request snapshot to the turn-end anchor. A known compaction + * suppresses snapshots that cannot be shown to postdate its transcript note. + * This preserves the existing timestamp policy; the note may be recorded later + * than the actual fold, so it is not a causal checkpoint identifier. + */ +export function resolveContextUsage(input: { + readonly latestRequestUsage: LatestRequestUsage; + readonly live?: LiveContextUsage; +}): ContextUsageReading { + const { latestRequestUsage, live } = input; + if ( + latestRequestUsage?.kind === 'compacted' && + (live?.completedAt === undefined || + latestRequestUsage.at === undefined || + latestRequestUsage.at >= live.completedAt) + ) { + return { kind: 'stale', reason: 'compaction' }; + } + if (live) { + return { + kind: 'measured', + tokens: live.usageTokens, + ...(live.contextWindow !== undefined ? { meteredWindow: live.contextWindow } : {}), + }; + } + if (latestRequestUsage?.kind === 'tokens') + return { kind: 'measured', tokens: latestRequestUsage.tokens }; + return { kind: 'unavailable' }; +} diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts index e94a6f08a28..b67a9de940f 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts @@ -44,6 +44,14 @@ export interface LiveContextUsage { readonly usageTokens: number; /** The window the request was metered against, frozen at call time. */ readonly contextWindow?: number; + /** + * When that request settled, on the Host's clock — the same clock the + * session's own transcript rows carry, so a reader can tell whether this + * snapshot predates a compaction boundary it already knows about. Without + * it, a snapshot that a fold has replaced is indistinguishable from one + * taken after it. + */ + readonly completedAt?: number; } /** @@ -56,6 +64,10 @@ export interface LiveContextUsage { * numerator by the same row's denominator exactly as the inspector's bar * does — a window from the live catalog could disagree with the metered * request, while a user-declared override still wins by design. + * + * The settlement time rides along for the same reason the window does: it + * belongs to this request alone, and a reader comparing this reading against a + * compaction boundary has no other row to get it from. */ export function liveContextUsageFromDiagnostics( diagnostics: ContextDiagnosticsResult | undefined, @@ -75,6 +87,7 @@ export function liveContextUsageFromDiagnostics( ...(diagnostics.contextWindow !== undefined ? { contextWindow: diagnostics.contextWindow } : {}), + completedAt: diagnostics.completedAt, }; } diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index b8d61e0da15..6d0d5da9cc6 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -31,7 +31,11 @@ import { } from '@maka/ui'; import { StagedComposer, type ComposerStagingProp } from './features/conversation/index.js'; import type { ComposerHandle } from '@maka/ui'; -export { selectLatestRequestUsage } from './application/contracts/session-inspector/latest-request-usage.js'; +import { + resolveContextUsage, + type LatestRequestUsage, +} from './application/contracts/session-inspector/latest-request-usage.js'; +import type { LiveContextUsage } from './application/contracts/session-inspector/live-context-usage.js'; import { useComposerMentionsContext } from './composer-mentions.js'; import type { GuestComposerProjection } from './features/session-collaboration/index.js'; import { @@ -124,14 +128,14 @@ interface ChatComposerRegionProps boundaryUnreadableNotice?: BoundaryUnreadableNotice; /** * Tokens the provider counted for the session's latest request on the active - * route, or nothing when that cannot be established. Resolved by the owner, - * which knows the transcript range and the route; this control never derives - * it from the rendered slice. This is the per-turn anchor: it moves when a - * turn's usage record lands. `LiveContextUsageProbe` overlays the - * per-settled-request snapshot (#4717) whenever that snapshot can vouch for - * the same route, and this value is the fallback when it cannot. + * route, or nothing when that cannot be established, or a compaction + * boundary that superseded it. Resolved by the owner, which knows the + * transcript range and the route; this control never derives it from the + * rendered slice. `LiveContextUsageProbe` overlays the per-settled-request + * snapshot (#4717) whenever that snapshot is the newer answer to the same + * question, and this value is the fallback when it is not. */ - latestRequestUsageTokens?: number; + latestRequestUsage?: LatestRequestUsage; onOpenContextUsage(): void; /** * The live overlay for the gauge (#4717), injected rather than imported: @@ -151,7 +155,7 @@ interface ChatComposerRegionProps * ceiling. */ children: ( - usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, + usage: LiveContextUsage | undefined, ) => ReactNode; }>; canStageContext: boolean; @@ -176,7 +180,7 @@ export function ChatComposerRegion({ respondToUserForm, stop, boundaryUnreadableNotice, - latestRequestUsageTokens, + latestRequestUsage, onOpenContextUsage, LiveContextUsageProbe, canStageContext, @@ -200,14 +204,6 @@ export function ChatComposerRegion({ choice.model === composerRest.activeModel, ) : undefined; - const contextUsage = activeId - ? { - usageTokens: latestRequestUsageTokens, - declaredContextWindow: activeModelChoice?.declaredContextWindow, - metadataContextWindow: activeModelChoice?.contextWindow, - onOpen: onOpenContextUsage, - } - : undefined; const previousNewTaskDraftKey = useRef(newTaskDraftKey); useLayoutEffect(() => { const previous = previousNewTaskDraftKey.current; @@ -269,49 +265,57 @@ export function ChatComposerRegion({ // — when mounted — can feed it the per-settled-request snapshot (#4717), and // the anchor prop remains the reading it falls back to. const renderComposer = ( - liveContextUsage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, - ) => ( - - {(goalProjection) => ( - - ); + liveContextUsage: LiveContextUsage | undefined, + ) => { + // One question, two answers, and a fold can make the finer one stale: the + // snapshot wins when it landed after the boundary, and the boundary wins + // when it did not. + const reading = resolveContextUsage({ latestRequestUsage, live: liveContextUsage }); + const contextUsage = activeId + ? { + reading, + declaredContextWindow: activeModelChoice?.declaredContextWindow, + metadataContextWindow: activeModelChoice?.contextWindow, + onOpen: onOpenContextUsage, + } + : undefined; + return ( + + {(goalProjection) => ( + + ); + }; return ( <> diff --git a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts index d488d7f9bc6..abb35ef92a6 100644 --- a/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts +++ b/apps/desktop/src/renderer/features/conversation/model/conversation-workspace.ts @@ -128,8 +128,18 @@ export function createConversationWorkspace(catalog: SessionCatalogController, s ui, activeIdRef, transcriptRangeRef, observationRef, bootstrapSelectionLease, commands, publishedSession: Object.freeze({ get current() { return activeIdRef.current; } }), messages: reader((value) => value.messages), - usage: (model: string | undefined, connectionId: string | undefined) => reader((value) => - selectLatestRequestUsage(value.messages, model, { llmConnectionId: connectionId })), + usage: (model: string | undefined, connectionId: string | undefined) => { + let messages: readonly StoredMessage[] | undefined; + let usage: ReturnType; + return reader((value) => { + // Keep the external-store snapshot stable until its transcript changes. + if (messages !== value.messages) { + messages = value.messages; + usage = selectLatestRequestUsage(messages, model, { llmConnectionId: connectionId }); + } + return usage; + }); + }, publication: reader((value) => value), target: reader((value) => value.sessionId), chrome: reader((value) => ({ diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx index c2180ac5013..fa71d8033ee 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -35,6 +35,7 @@ import { useAppShellTurnPresentation } from '../../../application/contracts/turn import { getShellCopy } from '../../../locales/shell-copy.js'; import { composerTurnGates, desktopComposerSlashCommands } from '../model/composer-turn-gates.js'; import { executorComposerProps } from '../model/executor-composer.js'; +import type { LatestRequestUsage } from '../../../application/contracts/session-inspector/latest-request-usage.js'; import { liveTurnFlags } from '../model/live-turn-flags.js'; /** The displayed Session's Turn, read at chrome frequency. */ @@ -128,7 +129,7 @@ type TurnGatedProps = ReturnType & ReturnType['composer']; - processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsageTokens?: number; + processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsage?: LatestRequestUsage; /** The owner Session's open interaction; the Composer slot answers it. */ activeInteraction: ComposerInteraction | undefined; queuedMessages: UiComposerProps['queuedMessages']; @@ -175,7 +176,7 @@ export function ConversationComposerRegion

( const { selection: executor, ...executorInput } = executorComposer; const view = useSyncExternalStore(workspace.composer.subscribe, workspace.composer.getSnapshot); const usage = useMemo(() => workspace.usage(usageModel, usageRoute?.llmConnectionId), [workspace, usageModel, usageRoute?.llmConnectionId]); - const latestRequestUsageTokens = useSyncExternalStore(usage.subscribe, usage.getSnapshot); + const latestRequestUsage = useSyncExternalStore(usage.subscribe, usage.getSnapshot); const stopPending = useSessionUiRead(workspace.ui.reads, 'stop', activeId); const draft = submission.revisionDraft; const editing = draft !== null && activeId === draft.draftSessionId; @@ -221,7 +222,7 @@ export function ConversationComposerRegion

( : {}), processing: view.transientMessages.length > 0, pendingMessages: view.transientMessages, - latestRequestUsageTokens, + latestRequestUsage, }; return createElement(surface, { ...presentation, ...owned } as unknown as P); } diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index fcdd8cb038e..e6eb0d74eae 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx @@ -22,7 +22,10 @@ import { ChatSurfaceLayout, UserQuestionPrompt, MakaWordmark, useUiLocale, type import { Button, IconButton } from '@astryxdesign/core'; import { ChevronDown, PictureInPicture2, Undo2, X } from '@maka/ui/icons'; import { useLiveContextUsage } from '../../../application/contracts/session-inspector/use-live-context-usage.js'; -import { selectLatestRequestUsage } from '../../../application/contracts/session-inspector/latest-request-usage.js'; +import { + resolveContextUsage, + selectLatestRequestUsage, +} from '../../../application/contracts/session-inspector/latest-request-usage.js'; import { WorkHubProgressCard } from './workhub-progress-card.js'; import { completedWorkHubDraft } from '../model/wn-draft.js'; import { WorkHubComposer } from './workhub-composer.js'; @@ -109,10 +112,19 @@ export function WorkHubRoot() { ); const thinkingLevels = newWorkModelChoice?.thinkingLevels ?? []; const liveContextUsage = useLiveContextUsage({ inspector: services.inspector, sessionId: controller.sessionId, model: session?.model, providerType: coordinationModelChoice?.providerType }); + const contextUsageReading = useMemo( + () => + resolveContextUsage({ + latestRequestUsage: selectLatestRequestUsage(transcript.messages, session?.model, session), + live: liveContextUsage, + }), + [transcript.messages, session, liveContextUsage], + ); const thinkingLevel = session?.thinkingLevel && thinkingLevels.includes(session?.thinkingLevel) ? session?.thinkingLevel : undefined; + const locale = useUiLocale(); const t = workHubLiveCopy[locale]; const shortcutLabel = navigator.platform.toLowerCase().includes('mac') ? '⌘⇧K' : 'Ctrl+Shift+K'; @@ -370,9 +382,8 @@ export function WorkHubRoot() { : undefined} modelSwitchAvailability={controller.configuringModel ? { available: false, pending: true, reason: 'pending' } : undefined} contextUsage={session ? { - usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, session.model, session), + reading: contextUsageReading, declaredContextWindow: coordinationModelChoice?.declaredContextWindow, - meteredContextWindow: liveContextUsage?.contextWindow, metadataContextWindow: coordinationModelChoice?.contextWindow, onOpen: () => call(services.presentation.openUsage()), } : undefined} diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index dc9ea891112..0d57cb9db71 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -3043,7 +3043,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { turns={12} composer={{ contextUsage: { - usageTokens: 37_000, + reading: { kind: 'measured', tokens: 37_000 }, declaredContextWindow: 100_000, onOpen: noop, }, @@ -4453,7 +4453,7 @@ export const NarrowComposerFooter: Story = { planModeActive: true, orchestrationMode: 'swarm', contextUsage: { - usageTokens: 100_000, + reading: { kind: 'measured', tokens: 100_000 }, declaredContextWindow: 100_000, onOpen: noop, }, diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index 9f6c229f404..f11cb531fe0 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -23,6 +23,7 @@ import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { Composer } from '../composer.js'; +import type { ContextUsageReading } from '../context-usage-reading.js'; import { LocaleProvider } from '../locale-context.js'; test('the context usage action opens its host trace surface', async () => { @@ -49,7 +50,7 @@ test('the context usage action opens its host trace surface', async () => { await act(() => root.render( { opened = true; } }} + contextUsage={{ reading: { kind: 'unavailable' }, onOpen: () => { opened = true; } }} onSend={() => undefined} onStop={() => undefined} /> @@ -99,9 +100,8 @@ test('the context usage share resolves declared, then metered, then metadata win const render = async ( contextUsage: { - usageTokens?: number; + reading: ContextUsageReading; declaredContextWindow?: number; - meteredContextWindow?: number; metadataContextWindow?: number; }, ) => { @@ -125,9 +125,8 @@ test('the context usage share resolves declared, then metered, then metadata win // The user's declaration wins over every reported window. assert.equal( await render({ - usageTokens: 40_000, + reading: { kind: 'measured', tokens: 40_000, meteredWindow: 80_000 }, declaredContextWindow: 100_000, - meteredContextWindow: 80_000, metadataContextWindow: 64_000, }), '40%', @@ -135,13 +134,18 @@ test('the context usage share resolves declared, then metered, then metadata win // The metered window was frozen against the same request as the tokens, // so it outranks the catalog's metadata window. assert.equal( - await render({ usageTokens: 40_000, meteredContextWindow: 80_000, metadataContextWindow: 64_000 }), + await render({ reading: { kind: 'measured', tokens: 40_000, meteredWindow: 80_000 }, metadataContextWindow: 64_000 }), '50%', ); // Metadata is the fallback… - assert.equal(await render({ usageTokens: 32_000, metadataContextWindow: 64_000 }), '50%'); + assert.equal(await render({ reading: { kind: 'measured', tokens: 32_000 }, metadataContextWindow: 64_000 }), '50%'); // …and with no window at all the usage stands alone, no invented share. - assert.equal(await render({ usageTokens: 40_000 }), 'Usage'); + assert.equal(await render({ reading: { kind: 'measured', tokens: 40_000 } }), 'Usage'); + // A superseded reading keeps the usage entry label even when a window is known. + assert.equal(await render({ reading: { kind: 'stale', reason: 'compaction' }, declaredContextWindow: 100_000 }), 'Usage'); + // A later successful measurement restores the share in the same mounted control. + assert.equal(await render({ reading: { kind: 'measured', tokens: 10_000, meteredWindow: 100_000 } }), '10%'); + assert.equal(await render({ reading: { kind: 'unavailable' }, declaredContextWindow: 100_000 }), 'Usage'); } finally { await act(() => root.unmount()); Object.assign(globalThis, original); diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index d2c9b71f39a..a467e330d6e 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -33,6 +33,7 @@ import { type KeyboardEvent, type ReactNode, } from 'react'; +import type { ContextUsageReading } from './context-usage-reading.js'; import type { LucideIcon } from './icons.js'; import { useMountedRef } from './use-mounted-ref.js'; import { isAppleShortcutPlatform } from './utils.js'; @@ -476,14 +477,8 @@ export const Composer = forwardRef< noModelHint?: string; /** Read-only usage indicator for the active model's latest request. */ contextUsage?: { - usageTokens?: number; + reading: ContextUsageReading; declaredContextWindow?: number; - /** - * The window the usage number was metered against, frozen at call time. - * When present it outranks the metadata window, so a live reading keeps - * its numerator and denominator from the same request. - */ - meteredContextWindow?: number; metadataContextWindow?: number; /** Open the Host-owned trace surface for this readout. */ onOpen(): void; @@ -2663,13 +2658,15 @@ export const Composer = forwardRef< }); function ContextUsageAction(props: { - usageTokens?: number; + reading: ContextUsageReading; declaredContextWindow?: number; - meteredContextWindow?: number; metadataContextWindow?: number; onOpen(): void; }) { const copy = getConversationCopy(useUiLocale()).messages; + const { reading } = props; + const usageTokens = reading.kind === 'measured' ? reading.tokens : undefined; + const meteredWindow = reading.kind === 'measured' ? reading.meteredWindow : undefined; // A window from any source is enough to show a share, and the order is a // claim about which window the number was earned against: the user's // declaration first — it is the user's intent, and the only one that arms @@ -2678,17 +2675,18 @@ function ContextUsageAction(props: { // same request, and only then the model's reported metadata. With no window // at all the usage stands on its own. const window = - props.declaredContextWindow ?? props.meteredContextWindow ?? props.metadataContextWindow; - const label = - props.usageTokens !== undefined && window !== undefined && window > 0 - ? `${Math.round((props.usageTokens / window) * 100)}%` + props.declaredContextWindow ?? meteredWindow ?? props.metadataContextWindow; + // Without a current measurement, keep the usage entry label. + const label = usageTokens !== undefined && window !== undefined && window > 0 + ? `${Math.round((usageTokens / window) * 100)}%` : copy.systemNotes.contextUsageLabel; - const tooltip = - props.usageTokens === undefined + const tooltip = reading.kind === 'stale' + ? copy.systemNotes.contextUsageCompacted + : usageTokens === undefined ? copy.systemNotes.contextUsageUnavailable : window !== undefined && window > 0 - ? copy.systemNotes.contextUsageShare(props.usageTokens, window) - : copy.systemNotes.contextUsageNoWindow(props.usageTokens); + ? copy.systemNotes.contextUsageShare(usageTokens, window) + : copy.systemNotes.contextUsageNoWindow(usageTokens); return ( string; contextUsageNoWindow: (used: number) => string; contextUsageUnavailable: string; + contextUsageCompacted: string; contextUsageOpen: string; stepLimit: string; }; @@ -540,6 +541,7 @@ const CONVERSATION_COPY = { `上下文:${Math.round((used / window) * 100)}%(${formatCompactTokenCount(used)} / ${formatCompactTokenCount(window)} token)`, contextUsageNoWindow: (used) => `上下文:${formatCompactTokenCount(used)} token`, contextUsageUnavailable: '暂无上下文用量数据', + contextUsageCompacted: '上下文已压缩,用量将在下一次请求完成后更新。', contextUsageOpen: '打开用量追踪', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', }, @@ -663,6 +665,7 @@ const CONVERSATION_COPY = { `上下文:${Math.round((used / window) * 100)}%(${formatCompactTokenCount(used)} / ${formatCompactTokenCount(window)} token)`, contextUsageNoWindow: (used) => `上下文:${formatCompactTokenCount(used)} token`, contextUsageUnavailable: '暫無上下文用量資料', + contextUsageCompacted: '上下文已壓縮,用量將在下一次請求完成後更新。', contextUsageOpen: '開啟用量追蹤', stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。', }, @@ -784,6 +787,8 @@ const CONVERSATION_COPY = { `Context: ${Math.round((used / window) * 100)}% (${formatCompactTokenCount(used)} / ${formatCompactTokenCount(window)} tokens)`, contextUsageNoWindow: (used) => `Context: ${formatCompactTokenCount(used)} tokens`, contextUsageUnavailable: 'No context usage data', + contextUsageCompacted: + 'Context has been compacted. Usage will update when the next request completes.', contextUsageOpen: 'Open usage trace', stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.', }, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 6c4e6ed00ee..afa278279ec 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -39,6 +39,7 @@ export type { export type { SessionMoveTarget } from './session-rail-context.js'; export * from './session-status-presentation.js'; export * from './composer-helpers.js'; +export type { ContextUsageReading } from './context-usage-reading.js'; export * from './conversation-copy.js'; export * from './shared-ui-copy.js'; export * from './skills-copy.js'; From bd93d4a2e678cd4810c8fcd58fa0d215e186876f Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 28 Sep 2026 00:20:21 +0800 Subject: [PATCH 02/11] fix(ui): keep post-compaction anchor over stale snapshot Preserve the selected request anchor timestamp so the context usage resolver can order it against a retained diagnostics snapshot. Cover the post-compaction sequence in resolver tests. Generated-by: Codex --- .../__tests__/latest-request-usage.test.ts | 40 ++++++++++++++++++- .../session-inspector/latest-request-usage.ts | 17 +++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index d2038ed08e8..80e30cbcdcc 100644 --- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -101,7 +101,36 @@ test('a measurement newer than the boundary stands, which is the post-fold readi MODEL, ROUTE, ); - assert.deepEqual(reading, { kind: 'tokens', tokens: 35 }); + assert.deepEqual(reading, { kind: 'tokens', tokens: 35, at: 3_000 }); +}); + +test('a post-fold anchor supersedes a pre-fold snapshot while diagnostics are pending', () => { + const latestRequestUsage = selectLatestRequestUsage( + [ + usage({ inputTokens: 90_000, modelId: MODEL, connectionId: 'conn-a' }, 1_000), + compactionNote('context_compacted', 2_000), + usage({ inputTokens: 35_000, modelId: MODEL, connectionId: 'conn-a' }, 3_000), + ], + MODEL, + ROUTE, + ); + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage, + live: { usageTokens: 90_000, contextWindow: 100_000, completedAt: 1_000 }, + }), + { kind: 'measured', tokens: 35_000 }, + ); +}); + +test('a timed anchor wins when the retained snapshot has no settlement time', () => { + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'tokens', tokens: 35_000, at: 3_000 }, + live: { usageTokens: 90_000 }, + }), + { kind: 'measured', tokens: 35_000 }, + ); }); test('a failed-open fold is not a boundary', () => { @@ -171,7 +200,7 @@ test('refuses a non-positive input count', () => { test('the snapshot is the reading when it is the newer answer', () => { assert.deepEqual( resolveContextUsage({ - latestRequestUsage: { kind: 'tokens', tokens: 120 }, + latestRequestUsage: { kind: 'tokens', tokens: 120, at: 1_000 }, live: { usageTokens: 130, completedAt: 1_500 }, }), { kind: 'measured', tokens: 130 }, @@ -181,6 +210,13 @@ test('the snapshot is the reading when it is the newer answer', () => { resolveContextUsage({ latestRequestUsage: { kind: 'tokens', tokens: 120 } }), { kind: 'measured', tokens: 120 }, ); + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: { kind: 'tokens', tokens: 120, at: 1_500 }, + live: { usageTokens: 130, completedAt: 1_500 }, + }), + { kind: 'measured', tokens: 130 }, + ); // The snapshot can still vouch when the transcript established nothing. assert.deepEqual( resolveContextUsage({ latestRequestUsage: undefined, live: { usageTokens: 130 } }), diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index 2078f8c1900..4c16f9c8cd1 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -35,7 +35,7 @@ export interface LatestRequestUsageRow { } export type LatestRequestUsage = - | { readonly kind: 'tokens'; readonly tokens: number } + | { readonly kind: 'tokens'; readonly tokens: number; readonly at?: number } | { readonly kind: 'compacted'; readonly at?: number } | undefined; @@ -66,16 +66,16 @@ export function selectLatestRequestUsage( return { kind: 'tokens', tokens: anchor.inputTokens + output, + ...(message.ts !== undefined ? { at: message.ts } : {}), }; } return undefined; } /** - * Prefer the per-request snapshot to the turn-end anchor. A known compaction - * suppresses snapshots that cannot be shown to postdate its transcript note. - * This preserves the existing timestamp policy; the note may be recorded later - * than the actual fold, so it is not a causal checkpoint identifier. + * Prefer the newest timed measurement. A known compaction suppresses snapshots + * that cannot be shown to postdate its transcript note. The note may be + * recorded later than the actual fold, so it is not a causal checkpoint identifier. */ export function resolveContextUsage(input: { readonly latestRequestUsage: LatestRequestUsage; @@ -90,6 +90,13 @@ export function resolveContextUsage(input: { ) { return { kind: 'stale', reason: 'compaction' }; } + if ( + latestRequestUsage?.kind === 'tokens' && + latestRequestUsage.at !== undefined && + (live?.completedAt === undefined || latestRequestUsage.at > live.completedAt) + ) { + return { kind: 'measured', tokens: latestRequestUsage.tokens }; + } if (live) { return { kind: 'measured', From eb98306865ecff391e1a5e3959c02fa6cc8f30a1 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 28 Sep 2026 01:05:50 +0800 Subject: [PATCH 03/11] fix(ui): order context usage anchors by request settlement Persist the provider request completion time on usage anchors so a later transcript write cannot displace the same request snapshot and its metered window. Preserve legacy anchors and bump the runtime-host compatibility epoch for the new field. Generated-by: Codex --- .../__tests__/latest-request-usage.test.ts | 38 ++++++++++++++++++- .../session-inspector/latest-request-usage.ts | 5 ++- .../usage-record-last-request-anchor.test.ts | 2 + packages/core/src/usage-record-schema.ts | 6 ++- packages/runtime-host/src/protocol/index.ts | 4 +- .../mid-turn-capacity-backend.test.ts | 5 ++- .../provider-request-telemetry.test.ts | 1 + packages/runtime/src/ai-sdk-turn.ts | 3 ++ .../runtime/src/provider-request-telemetry.ts | 8 ++++ 9 files changed, 66 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index 80e30cbcdcc..38264aae1e4 100644 --- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -33,6 +33,7 @@ function usage( outputTokens?: number; modelId?: string; connectionId?: string; + completedAt?: number; }, ts?: number, ) { @@ -96,7 +97,7 @@ test('a measurement newer than the boundary stands, which is the post-fold readi [ usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000), compactionNote('context_compacted', 2_000), - usage({ inputTokens: 30, outputTokens: 5, modelId: MODEL, connectionId: 'conn-a' }, 3_000), + usage({ inputTokens: 30, outputTokens: 5, modelId: MODEL, connectionId: 'conn-a', completedAt: 3_000 }, 3_100), ], MODEL, ROUTE, @@ -109,11 +110,12 @@ test('a post-fold anchor supersedes a pre-fold snapshot while diagnostics are pe [ usage({ inputTokens: 90_000, modelId: MODEL, connectionId: 'conn-a' }, 1_000), compactionNote('context_compacted', 2_000), - usage({ inputTokens: 35_000, modelId: MODEL, connectionId: 'conn-a' }, 3_000), + usage({ inputTokens: 35_000, modelId: MODEL, connectionId: 'conn-a', completedAt: 2_500 }, 3_000), ], MODEL, ROUTE, ); + assert.deepEqual(latestRequestUsage, { kind: 'tokens', tokens: 35_000, at: 2_500 }); assert.deepEqual( resolveContextUsage({ latestRequestUsage, @@ -123,6 +125,38 @@ test('a post-fold anchor supersedes a pre-fold snapshot while diagnostics are pe ); }); +test('the token row written after its own request keeps the settled snapshot and window', () => { + const latestRequestUsage = selectLatestRequestUsage( + [usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a', completedAt: 1_000 }, 1_100)], + MODEL, + ROUTE, + ); + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage, + live: { usageTokens: 100, contextWindow: 1_000, completedAt: 1_000 }, + }), + { kind: 'measured', tokens: 100, meteredWindow: 1_000 }, + ); +}); + +test('a legacy anchor without settlement time cannot displace a live snapshot', () => { + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage: selectLatestRequestUsage( + [{ + type: 'token_usage', ts: 1_100, + lastRequestAnchor: { inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, + }], + MODEL, + ROUTE, + ), + live: { usageTokens: 100, contextWindow: 1_000, completedAt: 1_000 }, + }), + { kind: 'measured', tokens: 100, meteredWindow: 1_000 }, + ); +}); + test('a timed anchor wins when the retained snapshot has no settlement time', () => { assert.deepEqual( resolveContextUsage({ diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index 4c16f9c8cd1..49a4f31351b 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -23,6 +23,7 @@ import type { LiveContextUsage } from './live-context-usage.js'; export interface LatestRequestUsageAnchor { inputTokens: number; outputTokens?: number; + completedAt?: number; modelId?: string; connectionId?: string; } @@ -66,7 +67,9 @@ export function selectLatestRequestUsage( return { kind: 'tokens', tokens: anchor.inputTokens + output, - ...(message.ts !== undefined ? { at: message.ts } : {}), + // The row is persisted after request settlement (and sometimes after a + // compaction note). Its write time cannot order its own snapshot. + ...(anchor.completedAt !== undefined ? { at: anchor.completedAt } : {}), }; } return undefined; diff --git a/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts index e37c5af83ee..89b5cf32340 100644 --- a/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts +++ b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts @@ -27,6 +27,8 @@ const usage = { input: 370, output: 60 }; test('a last-request anchor accepts the new usage shape and retired payload key', () => { assert.equal(isLastRequestAnchor({ inputTokens: 120, outputTokens: 30 }), true); assert.equal(isLastRequestAnchor({ inputTokens: 120 }), true); + assert.equal(isLastRequestAnchor({ inputTokens: 120, completedAt: 1_000 }), true); + assert.equal(isLastRequestAnchor({ inputTokens: 120, completedAt: -1 }), false); assert.equal(isLastRequestAnchor({ inputTokens: 120, payloadChars: 4_000 }), true); assert.equal(isLastRequestAnchor({ payloadChars: 4_000 }), false); assert.equal(isLastRequestAnchor({ inputTokens: 0, payloadChars: 4_000 }), false); diff --git a/packages/core/src/usage-record-schema.ts b/packages/core/src/usage-record-schema.ts index e86c0bde441..75d7dac5261 100644 --- a/packages/core/src/usage-record-schema.ts +++ b/packages/core/src/usage-record-schema.ts @@ -302,6 +302,8 @@ export interface TokenUsageFields { export interface LastRequestAnchor { inputTokens: number; outputTokens?: number; + /** Settlement time of this provider request, rather than the later token row write. */ + completedAt?: number; /** * The route that produced these counts. * @@ -317,7 +319,7 @@ export interface LastRequestAnchor { const LAST_REQUEST_ANCHOR_SHAPE = defineObjectShape()( ['inputTokens'], - ['outputTokens', 'modelId', 'connectionId'], + ['outputTokens', 'completedAt', 'modelId', 'connectionId'], ['payloadChars'], ); @@ -329,6 +331,8 @@ export function isLastRequestAnchor(value: unknown): value is LastRequestAnchor value.inputTokens > 0 && (value.outputTokens === undefined || (isFiniteNumber(value.outputTokens) && value.outputTokens >= 0)) && + (value.completedAt === undefined || + (isFiniteNumber(value.completedAt) && value.completedAt >= 0)) && (value.payloadChars === undefined || isFiniteNumber(value.payloadChars)) ); } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 65062152885..cc57ac205c1 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -104,7 +104,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 215 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 216 as const; +// 216: token_usage.lastRequestAnchor can carry the provider request settlement +// time. Older peers reject that field in the closed transcript shape. // 215: WorkHub coordination uses canonical configuration and scoped delegated permissions. // 212: Session catalogs carry `backgroundActivity` and `backgroundActivityVersion` // for Host-owned activity and generation-scoped ordering independent of Session diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index add55fad4ba..ba384d26eb3 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -1722,6 +1722,7 @@ describe('the shipped runtime default drives the proactive long-turn journey (is const fixture = buildFixture({ contextWindow: 1_000_000, finalAtSecondCall: true, + meteredSummarizer: true, }); await runFixtureTurn(fixture); @@ -1737,10 +1738,12 @@ describe('the shipped runtime default drives the proactive long-turn journey (is // Two steps: 100 + 120 reported input, and the send sum is both. assert.equal(usage?.input, 220); const anchor = usage?.lastRequestAnchor as - | { inputTokens: number; outputTokens?: number } + | { inputTokens: number; outputTokens?: number; completedAt?: number } | undefined; assert.equal(anchor?.inputTokens, 120); assert.equal(anchor?.outputTokens, 10); + const lastMainAttempt = fixture.modelCalls.filter((call) => call.callKind === 'main').at(-1); + assert.equal(anchor?.completedAt, lastMainAttempt?.completedAt); }); test('an anchor is discarded unless its invocation proves it came from this model', async () => { diff --git a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts index 72fdac6798d..c20d51b1e58 100644 --- a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts +++ b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts @@ -265,6 +265,7 @@ describe('provider request tracker', () => { await drain(result.stream); assert.equal(attempts[0]?.contextWindow, 200_000); + assert.equal(tracker.latestCompletedMainRequestAt, attempts[0]?.completedAt); }); test('omits a non-positive request model context window', async () => { diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 789ed19a061..e29bad58b4a 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -2406,6 +2406,9 @@ export class AiSdkTurn { ? { lastRequestAnchor: { inputTokens: anchorInputTokens, + ...(providerRequestTracker?.latestCompletedMainRequestAt !== undefined + ? { completedAt: providerRequestTracker.latestCompletedMainRequestAt } + : {}), ...(anchorOutputTokens !== undefined ? { outputTokens: anchorOutputTokens } : {}), diff --git a/packages/runtime/src/provider-request-telemetry.ts b/packages/runtime/src/provider-request-telemetry.ts index 05272b153fe..3913dd65924 100644 --- a/packages/runtime/src/provider-request-telemetry.ts +++ b/packages/runtime/src/provider-request-telemetry.ts @@ -428,6 +428,7 @@ function modelCallUsageFields( export class ProviderRequestTracker { private step = 0; + private lastCompletedMainRequestAt: number | undefined; private readonly attemptsByStep = new Map(); /** * One logical call per step. Retries of the same step are further attempts of @@ -442,6 +443,10 @@ export class ProviderRequestTracker { return this.input.traceId; } + get latestCompletedMainRequestAt(): number | undefined { + return this.lastCompletedMainRequestAt; + } + setStep(step: number, requestCompositionId?: string): void { this.step = step; if (requestCompositionId !== undefined) { @@ -617,6 +622,9 @@ export class ProviderRequestTracker { input.abortSignal?.removeEventListener('abort', abortListener); } const completedAt = this.input.now(); + if (status === 'completed' && this.input.accounting?.callKind === 'main') { + this.lastCompletedMainRequestAt = completedAt; + } const usage = strictProviderRequestUsage(finish?.usage); const contextWindow = positiveInteger(this.input.contextWindow); const diagnostic = From 4ee9bc454417bf50446e98a3efe56c8c02c131da Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 5 Oct 2026 01:26:23 +0800 Subject: [PATCH 04/11] docs(changelog): describe the post-compaction gauge fallback The stale reading keeps the gauge chip on its localized Usage label with the compacted-context tooltip; it never rendered a question mark. Align the entry with the shipped ContextUsageAction. Generated-by: Codex --- CHANGELOG.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 948362cc073..37e02c7dc5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,17 +40,19 @@ ### Fixed -- The composer's context gauge now says so instead of holding the pre-compaction - figure after a fold. A compaction replaces the prompt the last measured request - described, and no provider has tokenized the replacement yet, so the newest real - count is stale rather than current — showing it read as a live measurement of - what the session was about to send. The gauge walks the transcript backwards and - whichever fact comes first decides: a `context_compacted` note newer than every - measurement renders `?` with a tooltip saying why, and a measurement newer than - the note stands. The live per-settled-request snapshot still wins when it landed - after the boundary, so a mid-turn fold recovers as soon as the next step settles - rather than waiting for the turn to end. A failed-open fold is not a boundary: - that request went out with its full raw history. +- The composer's context gauge now falls back to its localized `Usage` label + instead of holding the pre-compaction figure after a fold. A compaction + replaces the prompt the last measured request described, and no provider has + tokenized the replacement yet, so the newest real count is stale rather than + current — showing it read as a live measurement of what the session was about + to send. The gauge walks the transcript backwards and whichever fact comes + first decides: a `context_compacted` note newer than every measurement keeps + the gauge chip on `Usage` with a tooltip saying the reading will update when + the next request completes, and a measurement newer than the note stands. The + live per-settled-request snapshot still wins when it landed after the + boundary, so a mid-turn fold recovers as soon as the next step settles rather + than waiting for the turn to end. A failed-open fold is not a boundary: that + request went out with its full raw history. - Fixed a renderer crash dialog reporting React error #185 ("Maximum update depth exceeded") coming from the composer's prompt-history inline completion (#4117): the offer engine the 0.1.11 composer fed could flip-flop its announcement state on From 4b29eba01ab62e6b09bcbf2e794bd58f2b9891ff Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Mon, 5 Oct 2026 01:43:53 +0800 Subject: [PATCH 05/11] docs(ui): explain context usage reading resolution Describe what resolveContextUsage consumes (live Turn snapshot and durable transcript anchors/notes) and the ordering rule it applies after a compaction. Generated-by: Codex --- .../contracts/session-inspector/latest-request-usage.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index 49a4f31351b..505e205351a 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -76,9 +76,11 @@ export function selectLatestRequestUsage( } /** - * Prefer the newest timed measurement. A known compaction suppresses snapshots - * that cannot be shown to postdate its transcript note. The note may be - * recorded later than the actual fold, so it is not a causal checkpoint identifier. + * Resolve the context-usage reading the UI can display from the live Turn's + * latest request snapshot and the durable transcript's token-usage anchors + * and compaction notes. Choose the newest reading whose relative order can + * be established; after a compaction, only a measurement proven to have + * completed later may be shown, otherwise report the usage as stale. */ export function resolveContextUsage(input: { readonly latestRequestUsage: LatestRequestUsage; From e5040b4c5b91079bd57e44493e47f9d81bb0538b Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Tue, 6 Oct 2026 00:25:45 +0800 Subject: [PATCH 06/11] fix(ui): order usage anchors against the compaction apply time Record context_compaction_applied when a compaction lands so the boundary carries the apply time rather than the turn's settlement time, and persist completedAt on the settlement usage anchor even without provider telemetry. A send that compacts mid-turn and never completes its retry settles the usage row after the compaction notes while the anchor still describes the pre-compaction request, so readers arbitrate the newest anchored row against a boundary found behind it by event time. The applied row is hidden from the user-facing notes; context_compacted stays the display row. Refs #5547 Generated-by: Codex --- .../__tests__/latest-request-usage.test.ts | 231 +++++++++++++++++- .../session-inspector/latest-request-usage.ts | 105 +++++++- .../session-inspector/live-context-usage.ts | 2 +- .../src/renderer/chat-composer-region.tsx | 2 +- packages/core/src/session.ts | 6 +- .../mid-turn-capacity-backend.test.ts | 134 ++++++++++ .../overflow-reactive-recovery.test.ts | 61 ++++- .../src/__tests__/session-manager.test.ts | 8 + packages/runtime/src/ai-sdk-compaction.ts | 99 +++++++- packages/runtime/src/ai-sdk-turn.ts | 25 +- packages/runtime/src/runtime-kernel.ts | 3 + packages/ui/src/__tests__/materialize.test.ts | 25 ++ 12 files changed, 667 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index 38264aae1e4..069e582582b 100644 --- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -48,6 +48,24 @@ function compactionNote(kind: string, ts?: number) { return { type: 'system_note', kind, ...(ts !== undefined ? { ts } : {}) }; } +function appliedNote(ts?: number, turnId?: string) { + return { + type: 'system_note', + kind: 'context_compaction_applied', + ...(ts !== undefined ? { ts } : {}), + ...(turnId !== undefined ? { turnId } : {}), + }; +} + +function displayNote(ts?: number, turnId?: string) { + return { + type: 'system_note', + kind: 'context_compacted', + ...(ts !== undefined ? { ts } : {}), + ...(turnId !== undefined ? { turnId } : {}), + }; +} + test('reads the newest anchor on the active route', () => { const reading = selectLatestRequestUsage( [ @@ -64,7 +82,7 @@ test('reads the newest anchor on the active route', () => { test('scans past an anchorless usage row, which is what manual compaction writes', () => { // `/compact` appends a synthetic `token_usage` with no anchor. The runtime's // own reader skips it and keeps the last real request; stopping there would - // read the fold's own record as a count of zero. + // read the compaction's own record as a count of zero. const reading = selectLatestRequestUsage( [ usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), @@ -77,7 +95,7 @@ test('scans past an anchorless usage row, which is what manual compaction writes }); test('a compaction boundary newer than every measurement supersedes it', () => { - // The fold replaced the prompt the newest count described, and nothing has + // The compaction replaced the prompt the newest count described, and nothing has // measured the replacement. The stale figure must not be shown as a live // reading of what the session is about to send. const reading = selectLatestRequestUsage( @@ -92,7 +110,7 @@ test('a compaction boundary newer than every measurement supersedes it', () => { assert.deepEqual(reading, { kind: 'compacted', at: 2_000 }); }); -test('a measurement newer than the boundary stands, which is the post-fold reading', () => { +test('a measurement newer than the boundary stands, which is the post-compaction reading', () => { const reading = selectLatestRequestUsage( [ usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000), @@ -105,7 +123,7 @@ test('a measurement newer than the boundary stands, which is the post-fold readi assert.deepEqual(reading, { kind: 'tokens', tokens: 35, at: 3_000 }); }); -test('a post-fold anchor supersedes a pre-fold snapshot while diagnostics are pending', () => { +test('a post-compaction anchor supersedes a pre-compaction snapshot while diagnostics are pending', () => { const latestRequestUsage = selectLatestRequestUsage( [ usage({ inputTokens: 90_000, modelId: MODEL, connectionId: 'conn-a' }, 1_000), @@ -167,8 +185,8 @@ test('a timed anchor wins when the retained snapshot has no settlement time', () ); }); -test('a failed-open fold is not a boundary', () => { - // The fold was refused and the request went out with its full raw history, so +test('a failed-open compaction is not a boundary', () => { + // The compaction was refused and the request went out with its full raw history, so // the measurement behind the note still describes what was sent. const reading = selectLatestRequestUsage( [ @@ -186,6 +204,199 @@ test('a boundary with no measurement behind it is still a superseded reading', ( assert.deepEqual(reading, { kind: 'compacted' }); }); +test('an apply-time boundary row supersedes with its own write time', () => { + // Mid-turn compactions record `context_compaction_applied` when the compaction lands, + // so the boundary time is the compaction moment even mid-turn. + const reading = selectLatestRequestUsage( + [ + usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000), + appliedNote(1_500, 'turn-1'), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 1_500 }); +}); + +test('a settlement display note adopts the apply time of the compaction it describes', () => { + // The mid-turn case: `context_compaction_applied` is written when the compaction + // lands and `context_compacted` at settlement. The boundary time is the + // compaction's, so post-compaction measurements settling before the turn ends are not + // misjudged as pre-compaction. + const reading = selectLatestRequestUsage( + [ + usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }, 1_000), + appliedNote(1_500, 'turn-1'), + displayNote(9_000, 'turn-1'), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 1_500 }); +}); + +test('the latest apply row wins when compaction is applied twice in one turn', () => { + const reading = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + appliedNote(4_000, 'turn-1'), + displayNote(9_000, 'turn-1'), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 4_000 }); +}); + +test('an apply row from another turn does not lend the note its time', () => { + const reading = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + displayNote(9_000, 'turn-2'), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 9_000 }); +}); + +test('a usage row between the apply and the note keeps the note time', () => { + // Cannot arise in written data — the turn's usage row lands after the + // note — but the hunt stops at it rather than reaching across turns. + const reading = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + usage({ inputTokens: 30, modelId: MODEL, connectionId: 'conn-a', completedAt: 2_000 }, 2_100), + displayNote(9_000, 'turn-1'), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 9_000 }); +}); + +test('a usage row settled after the notes but anchored before the compaction is superseded (#5547)', () => { + // The failed-send settlement order: the apply row, then the display note, + // then the usage row — whose anchor is still the last COMPLETED request, + // which finished before the compaction because its retry never did. Position + // alone would misread the anchor as a post-compaction measurement. + const reading = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + displayNote(9_000, 'turn-1'), + usage( + { + inputTokens: 100, + outputTokens: 20, + modelId: MODEL, + connectionId: 'conn-a', + completedAt: 1_000, + }, + 9_100, + ), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 1_500 }); +}); + +test('a post-compaction measurement settled after the notes still stands', () => { + // Same ledger shape, healthy send: the retry completed after the compaction, so + // the anchor is genuinely post-compaction and stays the newest reading. + const reading = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + displayNote(9_000, 'turn-1'), + usage( + { inputTokens: 30, modelId: MODEL, connectionId: 'conn-a', completedAt: 2_000 }, + 9_100, + ), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'tokens', tokens: 30, at: 2_000 }); +}); + +test('a boundary tied with the anchored completion cannot prove the anchor is post-compaction', () => { + const reading = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + usage( + { inputTokens: 30, modelId: MODEL, connectionId: 'conn-a', completedAt: 1_500 }, + 9_100, + ), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'compacted', at: 1_500 }); +}); + +test('an anchor without a completion time cannot be superseded by a boundary behind it', () => { + const reading = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + usage({ inputTokens: 30, modelId: MODEL, connectionId: 'conn-a' }, 9_100), + ], + MODEL, + ROUTE, + ); + assert.deepEqual(reading, { kind: 'tokens', tokens: 30 }); +}); + +test('the failed-retry ledger reads stale, not the pre-compaction measurement (#5547)', () => { + // End to end: the send compacted at 1_500 and its retry never completed, so the + // live snapshot and the settled anchor both describe the request that + // finished at 1_000 — pre-compaction context. The gauge must report stale. + const latestRequestUsage = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + displayNote(9_000, 'turn-1'), + usage( + { + inputTokens: 190_000, + modelId: MODEL, + connectionId: 'conn-a', + completedAt: 1_000, + }, + 9_100, + ), + ], + MODEL, + ROUTE, + ); + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage, + live: { usageTokens: 190_000, contextWindow: 200_000, completedAt: 1_000 }, + }), + { kind: 'stale', reason: 'compaction' }, + ); +}); + +test('a post-compaction snapshot is measured against the apply time, not settlement', () => { + // The bug this event fixes: the compaction landed at 1_500, a later request + // settled at 2_000, and the turn itself settled at 9_000. Reading the + // display note's own write time would hide the valid post-compaction snapshot. + const latestRequestUsage = selectLatestRequestUsage( + [ + appliedNote(1_500, 'turn-1'), + displayNote(9_000, 'turn-1'), + ], + MODEL, + ROUTE, + ); + assert.deepEqual( + resolveContextUsage({ + latestRequestUsage, + live: { usageTokens: 30_000, contextWindow: 100_000, completedAt: 2_000 }, + }), + { kind: 'measured', tokens: 30_000, meteredWindow: 100_000 }, + ); +}); + test('refuses an anchor from another model', () => { // A token count is a number in one model's tokenizer. Pairing model A's // count with model B's window produces a precise-looking figure about a @@ -262,7 +473,7 @@ test('the snapshot is the reading when it is the newer answer', () => { }); test('a boundary supersedes the snapshot it landed after', () => { - // The manual `/compact` case: the snapshot still describes the pre-fold + // The manual `/compact` case: the snapshot still describes the pre-compaction // prompt, so the gauge says unknown rather than holding that figure. assert.deepEqual( resolveContextUsage({ @@ -287,8 +498,8 @@ test('a boundary supersedes the snapshot it landed after', () => { ); }); -test('a snapshot newer than the boundary is the post-fold reading', () => { - // A mid-turn fold is followed by steps that really do measure the smaller +test('a snapshot newer than the boundary is the post-compaction reading', () => { + // A mid-turn compaction is followed by steps that really do measure the smaller // prompt, so the gauge recovers without waiting for the turn to end. assert.deepEqual( resolveContextUsage({ @@ -317,7 +528,7 @@ test('a selected live measurement carries only its own metered window', () => { ); }); -test('equal or missing boundary times cannot establish a post-fold measurement', () => { +test('equal or missing boundary times cannot establish a post-compaction measurement', () => { for (const at of [undefined, 2_000]) { assert.deepEqual( resolveContextUsage({ diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index 505e205351a..fc9d46f4b34 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -32,6 +32,7 @@ export interface LatestRequestUsageRow { readonly type: string; readonly ts?: number; readonly kind?: string; + readonly turnId?: string; readonly lastRequestAnchor?: LatestRequestUsageAnchor; } @@ -44,6 +45,12 @@ export type LatestRequestUsage = * Read the newest route-matching measurement or compaction from the session tail. * Anchorless usage rows (including manual compaction usage) carry no measurement. * A compaction invalidates earlier measurements until a later request settles. + * + * Ledger position decides the scan order, but the candidate is arbitrated by + * event time: settlement persists the usage row AFTER the compaction notes even + * when the anchored request completed BEFORE the compaction — its retry never + * finished (#5547) — so a boundary row behind the newest anchored row can still + * supersede it when the compaction's apply time postdates the anchor's completion. */ export function selectLatestRequestUsage( messages: readonly LatestRequestUsageRow[], @@ -51,27 +58,109 @@ export function selectLatestRequestUsage( route: { llmConnectionId?: string } | undefined, ): LatestRequestUsage { const connectionId = route?.llmConnectionId; + // The newest anchored usage row, held while the scan behind it looks for a + // boundary that postdates its completion. A second anchored row settles it: + // under ordered writes every boundary behind that row is strictly older. + let pendingTokens: + | { + readonly reading: { + readonly kind: 'tokens'; + readonly tokens: number; + readonly at?: number; + }; + readonly completedAt?: number; + } + | undefined; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; - // Ledger order decides whether the latest anchor has been superseded. + if (message?.type === 'system_note' && message.kind === 'context_compaction_applied') { + // Written the moment the compaction is applied, so its row time IS the + // boundary time — mid-turn compactions land it mid-turn, not at settlement. + if (!pendingTokens) { + return { kind: 'compacted', ...(message.ts !== undefined ? { at: message.ts } : {}) }; + } + return orderTokensAgainstBoundary(pendingTokens, message.ts); + } if (message?.type === 'system_note' && message.kind === 'context_compacted') { - return { kind: 'compacted', ...(message.ts !== undefined ? { at: message.ts } : {}) }; + // The settlement-time display row: the compaction it describes was applied + // earlier in the same turn and recorded its own boundary row. + const appliedAt = latestCompactionAppliedAt(messages, index, message.turnId); + const at = appliedAt ?? message.ts; + if (!pendingTokens) { + return { kind: 'compacted', ...(at !== undefined ? { at } : {}) }; + } + return orderTokensAgainstBoundary(pendingTokens, at); } if (message?.type !== 'token_usage') continue; const anchor = message.lastRequestAnchor; if (!anchor) continue; + if (pendingTokens) return pendingTokens.reading; if (model === undefined || connectionId === undefined) return undefined; if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined; if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined; const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0; - return { - kind: 'tokens', - tokens: anchor.inputTokens + output, - // The row is persisted after request settlement (and sometimes after a - // compaction note). Its write time cannot order its own snapshot. - ...(anchor.completedAt !== undefined ? { at: anchor.completedAt } : {}), + pendingTokens = { + completedAt: anchor.completedAt, + reading: { + kind: 'tokens', + tokens: anchor.inputTokens + output, + // The row is persisted after request settlement (and sometimes after a + // compaction note). Its write time cannot order its own snapshot. + ...(anchor.completedAt !== undefined ? { at: anchor.completedAt } : {}), + }, }; } + return pendingTokens?.reading; +} + +/** + * Arbitration between the position-newest anchored usage row and a boundary + * row found behind it. The compaction supersedes the measurement only when its + * apply time provably postdates the anchored request's completion; a missing + * completion or boundary time cannot establish that order, so the candidate + * stands. + */ +function orderTokensAgainstBoundary( + pendingTokens: { + readonly reading: { readonly kind: 'tokens'; readonly tokens: number; readonly at?: number }; + readonly completedAt?: number; + }, + boundaryAt: number | undefined, +): LatestRequestUsage { + if ( + pendingTokens.completedAt !== undefined && + boundaryAt !== undefined && + boundaryAt >= pendingTokens.completedAt + ) { + return { kind: 'compacted', at: boundaryAt }; + } + return pendingTokens.reading; +} + +/** + * Find the apply-time boundary behind a settlement `context_compacted` row. + * The display note is written when the turn settles while the compaction it + * describes was applied mid-turn; between the two rows sit only that turn's + * post-compaction output — the turn's own usage row lands after the note — so the + * first relevant row behind the note is the matching apply row when one was + * recorded. A different turn's apply row, an older display note, a usage + * row, or the head of the log all mean the note's own write time is all + * there is. + */ +function latestCompactionAppliedAt( + messages: readonly LatestRequestUsageRow[], + noteIndex: number, + turnId: string | undefined, +): number | undefined { + for (let index = noteIndex - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.type === 'token_usage') return undefined; + if (message?.type !== 'system_note') continue; + if (message.kind === 'context_compaction_applied') { + return message.turnId === turnId ? message.ts : undefined; + } + if (message.kind === 'context_compacted') return undefined; + } return undefined; } diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts index b67a9de940f..3b912d2deaf 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts @@ -48,7 +48,7 @@ export interface LiveContextUsage { * When that request settled, on the Host's clock — the same clock the * session's own transcript rows carry, so a reader can tell whether this * snapshot predates a compaction boundary it already knows about. Without - * it, a snapshot that a fold has replaced is indistinguishable from one + * it, a snapshot that a compaction has replaced is indistinguishable from one * taken after it. */ readonly completedAt?: number; diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 6d0d5da9cc6..e7ae25514a6 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -267,7 +267,7 @@ export function ChatComposerRegion({ const renderComposer = ( liveContextUsage: LiveContextUsage | undefined, ) => { - // One question, two answers, and a fold can make the finer one stale: the + // One question, two answers, and a compaction can make the finer one stale: the // snapshot wins when it landed after the boundary, and the boundary wins // when it did not. const reading = resolveContextUsage({ latestRequestUsage, live: liveContextUsage }); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0f488a1a17f..1311272047e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -853,7 +853,10 @@ export function userFacingText(message: Pick { + // The failed-send ledger shape: the settlement usage row lands after the + // apply row, but its anchor still describes the pre-compaction request — the + // retry never completed. Such an anchor must not seed the next send's + // baseline: it measures context the compaction already replaced. + for (const [boundaryTs, anchorCompletedAt, folds] of [ + [1_800_000_001_000, 1_800_000_000_500, false], + [1_800_000_000_100, 1_800_000_000_500, true], + ] as const) { + const fixture = buildFixture({ + priorChars: 2_000, + contextWindow: 20_000, + finalAtSecondCall: true, + extraPriorEvents: [ + { + ...runtimeTextEvent(`prior-applied-${boundaryTs}`, 'turn-0', 'model', ''), + runId: 'run-0', + invocationId: 'run-0', + role: 'system' as const, + author: 'system' as const, + ts: boundaryTs, + content: { + kind: 'system_note' as const, + note: 'context_compaction_applied', + }, + }, + priorUsageEvent({ + inputTokens: 30_000, + outputTokens: 10, + completedAt: anchorCompletedAt, + }), + ], + priorInvocations: [priorRunInvocation()], + }); + await runFixtureTurn(fixture); + + assert.equal(fixture.recorded.length, folds ? 1 : 0); + } + }); + + test('a settlement compaction note delegates its boundary to the apply row', async () => { + // The display note is written when the turn settles — after every + // request of its turn — so ordering an anchor against the note's own + // row time discards every post-compaction measurement. The apply row carries + // the real boundary: an anchor completed after the compaction still seeds + // the next send's baseline, and one completed before it does not. + for (const [appliedTs, anchorCompletedAt, folds] of [ + // Post-compaction anchor: applied at T2, anchor completed at T4, note + // settled at T5 — the anchor survives and the over-window seed + // compacts step 0. + [1_800_000_001_000, 1_800_000_004_000, true], + // Stale anchor: applied at T2 but the anchor completed at T1 — the + // retry never finished, so nothing post-compaction is measured. + [1_800_000_001_000, 1_800_000_000_500, false], + ] as const) { + const fixture = buildFixture({ + priorChars: 2_000, + contextWindow: 20_000, + finalAtSecondCall: true, + extraPriorEvents: [ + { + ...runtimeTextEvent(`prior-applied-${appliedTs}`, 'turn-0', 'model', ''), + runId: 'run-0', + invocationId: 'run-0', + role: 'system' as const, + author: 'system' as const, + ts: appliedTs, + content: { + kind: 'system_note' as const, + note: 'context_compaction_applied', + }, + }, + { + ...runtimeTextEvent(`prior-note-${appliedTs}`, 'turn-0', 'model', ''), + runId: 'run-0', + invocationId: 'run-0', + role: 'system' as const, + author: 'system' as const, + ts: 1_800_000_005_000, + content: { + kind: 'system_note' as const, + note: 'context_compacted', + }, + }, + priorUsageEvent({ + inputTokens: 30_000, + outputTokens: 10, + completedAt: anchorCompletedAt, + }), + ], + priorInvocations: [priorRunInvocation()], + }); + await runFixtureTurn(fixture); + + assert.equal(fixture.recorded.length, folds ? 1 : 0); + } + }); + + test('a settlement note without an apply row is itself the boundary', async () => { + // Ledgers written before `context_compaction_applied` existed — or a + // failed apply-row write — leave the settlement note as the only + // boundary. Its time postdates every request of its turn, so no anchor + // can be shown to postdate the compaction and the conservative seed is none. + const fixture = buildFixture({ + priorChars: 2_000, + contextWindow: 20_000, + finalAtSecondCall: true, + extraPriorEvents: [ + { + ...runtimeTextEvent('prior-note-legacy', 'turn-0', 'model', ''), + runId: 'run-0', + invocationId: 'run-0', + role: 'system' as const, + author: 'system' as const, + ts: 1_800_000_005_000, + content: { + kind: 'system_note' as const, + note: 'context_compacted', + }, + }, + priorUsageEvent({ + inputTokens: 30_000, + outputTokens: 10, + completedAt: 1_800_000_004_000, + }), + ], + priorInvocations: [priorRunInvocation()], + }); + await runFixtureTurn(fixture); + + assert.equal(fixture.recorded.length, 0); + }); + test('the reserve is twice the last real reply, bounded, not the model output limit', async () => { // With the window declared at the provider's real size, an accepted // request can never exceed it on its own; the reply the next request must @@ -1890,6 +2023,7 @@ describe('the shipped runtime default drives the proactive long-turn journey (is function priorUsageEvent(lastRequestAnchor: { inputTokens: number; outputTokens?: number; + completedAt?: number; }): RuntimeEvent { return { ...runtimeTextEvent('prior-usage', 'turn-0', 'model', ''), diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 31a0e812a3a..309deb3b651 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -1903,6 +1903,55 @@ describe('reactive overflow recovery in the streaming backend', () => { assert.equal(fixture.llmCalls.at(-1)?.errorClass, 'context_overflow'); }); + test('a terminal retry after the compaction still settles usage with its completed anchor (#5547)', async () => { + // Step 1 completes; step 2 overflows; the recovery compaction lands; the retry + // overflows terminally. Settlement persists the completed steps' usage + // row — after the compaction notes — and its anchor still describes the + // PRE-compaction request. The fact is kept whole: `completedAt` is what lets a + // reader order the anchor against the apply-time boundary instead of + // trusting ledger position. + const fixture = buildReactiveFixture({ + script: ['tool', 'overflow', 'overflow'], + bigPriors: true, + }); + await runTurn(fixture); + + const usage = fixture.events.find((event) => event.type === 'token_usage') as + | { + type: 'token_usage'; + lastRequestAnchor?: { inputTokens?: number; completedAt?: number }; + } + | undefined; + assert.ok(usage); + assert.equal(typeof usage.lastRequestAnchor?.inputTokens, 'number'); + assert.equal(typeof usage.lastRequestAnchor?.completedAt, 'number'); + assert.equal( + fixture.messages.some( + (message) => + (message as { type?: string; kind?: string }).type === 'system_note' && + (message as { kind?: string }).kind === 'context_compaction_applied', + ), + true, + ); + }); + + test('a post-compaction retry that completes keeps its request anchor', async () => { + // The healthy counterpart: the retry completed after the compaction, so the + // settlement usage row anchors the NEXT turn's baseline on real post-compaction + // input. + const fixture = buildReactiveFixture({ + script: ['tool', 'overflow', 'done'], + bigPriors: true, + }); + await runTurn(fixture); + + const usage = fixture.events.find((event) => event.type === 'token_usage') as + | { type: 'token_usage'; lastRequestAnchor?: { inputTokens?: number } } + | undefined; + assert.ok(usage); + assert.equal(typeof usage.lastRequestAnchor?.inputTokens, 'number'); + }); + test('a proactive fold spends the step, so the same step does not fold again', async () => { // The declared window is crossed before the second request, so that // request is already the folded one when the provider rejects it. Reactive @@ -2190,7 +2239,8 @@ describe('reactive overflow recovery in the streaming backend', () => { const note = fixture.messages.find( (message): message is { type: 'system_note'; kind: string; data?: unknown } => - (message as { type?: string }).type === 'system_note', + (message as { type?: string }).type === 'system_note' && + (message as { kind?: string }).kind === 'context_window_suggestion', ); assert.equal(note?.kind, 'context_window_suggestion'); assert.deepEqual(note?.data, { @@ -2271,6 +2321,15 @@ describe('reactive overflow recovery in the streaming backend', () => { await runTurn(fixture); assert.equal(complete(fixture)?.stopReason, 'end_turn'); // The fold itself is noted (context_compacted); the window suggestion is not. + // The compaction landed mid-turn, so its apply-time boundary row exists too. + assert.equal( + fixture.messages.some( + (message) => + (message as { type?: string; kind?: string }).type === 'system_note' && + (message as { kind?: string }).kind === 'context_compaction_applied', + ), + true, + ); assert.equal( fixture.messages.some( (message) => diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 5b59e10ef33..692f40a3ba0 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4826,6 +4826,14 @@ describe('SessionManager manual compaction and quiescent session changes', () => // The kernel writes the note on the compaction turn itself, so the row // appears the moment compaction ends — not one send later. assert.strictEqual(notes.length, 1); + // The apply-time boundary row lands beside it, on the same turn. + const applied = messages.filter( + (message) => + message.type === 'system_note' && + message.turnId === 'turn-compact' && + message.kind === 'context_compaction_applied', + ); + assert.strictEqual(applied.length, 1); // And no duplicate failed-open note on a successful compaction. const failed = messages.filter( (message) => diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index f6967f2456a..9eea7d0a371 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -845,6 +845,13 @@ export class AiSdkCompaction { queue: AsyncEventQueue, providerTools: readonly MakaTool[], onDiagnosticPatch: (patch: Partial) => void, + /** + * Fired once a compaction is durable and applied — checkpoint persisted and the + * projection takes over — before the replacement request goes out. The + * runtime records its apply-time boundary row here; like every system + * note it must never disturb the compaction, so the call is fail-open. + */ + onCompactionApplied: (() => void | Promise) | undefined, origin: ProviderRequestOrigin, memoryCompactionDecision?: () => AutomaticMemoryCompactionDecision, onMemoryCompaction?: (input: AutomaticMemoryCompactionDispatch) => void, @@ -932,6 +939,7 @@ export class AiSdkCompaction { activeToolsForStep, memoryCompactionDecision, onMemoryCompaction, + onCompactionApplied, abortSignal, }); if (outcome.decision === 'fail') { @@ -978,6 +986,8 @@ export class AiSdkCompaction { activeToolsForStep: readonly string[]; memoryCompactionDecision?: () => AutomaticMemoryCompactionDecision; onMemoryCompaction?: (input: AutomaticMemoryCompactionDispatch) => void; + /** See {@link buildMidTurnCapacityCompactProjection}'s parameter. */ + onCompactionApplied?: () => void | Promise; phase?: 'pre_turn' | 'mid_turn'; abortSignal?: AbortSignal; }): Promise { @@ -1193,6 +1203,14 @@ export class AiSdkCompaction { } } state.projectionCheckpoint = plan.checkpoint; + // The boundary is durable the moment the projection takes over: record it + // now, not at turn settlement, so usage readers ordering measurements + // against the compaction get the apply time. + try { + await input.onCompactionApplied?.(); + } catch { + // The boundary row explains the compaction; losing it must not undo one. + } return { decision: 'compacted', checkpoint: plan.checkpoint, @@ -1227,6 +1245,8 @@ export class AiSdkCompaction { origin: ProviderRequestOrigin; memoryCompactionDecision?: () => AutomaticMemoryCompactionDecision; onMemoryCompaction?: (input: AutomaticMemoryCompactionDispatch) => void; + /** See {@link buildMidTurnCapacityCompactProjection}'s parameter. */ + onCompactionApplied?: () => void | Promise; abortSignal?: AbortSignal; }): Promise<{ messages: ModelMessage[] } | undefined> { const state = input.midTurnState; @@ -1262,6 +1282,7 @@ export class AiSdkCompaction { activeToolsForStep: input.activeTools, memoryCompactionDecision: input.memoryCompactionDecision, onMemoryCompaction: input.onMemoryCompaction, + onCompactionApplied: input.onCompactionApplied, abortSignal: input.abortSignal, }); if (outcome.decision !== 'compacted') { @@ -1479,20 +1500,78 @@ function persistedRequestAnchor( modelId: string, connectionId: string | undefined, ): LastRequestAnchor | undefined { + // The newest anchored usage row is the candidate — but a send that compacted + // mid-turn and never completed another request settles its usage row AFTER + // the compaction notes while the anchor still describes the pre-compaction + // request (#5547). Position alone cannot order that pair, so the scan holds + // the candidate and lets a boundary row behind it supersede by event time. + let pending: { anchor: LastRequestAnchor; completedAt?: number } | undefined; for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; const anchor = event?.actions?.tokenUsage?.lastRequestAnchor; - if (!anchor) continue; - const route = invocations.find((candidate) => candidate.runId === event?.runId)?.opening.route; - if ( - route?.provenance !== 'runtime' || - route.backendKind === 'plugin-executor' || - route.modelId !== modelId || - route.llmConnectionId !== connectionId - ) { - return undefined; + if (anchor) { + // A second anchored row settles the candidate: under ordered writes every + // boundary behind it is strictly older than the candidate's completion. + if (pending) return pending.anchor; + const route = invocations.find((candidate) => candidate.runId === event?.runId)?.opening + .route; + if ( + route?.provenance !== 'runtime' || + route.backendKind === 'plugin-executor' || + route.modelId !== modelId || + route.llmConnectionId !== connectionId + ) { + return undefined; + } + pending = { anchor, completedAt: anchor.completedAt }; + continue; + } + const note = event?.content?.kind === 'system_note' ? event.content.note : undefined; + if (note === 'context_compaction_applied' || note === 'context_compacted') { + // A boundary behind the candidate is strictly older under ordered + // writes — unless the candidate's completion itself predates the compaction. + if (!pending) return undefined; + const boundaryAt = + note === 'context_compacted' + ? // The display row lands at settlement — after every request of + // its turn — so its own row time would supersede even an anchor + // that completed after the compaction. The compaction recorded its real + // boundary at apply time earlier in the same turn; only when no + // such row exists is the note's own time all there is. + (compactionAppliedAtBefore(events, index, event.turnId) ?? event.ts) + : event.ts; + return pending.completedAt !== undefined && + boundaryAt !== undefined && + boundaryAt >= pending.completedAt + ? undefined + : pending.anchor; + } + } + return pending?.anchor; +} + +/** + * The apply-time boundary a settlement `context_compacted` row describes: + * the same turn's `context_compaction_applied` note. Between the compaction's + * apply row and its display row sit only that turn's post-compaction output — + * the turn's own usage row lands after the note — so the first relevant + * row behind the note is the matching apply row when one was recorded. A + * usage row, another display note, a different turn's apply row, or the + * head of the log all mean the note's own write time is all there is. + */ +function compactionAppliedAtBefore( + events: readonly RuntimeEvent[], + noteIndex: number, + turnId: string | undefined, +): number | undefined { + for (let index = noteIndex - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.actions?.tokenUsage !== undefined) return undefined; + const note = event?.content?.kind === 'system_note' ? event.content.note : undefined; + if (note === 'context_compaction_applied') { + return event?.turnId === turnId ? event.ts : undefined; } - return anchor; + if (note === 'context_compacted') return undefined; } return undefined; } diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index e29bad58b4a..0f599354df1 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -1007,6 +1007,10 @@ export class AiSdkTurn { // Output tokens of the same step: with the input they are the baseline the // next request is judged from (everything the model produced is re-sent). let lastStepOutputTokens: number | undefined; + // When that step's request finished: the settlement anchor carries it as + // `completedAt` so readers can order the measurement against a compaction's + // apply-time boundary (#5547) even where no provider tracker is wired. + let lastStepRequestCompletedAt: number | undefined; let streamStatus: LlmCallRecord['status'] = 'success'; let streamErrorClass: string | undefined; let runtimeSteps = 0; @@ -1051,6 +1055,12 @@ export class AiSdkTurn { contextCompactedNoteWritten = await this.recordSystemNote('context_compacted', turnId); } }; + // The apply-time boundary the usage reader orders measurements against: + // recorded when each compaction lands, so it exists mid-turn and a stop or + // stream error cannot erase it — unlike the settlement-time display note. + const recordCompactionApplied = async (): Promise => { + await this.recordSystemNote('context_compaction_applied', turnId); + }; const trace = new RunTrace({ sessionId: this.deps.backend.sessionId, turnId, @@ -1445,6 +1455,7 @@ export class AiSdkTurn { queue, capacityProviderTools, onMidTurnDiagnosticPatch, + recordCompactionApplied, this, this.automaticMemoryCompactionSupported() ? () => this.automaticMemoryCompactionDecision() @@ -1931,6 +1942,7 @@ export class AiSdkTurn { } lastStepInputTokens = stepUsage?.inputTokens; lastStepOutputTokens = stepUsage?.outputTokens; + lastStepRequestCompletedAt = this.deps.now(); // A `finishReason: length` is deliberately not a trigger. The // reply may have been cut because the provider ran out of // window room, or because the provider's own output cap is @@ -2002,6 +2014,7 @@ export class AiSdkTurn { activeTools: activeToolsForRequest, queue, onDiagnosticPatch: onMidTurnDiagnosticPatch, + onCompactionApplied: recordCompactionApplied, origin: this, ...(this.automaticMemoryCompactionSupported() ? { @@ -2369,6 +2382,14 @@ export class AiSdkTurn { // are what the next request re-sends. No usable input count, no // anchor: the next turn then has no proactive fold until its first // accepted request. + const lastCompletedMainRequestAt = + providerRequestTracker?.latestCompletedMainRequestAt ?? lastStepRequestCompletedAt; + // This row lands after the compaction notes even when the + // anchored request completed BEFORE the compaction — the post-compaction + // retry never finished (#5547). The anchor is persisted whole as + // the durable fact it is; readers order it against the compaction's + // apply-time boundary via `completedAt` rather than trusting + // ledger position. const anchorInputTokens = finitePositive(lastStepInputTokens); const anchorOutputTokens = lastStepOutputTokens !== undefined && Number.isFinite(lastStepOutputTokens) @@ -2406,8 +2427,8 @@ export class AiSdkTurn { ? { lastRequestAnchor: { inputTokens: anchorInputTokens, - ...(providerRequestTracker?.latestCompletedMainRequestAt !== undefined - ? { completedAt: providerRequestTracker.latestCompletedMainRequestAt } + ...(lastCompletedMainRequestAt !== undefined + ? { completedAt: lastCompletedMainRequestAt } : {}), ...(anchorOutputTokens !== undefined ? { outputTokens: anchorOutputTokens } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index f6b83d8e517..1e3c77756cf 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -1350,6 +1350,9 @@ export class RuntimeKernel implements RuntimeKernelLike { // next user send passively replays this standalone checkpoint, which // `shouldAppendContextCompactedNote` suppresses, so there is no // duplicate. + // The applied row is the apply-time boundary the usage reader orders + // measurements against; the note below stays the display row. + await run.recordSystemNote('context_compaction_applied').catch(() => {}); await run.recordSystemNote('context_compacted').catch(() => {}); notedTerminal = true; } diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index b9c8c62d783..115cce2a099 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -257,6 +257,31 @@ describe("materializeTurns message metadata", () => { assert.deepEqual(turns.flatMap((turn) => turn.notes), []); }); + test("hides the apply-time compaction boundary note that exists for usage ordering", () => { + // The applied row is the selector's compaction-time anchor; the settlement + // context_compacted row stays the one users see. + const turns = materializeTurns([ + { + type: "system_note", + id: "applied", + turnId: "t1", + ts: 1, + kind: "context_compaction_applied", + }, + { + type: "system_note", + id: "compacted", + turnId: "t1", + ts: 2, + kind: "context_compacted", + }, + ], "en"); + assert.deepEqual( + turns.flatMap((turn) => turn.notes.map((note) => note.id)), + ["compacted"], + ); + }); + test("localizes visible system notes", () => { const messages: StoredMessage[] = [ { From c6d08c2f9c56058c7d3ddca5b42691c06fcfce7e Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Tue, 6 Oct 2026 00:59:23 +0800 Subject: [PATCH 07/11] test(ui): assert context usage tooltips for stale and unavailable readings The share-resolution test only checked the visible label, so a stale reading and an unavailable reading were indistinguishable. Resolve the button aria-describedby to its tooltip element and assert the copy for the stale, measured, and unavailable states so the tooltip follows the reading across transitions in the same mounted control. Generated-by: Codex --- .../__tests__/composer-context-usage.test.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index f11cb531fe0..5791d883961 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -104,6 +104,7 @@ test('the context usage share resolves declared, then metered, then metadata win declaredContextWindow?: number; metadataContextWindow?: number; }, + expectedTooltip?: string, ) => { await act(() => root.render( @@ -118,6 +119,13 @@ test('the context usage share resolves declared, then metered, then metadata win 'button[aria-label="Open usage trace"]', ); assert.ok(action); + if (expectedTooltip !== undefined) { + const describedBy = action.getAttribute('aria-describedby'); + assert.ok(describedBy, 'context usage must reference its tooltip'); + const tooltip = document.getElementById(describedBy); + assert.ok(tooltip, 'context usage tooltip must exist'); + assert.equal(tooltip.textContent?.trim(), expectedTooltip); + } return action.textContent?.trim(); }; @@ -142,10 +150,19 @@ test('the context usage share resolves declared, then metered, then metadata win // …and with no window at all the usage stands alone, no invented share. assert.equal(await render({ reading: { kind: 'measured', tokens: 40_000 } }), 'Usage'); // A superseded reading keeps the usage entry label even when a window is known. - assert.equal(await render({ reading: { kind: 'stale', reason: 'compaction' }, declaredContextWindow: 100_000 }), 'Usage'); + assert.equal(await render( + { reading: { kind: 'stale', reason: 'compaction' }, declaredContextWindow: 100_000 }, + 'Context has been compacted. Usage will update when the next request completes.', + ), 'Usage'); // A later successful measurement restores the share in the same mounted control. - assert.equal(await render({ reading: { kind: 'measured', tokens: 10_000, meteredWindow: 100_000 } }), '10%'); - assert.equal(await render({ reading: { kind: 'unavailable' }, declaredContextWindow: 100_000 }), 'Usage'); + assert.equal(await render( + { reading: { kind: 'measured', tokens: 10_000, meteredWindow: 100_000 } }, + 'Context window: 10% used (10K / 100K tokens).', + ), '10%'); + assert.equal(await render( + { reading: { kind: 'unavailable' }, declaredContextWindow: 100_000 }, + 'No usage data is available for this request.', + ), 'Usage'); } finally { await act(() => root.unmount()); Object.assign(globalThis, original); From f938f188f2e0d818326d9e9dd088b3b655c8b7fa Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Tue, 6 Oct 2026 01:59:29 +0800 Subject: [PATCH 08/11] docs(runtime-host): cover the compaction note kind in the epoch-206 note Generated-by: Codex --- packages/runtime-host/src/protocol/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index cc57ac205c1..63798cbf587 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -105,8 +105,10 @@ export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 216 as const; -// 216: token_usage.lastRequestAnchor can carry the provider request settlement -// time. Older peers reject that field in the closed transcript shape. +// 216: token_usage.lastRequestAnchor may carry completedAt, the provider +// request settlement time, and transcripts may carry +// context_compaction_applied system notes. Older peers reject both in the +// closed transcript shape. // 215: WorkHub coordination uses canonical configuration and scoped delegated permissions. // 212: Session catalogs carry `backgroundActivity` and `backgroundActivityVersion` // for Host-owned activity and generation-scoped ordering independent of Session From 21978f91884114e8f587b73ccc6dad63d3d1bce5 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Tue, 6 Oct 2026 02:34:56 +0800 Subject: [PATCH 09/11] docs(changelog): name the compaction apply-time boundary row The gauge entry credited context_compacted alone, but the mid-turn boundary is the context_compaction_applied row recorded when a compaction lands; context_compacted stays the settlement-time display note. Refs #5547 Generated-by: Codex --- CHANGELOG.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e02c7dc5d..40ef915ebe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,18 +41,14 @@ ### Fixed - The composer's context gauge now falls back to its localized `Usage` label - instead of holding the pre-compaction figure after a fold. A compaction - replaces the prompt the last measured request described, and no provider has - tokenized the replacement yet, so the newest real count is stale rather than - current — showing it read as a live measurement of what the session was about - to send. The gauge walks the transcript backwards and whichever fact comes - first decides: a `context_compacted` note newer than every measurement keeps - the gauge chip on `Usage` with a tooltip saying the reading will update when - the next request completes, and a measurement newer than the note stands. The - live per-settled-request snapshot still wins when it landed after the - boundary, so a mid-turn fold recovers as soon as the next step settles rather - than waiting for the turn to end. A failed-open fold is not a boundary: that - request went out with its full raw history. + instead of holding the pre-compaction figure after a compaction: no provider + has tokenized the replacement prompt yet, so the last real count is stale. + A `context_compaction_applied` row marks the boundary the moment a + compaction lands — the `context_compacted` note stays the settlement-time + display row — and only a measurement that completed after the boundary + restores the figure, so a mid-turn compaction recovers as soon as the next + step settles. A failed-open compaction is not a boundary: that request went + out with its full raw history. - Fixed a renderer crash dialog reporting React error #185 ("Maximum update depth exceeded") coming from the composer's prompt-history inline completion (#4117): the offer engine the 0.1.11 composer fed could flip-flop its announcement state on From 0efc4a400ee0073587ebda18a4ea6f7ecabc4853 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Tue, 6 Oct 2026 22:43:21 +0800 Subject: [PATCH 10/11] fix(runtime): forward compaction boundaries while a turn is running A mid-turn compaction only reached the composer gauge once the turn settled and the transcript refreshed, so the pre-compaction figure stayed on screen for the rest of a long turn. The runtime now emits a context_compaction_applied event when a compaction lands, persists it as the hidden boundary note, and the Runtime Host forwards it over the session subscription. The live context usage tracker holds the gauge on the compacted reading until a measurement that settled after the boundary arrives. Live and transcript readings share one ContextUsageSnapshot union, and the measured reading's window is renamed to contextWindow. Refs #5547 Generated-by: Codex --- CHANGELOG.md | 12 +- .../__tests__/latest-request-usage.test.ts | 66 ++++-- .../main/__tests__/live-context-usage.test.ts | 188 ++++++++++++++++-- .../__tests__/session-trace-refresh.test.ts | 12 +- .../context-usage-snapshot.ts | 68 +++++++ .../session-inspector/latest-request-usage.ts | 85 ++++---- .../session-inspector/live-context-usage.ts | 56 ++++-- .../session-trace-refresh.ts | 2 + .../use-live-context-usage.ts | 6 +- .../src/renderer/chat-composer-region.tsx | 10 +- .../inspector/live-context-usage-probe.tsx | 4 +- packages/core/src/events.ts | 10 + .../src/__tests__/protocol.test.ts | 29 +++ .../__tests__/root-turn-coordinator.test.ts | 96 +++++++++ .../src/__tests__/session-projector.test.ts | 22 ++ .../src/adapter/session-projector.ts | 8 + packages/runtime-host/src/protocol/index.ts | 10 +- .../src/protocol/session-continuity.ts | 25 ++- .../src/server/root-turn-coordinator.ts | 1 + .../server/session-continuity-coordinator.ts | 13 +- .../mid-turn-capacity-backend.test.ts | 52 +++++ .../session-event-runtime-mapper.test.ts | 3 + packages/runtime/src/ai-sdk-turn.ts | 10 +- .../src/session-event-runtime-mapper.ts | 9 + .../__tests__/composer-context-usage.test.tsx | 6 +- packages/ui/src/composer.tsx | 2 +- packages/ui/src/context-usage-reading.ts | 2 +- 27 files changed, 681 insertions(+), 126 deletions(-) create mode 100644 apps/desktop/src/renderer/application/contracts/session-inspector/context-usage-snapshot.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ef915ebe3..22bf505d048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,12 +43,12 @@ - The composer's context gauge now falls back to its localized `Usage` label instead of holding the pre-compaction figure after a compaction: no provider has tokenized the replacement prompt yet, so the last real count is stale. - A `context_compaction_applied` row marks the boundary the moment a - compaction lands — the `context_compacted` note stays the settlement-time - display row — and only a measurement that completed after the boundary - restores the figure, so a mid-turn compaction recovers as soon as the next - step settles. A failed-open compaction is not a boundary: that request went - out with its full raw history. + A `context_compaction_applied` boundary is persisted and forwarded over the + session subscription the moment a compaction lands, so a mid-turn fold + clears the figure while that turn is still running — the `context_compacted` + note stays the settlement-time display row — and only a measurement that + completed after the boundary restores it. A failed-open compaction is not a + boundary: that request went out with its full raw history. - Fixed a renderer crash dialog reporting React error #185 ("Maximum update depth exceeded") coming from the composer's prompt-history inline completion (#4117): the offer engine the 0.1.11 composer fed could flip-flop its announcement state on diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index 069e582582b..9b51db554ac 100644 --- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -137,7 +137,7 @@ test('a post-compaction anchor supersedes a pre-compaction snapshot while diagno assert.deepEqual( resolveContextUsage({ latestRequestUsage, - live: { usageTokens: 90_000, contextWindow: 100_000, completedAt: 1_000 }, + live: { kind: 'tokens', tokens: 90_000, contextWindow: 100_000, at: 1_000 }, }), { kind: 'measured', tokens: 35_000 }, ); @@ -152,9 +152,9 @@ test('the token row written after its own request keeps the settled snapshot and assert.deepEqual( resolveContextUsage({ latestRequestUsage, - live: { usageTokens: 100, contextWindow: 1_000, completedAt: 1_000 }, + live: { kind: 'tokens', tokens: 100, contextWindow: 1_000, at: 1_000 }, }), - { kind: 'measured', tokens: 100, meteredWindow: 1_000 }, + { kind: 'measured', tokens: 100, contextWindow: 1_000 }, ); }); @@ -169,9 +169,9 @@ test('a legacy anchor without settlement time cannot displace a live snapshot', MODEL, ROUTE, ), - live: { usageTokens: 100, contextWindow: 1_000, completedAt: 1_000 }, + live: { kind: 'tokens', tokens: 100, contextWindow: 1_000, at: 1_000 }, }), - { kind: 'measured', tokens: 100, meteredWindow: 1_000 }, + { kind: 'measured', tokens: 100, contextWindow: 1_000 }, ); }); @@ -179,7 +179,7 @@ test('a timed anchor wins when the retained snapshot has no settlement time', () assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'tokens', tokens: 35_000, at: 3_000 }, - live: { usageTokens: 90_000 }, + live: { kind: 'tokens', tokens: 90_000 }, }), { kind: 'measured', tokens: 35_000 }, ); @@ -370,7 +370,7 @@ test('the failed-retry ledger reads stale, not the pre-compaction measurement (# assert.deepEqual( resolveContextUsage({ latestRequestUsage, - live: { usageTokens: 190_000, contextWindow: 200_000, completedAt: 1_000 }, + live: { kind: 'tokens', tokens: 190_000, contextWindow: 200_000, at: 1_000 }, }), { kind: 'stale', reason: 'compaction' }, ); @@ -391,9 +391,9 @@ test('a post-compaction snapshot is measured against the apply time, not settlem assert.deepEqual( resolveContextUsage({ latestRequestUsage, - live: { usageTokens: 30_000, contextWindow: 100_000, completedAt: 2_000 }, + live: { kind: 'tokens', tokens: 30_000, contextWindow: 100_000, at: 2_000 }, }), - { kind: 'measured', tokens: 30_000, meteredWindow: 100_000 }, + { kind: 'measured', tokens: 30_000, contextWindow: 100_000 }, ); }); @@ -446,7 +446,7 @@ test('the snapshot is the reading when it is the newer answer', () => { assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'tokens', tokens: 120, at: 1_000 }, - live: { usageTokens: 130, completedAt: 1_500 }, + live: { kind: 'tokens', tokens: 130, at: 1_500 }, }), { kind: 'measured', tokens: 130 }, ); @@ -458,13 +458,13 @@ test('the snapshot is the reading when it is the newer answer', () => { assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'tokens', tokens: 120, at: 1_500 }, - live: { usageTokens: 130, completedAt: 1_500 }, + live: { kind: 'tokens', tokens: 130, at: 1_500 }, }), { kind: 'measured', tokens: 130 }, ); // The snapshot can still vouch when the transcript established nothing. assert.deepEqual( - resolveContextUsage({ latestRequestUsage: undefined, live: { usageTokens: 130 } }), + resolveContextUsage({ latestRequestUsage: undefined, live: { kind: 'tokens', tokens: 130 } }), { kind: 'measured', tokens: 130 }, ); assert.deepEqual(resolveContextUsage({ latestRequestUsage: undefined }), { @@ -478,7 +478,7 @@ test('a boundary supersedes the snapshot it landed after', () => { assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'compacted', at: 2_000 }, - live: { usageTokens: 90_000, completedAt: 1_000 }, + live: { kind: 'tokens', tokens: 90_000, at: 1_000 }, }), { kind: 'stale', reason: 'compaction' }, ); @@ -492,7 +492,7 @@ test('a boundary supersedes the snapshot it landed after', () => { assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'compacted', at: 2_000 }, - live: { usageTokens: 90_000 }, + live: { kind: 'tokens', tokens: 90_000 }, }), { kind: 'stale', reason: 'compaction' }, ); @@ -504,25 +504,53 @@ test('a snapshot newer than the boundary is the post-compaction reading', () => assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'compacted', at: 2_000 }, - live: { usageTokens: 30_000, completedAt: 2_500 }, + live: { kind: 'tokens', tokens: 30_000, at: 2_500 }, }), { kind: 'measured', tokens: 30_000 }, ); }); +test('a live boundary rejects old, equal-time, and untimed durable anchors', () => { + for (const completedAt of [undefined, 1_000, 2_000]) { + const latestRequestUsage = selectLatestRequestUsage([ + usage({ inputTokens: 90_000, modelId: MODEL, connectionId: 'conn-a', completedAt }, 9_100), + ], MODEL, ROUTE); + assert.deepEqual( + resolveContextUsage({ latestRequestUsage, live: { kind: 'compacted', at: 2_000 } }), + { kind: 'stale', reason: 'compaction' }, + ); + } + for (const latestRequestUsage of [undefined, { kind: 'compacted', at: 3_000 } as const]) { + assert.deepEqual( + resolveContextUsage({ latestRequestUsage, live: { kind: 'compacted', at: 2_000 } }), + { kind: 'stale', reason: 'compaction' }, + ); + } +}); + +test('a proven newer durable anchor restores usage while the live boundary remains', () => { + const latestRequestUsage = selectLatestRequestUsage([ + usage({ inputTokens: 30_000, outputTokens: 100, modelId: MODEL, connectionId: 'conn-a', completedAt: 2_500 }, 9_100), + ], MODEL, ROUTE); + assert.deepEqual( + resolveContextUsage({ latestRequestUsage, live: { kind: 'compacted', at: 2_000 } }), + { kind: 'measured', tokens: 30_100 }, + ); +}); + test('a selected live measurement carries only its own metered window', () => { assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'tokens', tokens: 120 }, - live: { usageTokens: 130, contextWindow: 1_000, completedAt: 1_500 }, + live: { kind: 'tokens', tokens: 130, contextWindow: 1_000, at: 1_500 }, }), - { kind: 'measured', tokens: 130, meteredWindow: 1_000 }, + { kind: 'measured', tokens: 130, contextWindow: 1_000 }, ); assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'compacted', at: 2_000 }, - live: { usageTokens: 130, contextWindow: 1_000, completedAt: 1_500 }, + live: { kind: 'tokens', tokens: 130, contextWindow: 1_000, at: 1_500 }, }), { kind: 'stale', reason: 'compaction' }, ); @@ -533,7 +561,7 @@ test('equal or missing boundary times cannot establish a post-compaction measure assert.deepEqual( resolveContextUsage({ latestRequestUsage: { kind: 'compacted', at }, - live: { usageTokens: 130, completedAt: 2_000 }, + live: { kind: 'tokens', tokens: 130, at: 2_000 }, }), { kind: 'stale', reason: 'compaction' }, ); diff --git a/apps/desktop/src/main/__tests__/live-context-usage.test.ts b/apps/desktop/src/main/__tests__/live-context-usage.test.ts index 1c8f5428539..e8f70f0d7d0 100644 --- a/apps/desktop/src/main/__tests__/live-context-usage.test.ts +++ b/apps/desktop/src/main/__tests__/live-context-usage.test.ts @@ -25,6 +25,10 @@ import { createLiveContextUsageTracker, liveContextUsageFromDiagnostics, } from '../../renderer/features/workbar/testing.js'; +import { + resolveContextUsage, + selectLatestRequestUsage, +} from '../../renderer/application/contracts/session-inspector/latest-request-usage.js'; const ROUTE = { model: 'deepseek-v4-flash', providerType: 'deepseek' } as const; @@ -83,9 +87,10 @@ function scriptedQuery() { describe('liveContextUsageFromDiagnostics', () => { it('maps a matching snapshot onto the gauge, window included', () => { assert.deepEqual(liveContextUsageFromDiagnostics(available(), ROUTE), { - completedAt: 1, - usageTokens: 79_436, + kind: 'tokens', + tokens: 79_436, contextWindow: 128_000, + at: 1, }); }); @@ -114,12 +119,167 @@ describe('liveContextUsageFromDiagnostics', () => { it('stands alone without a window', () => { assert.deepEqual( liveContextUsageFromDiagnostics(available({ contextWindow: undefined }), ROUTE), - { completedAt: 1, usageTokens: 79_436 }, + { kind: 'tokens', tokens: 79_436, at: 1 }, ); }); }); describe('createLiveContextUsageTracker', () => { + it('synchronously invalidates the gauge during a running turn while the transcript is still old', async () => { + const timer = fakeTimer(); + const query = scriptedQuery(); + const readings: unknown[] = []; + const latestRequestUsage = selectLatestRequestUsage([ + { + type: 'token_usage', ts: 1_100, + lastRequestAnchor: { + inputTokens: 79_436, completedAt: 1_000, + modelId: ROUTE.model, connectionId: 'conn-a', + }, + }, + ], ROUTE.model, { llmConnectionId: 'conn-a' }); + const tracker = createLiveContextUsageTracker({ + query: query.query, + delayMs: 400, + schedule: timer.schedule, + cancel: timer.cancel, + onChange: (live) => readings.push(resolveContextUsage({ latestRequestUsage, live })), + }); + tracker.setTarget({ sessionId: 's1', route: ROUTE }); + query.pending[0]!.resolve(available({ completedAt: 1_000 })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 79_436, contextWindow: 128_000 }); + + tracker.observe(event('tool_result')); + timer.fire(); + // No complete event or transcript refresh: the provider retry is still running. + tracker.observe({ type: 'context_compaction_applied', id: 'fold-1', turnId: 'turn-1', ts: 2_000 }); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + const afterBoundary = readings.length; + // The old query resolves before even issuing the replacement read. + query.pending[1]!.resolve(available({ completedAt: 1_000 })); + await Promise.resolve(); + assert.equal(readings.length, afterBoundary); + timer.fire(); + query.pending.at(-1)!.reject(new Error('host reconnecting')); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + + for (const diagnostics of [ + available({ completedAt: 1_000 }), + available({ completedAt: 2_000 }), + available({ completedAt: undefined }), + available({ completedAt: 2_500, modelId: 'other-model' }), + available({ completedAt: 2_500, inputTokens: undefined }), + { status: 'unavailable', reason: 'no_completed_request' } as const, + ]) { + tracker.observe(event('tool_result')); + timer.fire(); + query.pending.at(-1)!.resolve(diagnostics); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + } + + tracker.observe(event('tool_result')); + timer.fire(); + query.pending.at(-1)!.resolve(available({ completedAt: 2_500, inputTokens: 20_000 })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 20_000, contextWindow: 128_000 }); + // Duplicate or out-of-order replay cannot invalidate a newer measurement. + tracker.observe({ type: 'context_compaction_applied', id: 'fold-1', turnId: 'turn-1', ts: 2_000 }); + tracker.observe({ type: 'context_compaction_applied', id: 'older-fold', turnId: 'turn-1', ts: 1_500 }); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 20_000, contextWindow: 128_000 }); + + tracker.observe({ type: 'context_compaction_applied', id: 'fold-2', turnId: 'turn-1', ts: 3_000 }); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + tracker.observe({ type: 'context_compaction_applied', id: 'fold-1', turnId: 'turn-1', ts: 2_000 }); + timer.fire(); + query.pending.at(-1)!.resolve(available({ completedAt: 2_500, inputTokens: 20_000 })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + + tracker.observe(event('tool_result')); + timer.fire(); + query.pending.at(-1)!.resolve(available({ completedAt: 3_500, inputTokens: 10_000 })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 10_000, contextWindow: 128_000 }); + tracker.dispose(); + }); + + it('retains a boundary on the same target and isolates it from changed sessions or routes', async () => { + const timer = fakeTimer(); + const query = scriptedQuery(); + const seen: unknown[] = []; + const readings: unknown[] = []; + const tracker = createLiveContextUsageTracker({ + query: query.query, + delayMs: 400, + schedule: timer.schedule, + cancel: timer.cancel, + onChange: (live) => { + seen.push(live); + readings.push(resolveContextUsage({ latestRequestUsage: undefined, live })); + }, + }); + tracker.setTarget({ sessionId: 's1', route: ROUTE }); + tracker.observe({ type: 'context_compaction_applied', id: 'fold-1', turnId: 'turn-1', ts: 2_000 }); + tracker.setTarget({ sessionId: 's1', route: { ...ROUTE } }); + assert.deepEqual(seen.at(-1), { kind: 'compacted', at: 2_000 }); + query.pending.at(-1)!.resolve(available()); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + + tracker.setTarget({ sessionId: 's2', route: ROUTE }); + assert.deepEqual(readings.at(-1), { kind: 'unavailable' }); + query.pending.at(-1)!.resolve(available()); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 79_436, contextWindow: 128_000 }); + query.pending[0]!.resolve(available({ inputTokens: 99_000 })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 79_436, contextWindow: 128_000 }); + + tracker.observe({ type: 'context_compaction_applied', id: 'fold-2', turnId: 'turn-2', ts: 3_000 }); + tracker.setTarget({ sessionId: 's2', route: { model: 'other-model', providerType: ROUTE.providerType } }); + assert.deepEqual(readings.at(-1), { kind: 'unavailable' }); + query.pending.at(-1)!.resolve(available({ modelId: 'other-model' })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 79_436, contextWindow: 128_000 }); + + tracker.setTarget(undefined); + tracker.observe({ type: 'context_compaction_applied', id: 'late-fold', turnId: 'turn-2', ts: 4_000 }); + assert.deepEqual(readings.at(-1), { kind: 'unavailable' }); + assert.equal(timer.scheduled, 0); + tracker.dispose(); + }); + + it('recovers the boundary from the transcript after reconnect without a live event', async () => { + const timer = fakeTimer(); + const query = scriptedQuery(); + const readings: unknown[] = []; + const latestRequestUsage = selectLatestRequestUsage([ + { type: 'system_note', kind: 'context_compaction_applied', ts: 2_000, turnId: 'turn-1' }, + { type: 'system_note', kind: 'context_compacted', ts: 9_000, turnId: 'turn-1' }, + ], ROUTE.model, { llmConnectionId: 'conn-a' }); + const tracker = createLiveContextUsageTracker({ + query: query.query, + delayMs: 400, + schedule: timer.schedule, + cancel: timer.cancel, + onChange: (live) => readings.push(resolveContextUsage({ latestRequestUsage, live })), + }); + tracker.setTarget({ sessionId: 's1', route: ROUTE }); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + query.pending[0]!.resolve(available({ completedAt: 1_000 })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'stale', reason: 'compaction' }); + tracker.observe(event('tool_result')); + timer.fire(); + query.pending.at(-1)!.resolve(available({ completedAt: 2_500, inputTokens: 20_000 })); + await Promise.resolve(); + assert.deepEqual(readings.at(-1), { kind: 'measured', tokens: 20_000, contextWindow: 128_000 }); + tracker.dispose(); + }); + it('reads immediately when aimed at a session', async () => { const timer = fakeTimer(); const query = scriptedQuery(); @@ -137,7 +297,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); // The leading `undefined` is the aim itself: whatever stood on screen // before cannot answer for this target, so it clears before the read. - assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { kind: 'tokens', tokens: 79_436, contextWindow: 128_000, at: 1 }]); tracker.dispose(); }); @@ -187,8 +347,8 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); assert.deepEqual(seen, [ undefined, - { completedAt: 1, usageTokens: 40_000, contextWindow: 128_000 }, - { completedAt: 2, usageTokens: 52_000, contextWindow: 128_000 }, + { kind: 'tokens', tokens: 40_000, contextWindow: 128_000, at: 1 }, + { kind: 'tokens', tokens: 52_000, contextWindow: 128_000, at: 2 }, ]); tracker.dispose(); }); @@ -232,7 +392,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); query.pending[0]!.resolve(available({ inputTokens: 10_000 })); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 60_000, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { kind: 'tokens', tokens: 60_000, contextWindow: 128_000, at: 1 }]); tracker.dispose(); }); @@ -259,7 +419,7 @@ describe('createLiveContextUsageTracker', () => { timer.fire(); query.pending[1]!.resolve(available({ inputTokens: 60_000 })); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 60_000, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { kind: 'tokens', tokens: 60_000, contextWindow: 128_000, at: 1 }]); tracker.dispose(); }); @@ -282,7 +442,7 @@ describe('createLiveContextUsageTracker', () => { query.pending[1]!.reject(new Error('host not ready')); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { kind: 'tokens', tokens: 79_436, contextWindow: 128_000, at: 1 }]); tracker.dispose(); }); @@ -304,14 +464,14 @@ describe('createLiveContextUsageTracker', () => { // Switching sessions makes the standing number unanswerable: it must // leave the screen BEFORE the new target's first read lands… tracker.setTarget({ sessionId: 's2', route: ROUTE }); - assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, undefined]); + assert.deepEqual(seen, [undefined, { kind: 'tokens', tokens: 79_436, contextWindow: 128_000, at: 1 }, undefined]); // …and a rejected first read on the new target keeps it cleared, rather // than pinning the previous session's number in place indefinitely. query.pending[1]!.reject(new Error('host not ready')); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, undefined]); + assert.deepEqual(seen, [undefined, { kind: 'tokens', tokens: 79_436, contextWindow: 128_000, at: 1 }, undefined]); tracker.dispose(); }); @@ -338,7 +498,7 @@ describe('createLiveContextUsageTracker', () => { query.pending[1]!.reject(new Error('host not ready')); await Promise.resolve(); await Promise.resolve(); - assert.deepEqual(seen, [undefined, { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, { kind: 'tokens', tokens: 79_436, contextWindow: 128_000, at: 1 }]); tracker.dispose(); }); @@ -383,7 +543,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); // Aiming, then leaving s1 clears its (never-landed) reading, then s2's // lands; the stale s1 read resolving late must not overwrite it. - assert.deepEqual(seen, [undefined, undefined, { completedAt: 1, usageTokens: 5_000, contextWindow: 128_000 }]); + assert.deepEqual(seen, [undefined, undefined, { kind: 'tokens', tokens: 5_000, contextWindow: 128_000, at: 1 }]); tracker.dispose(); }); @@ -409,7 +569,7 @@ describe('createLiveContextUsageTracker', () => { await Promise.resolve(); assert.deepEqual(seen, [ undefined, - { completedAt: 1, usageTokens: 79_436, contextWindow: 128_000 }, + { kind: 'tokens', tokens: 79_436, contextWindow: 128_000, at: 1 }, undefined, undefined, ]); diff --git a/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts b/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts index 1177f642b77..ac9364d44e0 100644 --- a/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts +++ b/apps/desktop/src/main/__tests__/session-trace-refresh.test.ts @@ -61,7 +61,17 @@ describe('session trace refresh policy', () => { assert.equal(isTraceRelevantEvent(event('thinking_delta', { messageId: 'm', text: 'a' })), false); assert.equal(isTraceRelevantEvent(event('tool_output_delta')), false); - for (const type of ['tool_start', 'tool_result', 'token_usage', 'complete', 'error'] as const) { + for (const type of [ + 'tool_start', + 'tool_result', + 'token_usage', + 'provider_retry', + // A mid-turn compaction persists a boundary note the trace must show. + 'context_compaction_applied', + 'complete', + 'error', + 'abort', + ] as const) { assert.equal(isTraceRelevantEvent(event(type)), true, `${type} changes the trace`); } }); diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/context-usage-snapshot.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/context-usage-snapshot.ts new file mode 100644 index 00000000000..34a210aa20e --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/context-usage-snapshot.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * One context-usage observation: either a settled measurement or a compaction + * boundary, each carrying the event time that lets an arbitrator rank it + * against a parallel source. + * + * Both producers of this union answer the same question — "what fills the + * model's window right now" — at different granularities. The live snapshot + * refreshes per settled provider request, so its tokens row counts the + * request's prompt alone (inputTokens). The transcript anchor seals at turn + * settlement, so its tokens row counts prompt plus generation (input+output) + * — the number the per-turn gauge always showed. Displayed the same way; + * what differs is which request the number describes, and the producers + * document their own choice. + */ +export type ContextUsageSnapshot = + | { + readonly kind: 'tokens'; + readonly tokens: number; + /** + * When the metered request settled, on the Host's clock — the same + * clock transcript rows carry, so a reader can tell whether this + * snapshot predates a compaction boundary it already knows about. + * Without it, a snapshot that a compaction has replaced is + * indistinguishable from one taken after it. + */ + readonly at?: number; + /** + * The window the request was metered against, frozen at call time. Only + * sources that capture the window alongside the tokens carry this; it + * must never be filled from another row's ceiling. + */ + readonly contextWindow?: number; + } + | { + /** + * A compaction boundary: every measurement that completed before it + * counts context that no longer exists. + */ + readonly kind: 'compacted'; + /** + * When the boundary was applied. Rows that carry no time cannot order + * themselves against a measurement, so arbitration treats them as + * losing to it. + */ + readonly at?: number; + }; + +/** The measured variant, when a producer needs to name it. */ +export type ContextUsageTokens = Extract; diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index fc9d46f4b34..06bb0afb372 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -18,7 +18,11 @@ */ import type { ContextUsageReading } from '@maka/ui'; -import type { LiveContextUsage } from './live-context-usage.js'; +import type { + ContextUsageSnapshot, + ContextUsageTokens, +} from './context-usage-snapshot.js'; +import type { LiveContextReading } from './live-context-usage.js'; export interface LatestRequestUsageAnchor { inputTokens: number; @@ -36,10 +40,12 @@ export interface LatestRequestUsageRow { readonly lastRequestAnchor?: LatestRequestUsageAnchor; } -export type LatestRequestUsage = - | { readonly kind: 'tokens'; readonly tokens: number; readonly at?: number } - | { readonly kind: 'compacted'; readonly at?: number } - | undefined; +/** + * The durable half of the gauge's answer — what the transcript's own rows + * select — in the same shape the live tracker reports, so + * `resolveContextUsage` can arbitrate the two on one union. + */ +export type LatestRequestUsage = ContextUsageSnapshot | undefined; /** * Read the newest route-matching measurement or compaction from the session tail. @@ -61,16 +67,7 @@ export function selectLatestRequestUsage( // The newest anchored usage row, held while the scan behind it looks for a // boundary that postdates its completion. A second anchored row settles it: // under ordered writes every boundary behind that row is strictly older. - let pendingTokens: - | { - readonly reading: { - readonly kind: 'tokens'; - readonly tokens: number; - readonly at?: number; - }; - readonly completedAt?: number; - } - | undefined; + let pendingTokens: ContextUsageTokens | undefined; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message?.type === 'system_note' && message.kind === 'context_compaction_applied') { @@ -94,23 +91,20 @@ export function selectLatestRequestUsage( if (message?.type !== 'token_usage') continue; const anchor = message.lastRequestAnchor; if (!anchor) continue; - if (pendingTokens) return pendingTokens.reading; + if (pendingTokens) return pendingTokens; if (model === undefined || connectionId === undefined) return undefined; if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined; if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined; const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0; pendingTokens = { - completedAt: anchor.completedAt, - reading: { - kind: 'tokens', - tokens: anchor.inputTokens + output, - // The row is persisted after request settlement (and sometimes after a - // compaction note). Its write time cannot order its own snapshot. - ...(anchor.completedAt !== undefined ? { at: anchor.completedAt } : {}), - }, + kind: 'tokens', + tokens: anchor.inputTokens + output, + // The row is persisted after request settlement (and sometimes after a + // compaction note). Its write time cannot order its own snapshot. + ...(anchor.completedAt !== undefined ? { at: anchor.completedAt } : {}), }; } - return pendingTokens?.reading; + return pendingTokens; } /** @@ -121,20 +115,17 @@ export function selectLatestRequestUsage( * stands. */ function orderTokensAgainstBoundary( - pendingTokens: { - readonly reading: { readonly kind: 'tokens'; readonly tokens: number; readonly at?: number }; - readonly completedAt?: number; - }, + reading: ContextUsageTokens, boundaryAt: number | undefined, ): LatestRequestUsage { if ( - pendingTokens.completedAt !== undefined && + reading.at !== undefined && boundaryAt !== undefined && - boundaryAt >= pendingTokens.completedAt + boundaryAt >= reading.at ) { return { kind: 'compacted', at: boundaryAt }; } - return pendingTokens.reading; + return reading; } /** @@ -173,29 +164,45 @@ function latestCompactionAppliedAt( */ export function resolveContextUsage(input: { readonly latestRequestUsage: LatestRequestUsage; - readonly live?: LiveContextUsage; + readonly live?: LiveContextReading; }): ContextUsageReading { const { latestRequestUsage, live } = input; + // A live boundary holds the gauge on "compacted" until a durable + // measurement provably settled after it lands. + if (live?.kind === 'compacted') { + if ( + latestRequestUsage?.kind === 'tokens' && + latestRequestUsage.at !== undefined && + live.at !== undefined && + latestRequestUsage.at > live.at + ) { + return { kind: 'measured', tokens: latestRequestUsage.tokens }; + } + return { kind: 'stale', reason: 'compaction' }; + } + // A durable boundary does the same to any live measurement that cannot + // prove it settled after the boundary — an untimed one included. if ( latestRequestUsage?.kind === 'compacted' && - (live?.completedAt === undefined || + (live?.kind !== 'tokens' || + live.at === undefined || latestRequestUsage.at === undefined || - latestRequestUsage.at >= live.completedAt) + latestRequestUsage.at >= live.at) ) { return { kind: 'stale', reason: 'compaction' }; } if ( latestRequestUsage?.kind === 'tokens' && latestRequestUsage.at !== undefined && - (live?.completedAt === undefined || latestRequestUsage.at > live.completedAt) + (live?.kind !== 'tokens' || live.at === undefined || latestRequestUsage.at > live.at) ) { return { kind: 'measured', tokens: latestRequestUsage.tokens }; } - if (live) { + if (live?.kind === 'tokens') { return { kind: 'measured', - tokens: live.usageTokens, - ...(live.contextWindow !== undefined ? { meteredWindow: live.contextWindow } : {}), + tokens: live.tokens, + ...(live.contextWindow !== undefined ? { contextWindow: live.contextWindow } : {}), }; } if (latestRequestUsage?.kind === 'tokens') diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts index 3b912d2deaf..a1b7d67d1bd 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts @@ -20,6 +20,10 @@ import type { SessionEvent } from '@maka/core/events'; import { createRefreshReadCoordinator } from '@maka/core/refresh-read-coordinator'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; +import type { + ContextUsageSnapshot, + ContextUsageTokens, +} from './context-usage-snapshot.js'; import { isTraceRelevantEvent } from './session-trace-refresh.js'; /** @@ -39,20 +43,14 @@ export interface LiveContextRoute { readonly providerType?: string; } -/** The gauge's reading: the last settled request's prompt, and its ceiling. */ -export interface LiveContextUsage { - readonly usageTokens: number; - /** The window the request was metered against, frozen at call time. */ - readonly contextWindow?: number; - /** - * When that request settled, on the Host's clock — the same clock the - * session's own transcript rows carry, so a reader can tell whether this - * snapshot predates a compaction boundary it already knows about. Without - * it, a snapshot that a compaction has replaced is indistinguishable from one - * taken after it. - */ - readonly completedAt?: number; -} +/** + * The live half of the gauge's answer, in the same shape the transcript + * selector uses: a measured prompt until a live compaction boundary retires + * it, then that boundary until a measurement provably settled after it + * lands. `resolveContextUsage` arbitrates the two sources by their `at` + * clocks; giving them one union is what makes that arbitration symmetric. + */ +export type LiveContextReading = ContextUsageSnapshot | undefined; /** * Maps a context diagnostics snapshot onto the gauge, or refuses. @@ -72,7 +70,7 @@ export interface LiveContextUsage { export function liveContextUsageFromDiagnostics( diagnostics: ContextDiagnosticsResult | undefined, route: LiveContextRoute, -): LiveContextUsage | undefined { +): ContextUsageTokens | undefined { if (!diagnostics || diagnostics.status !== 'available') return undefined; if (route.model === undefined || route.providerType === undefined) return undefined; if (diagnostics.modelId !== route.model || diagnostics.providerId !== route.providerType) { @@ -83,11 +81,12 @@ export function liveContextUsageFromDiagnostics( return undefined; } return { - usageTokens: inputTokens, + kind: 'tokens', + tokens: inputTokens, ...(diagnostics.contextWindow !== undefined ? { contextWindow: diagnostics.contextWindow } : {}), - completedAt: diagnostics.completedAt, + ...(diagnostics.completedAt !== undefined ? { at: diagnostics.completedAt } : {}), }; } @@ -148,15 +147,24 @@ export function createLiveContextUsageTracker(input: { delayMs: number; schedule: (callback: () => void, delayMs: number) => unknown; cancel: (handle: unknown) => void; - onChange: (usage: LiveContextUsage | undefined) => void; + onChange: (usage: LiveContextReading) => void; }): LiveContextUsageTracker { let target: LiveContextUsageTarget | undefined; + let appliedAt: number | undefined; const coordinator = createRefreshReadCoordinator({ read: () => target ? input.query(target.sessionId) : Promise.resolve(undefined), apply: (diagnostics) => { // Every target change invalidates in-flight reads before they can apply. if (!diagnostics || !target) return; - input.onChange(liveContextUsageFromDiagnostics(diagnostics, target.route)); + const usage = liveContextUsageFromDiagnostics(diagnostics, target.route); + // A live boundary holds the gauge on "compacted" until a read lands + // that provably settled after it; reads at or before the boundary — or + // carrying no settlement time — cannot re-earn the number. + input.onChange( + appliedAt !== undefined && !(usage !== undefined && usage.at !== undefined && usage.at > appliedAt) + ? { kind: 'compacted', at: appliedAt } + : usage, + ); }, delayMs: input.delayMs, schedule: (callback, delayMs) => { @@ -178,6 +186,7 @@ export function createLiveContextUsageTracker(input: { const changed = !sameLiveContextUsageTarget(target, next); target = next; coordinator.cancel(); + if (changed) appliedAt = undefined; if (!next) { input.onChange(undefined); return; @@ -186,6 +195,15 @@ export function createLiveContextUsageTracker(input: { coordinator.refresh(); }, observe(event) { + if (!target) return; + if (event.type === 'context_compaction_applied') { + if (appliedAt !== undefined && event.ts <= appliedAt) return; + appliedAt = event.ts; + // Retire pre-boundary reads immediately, before the debounced refresh. + coordinator.observe(); + input.onChange({ kind: 'compacted', at: appliedAt }); + return; + } if (isTraceRelevantEvent(event)) coordinator.observe(); }, dispose() { diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts index 9d9d538fbd8..e193b7fcb5c 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts @@ -36,6 +36,8 @@ const TRACE_RELEVANT_EVENT_TYPES: ReadonlySet = new Set([ 'tool_result', 'token_usage', 'provider_retry', + // Its whole ledger effect is a persisted boundary note; easy to miss that it counts. + 'context_compaction_applied', 'error', 'complete', 'abort', diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts index 1933b5e4235..d41f5b44a8b 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts @@ -21,7 +21,7 @@ import { useEffect, useState } from 'react'; import type { SessionInspectorService } from './service.js'; import { createLiveContextUsageTracker, - type LiveContextUsage, + type LiveContextReading, } from './live-context-usage.js'; import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js'; @@ -42,9 +42,9 @@ export function useLiveContextUsage(input: { readonly sessionId: string | undefined; readonly model: string | undefined; readonly providerType: string | undefined; -}): LiveContextUsage | undefined { +}): LiveContextReading { const { inspector } = input; - const [usage, setUsage] = useState(undefined); + const [usage, setUsage] = useState(undefined); const { sessionId, model, providerType } = input; useEffect(() => { const tracker = createLiveContextUsageTracker({ diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index e7ae25514a6..f2a9ae9b743 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -35,7 +35,7 @@ import { resolveContextUsage, type LatestRequestUsage, } from './application/contracts/session-inspector/latest-request-usage.js'; -import type { LiveContextUsage } from './application/contracts/session-inspector/live-context-usage.js'; +import type { LiveContextReading } from './application/contracts/session-inspector/live-context-usage.js'; import { useComposerMentionsContext } from './composer-mentions.js'; import type { GuestComposerProjection } from './features/session-collaboration/index.js'; import { @@ -154,9 +154,7 @@ interface ChatComposerRegionProps * catalog currently reports — one row's tokens against another row's * ceiling. */ - children: ( - usage: LiveContextUsage | undefined, - ) => ReactNode; + children: (usage: LiveContextReading) => ReactNode; }>; canStageContext: boolean; contextPickEnabled: boolean; @@ -264,9 +262,7 @@ export function ChatComposerRegion({ // The composer body as a function of the gauge's live reading, so the probe // — when mounted — can feed it the per-settled-request snapshot (#4717), and // the anchor prop remains the reading it falls back to. - const renderComposer = ( - liveContextUsage: LiveContextUsage | undefined, - ) => { + const renderComposer = (liveContextUsage: LiveContextReading) => { // One question, two answers, and a compaction can make the finer one stale: the // snapshot wins when it landed after the boundary, and the boundary wins // when it did not. diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx index e43864ef2d5..fcb7caa0834 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx @@ -19,7 +19,7 @@ import { useWorkbarServices } from '../../services-context.js'; import type { ReactElement, ReactNode } from 'react'; -import type { LiveContextUsage } from '../../../../application/contracts/session-inspector/live-context-usage.js'; +import type { LiveContextReading } from '../../../../application/contracts/session-inspector/live-context-usage.js'; import { useLiveContextUsage } from '../../../../application/contracts/session-inspector/use-live-context-usage.js'; /** @@ -36,7 +36,7 @@ export function LiveContextUsageProbe(props: { readonly sessionId: string | undefined; readonly model: string | undefined; readonly providerType: string | undefined; - readonly children: (usage: LiveContextUsage | undefined) => ReactNode; + readonly children: (usage: LiveContextReading) => ReactNode; }): ReactElement { const { inspector } = useWorkbarServices(); const usage = useLiveContextUsage({ diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 25cb9dc453b..40e107ebb31 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -685,6 +685,7 @@ export type SessionEvent = | ErrorEvent | CompleteEvent | AbortEvent + | ContextCompactionAppliedEvent | ContextCompactionStartedEvent; export interface TextDeltaEvent extends BaseEvent { @@ -1434,6 +1435,15 @@ export interface ContextCompactionStartedEvent extends BaseEvent { type: 'context_compaction_started'; } +/** + * A persisted compaction has taken over the request projection. Its timestamp + * invalidates older context measurements while the turn is still running. + * The runtime maps this event to a hidden system note before publishing it. + */ +export interface ContextCompactionAppliedEvent extends BaseEvent { + type: 'context_compaction_applied'; +} + // ============================================================================ // UI → Backend commands // ============================================================================ diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index a59cdbfffff..9e9c401ca15 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -588,6 +588,35 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('roundtrips live compaction boundaries with a closed event shape', () => { + const frame = { + kind: 'subscription.session_event' as const, + hostEpoch: 'epoch-1', + subscriptionId: 'subscription-1', + sequence: 2, + sessionId: 'session-1', + runId: 'run-1', + event: { + type: 'context_compaction_applied' as const, + id: 'compaction-1', + turnId: 'turn-1', + ts: 123456789, + }, + }; + const encoded = encodeProtocolMessage(frame); + assert.deepEqual(decodeHostFrame(JSON.parse(encoded.toString('utf8'))), frame); + for (const event of [ + { ...frame.event, toolUseId: 'tool-1' }, + { ...frame.event, ts: -1 }, + { ...frame.event, ts: '123456789' }, + { ...frame.event, id: '' }, + { ...frame.event, turnId: '' }, + { type: frame.event.type, id: frame.event.id, turnId: frame.event.turnId }, + ]) { + assert.throws(() => decodeHostFrame({ ...frame, event }), isInvalidFrame); + } + }); + test('preserves opaque nested call and step identities in subscription tool events', () => { const parentId = 'p'.repeat(128); const frame = { diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 7696373ec14..1501ab6304d 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -951,6 +951,102 @@ test('startup recovery budgets the legal 64-source admission from write ceilings } }); +test('forwards compaction boundaries while the root turn is still running', async () => { + const release = deferred(); + const boundaries = [1, 2].map((index) => ({ + type: 'context_compaction_applied' as const, + id: `compaction-${index}`, + turnId: 'compaction-turn', + ts: 123456789 + index, + })); + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register( + 'ai-sdk', + (context) => + new (class extends FakeBackend { + override async *send(input: BackendSendInput): AsyncIterable { + for (const event of boundaries) yield event; + await release.promise; + yield { + type: 'complete', + id: 'compaction-complete', + turnId: input.turnId, + ts: Date.now(), + stopReason: 'end_turn', + }; + } + })(context), + ), + }); + const sink = new RecordingContinuitySink(); + const continuity = fixture.currentContinuity(); + const connectionId = 'compaction-observer'; + const observer = continuity.attachConnection(connectionId, sink); + const context = operationContext(fixture.hostEpoch, fixture.acquireResidency, connectionId); + try { + const opened = await continuity.handlers['subscription.open']( + { sessionId: fixture.sessionId, transcript: { kind: 'none' } }, + context, + ); + assert.ok(opened.ok); + await continuity.handlers['subscription.ready']( + { subscriptionId: opened.result.subscriptionId }, + context, + ); + const started = await fixture.interactiveTurns.handlers['turn.start']( + { sessionId: fixture.sessionId, turnId: 'compaction-turn', content: { text: 'compact' } }, + context, + ); + assertStartedTurn(started); + await waitFor( + () => + sink.frames.filter( + (frame) => + frame.kind === 'subscription.session_event' && + frame.event.type === 'context_compaction_applied', + ).length === 2, + ); + const forwarded = sink.frames.filter((frame) => frame.kind === 'subscription.session_event'); + assert.deepEqual( + forwarded.map((frame) => frame.event), + boundaries, + ); + assert.ok( + forwarded.every( + (frame) => + frame.sessionId === fixture.sessionId && frame.runId === started.result.turn.runId, + ), + ); + assert.equal( + ( + await fixture.coordinator.read({ + sessionId: fixture.sessionId, + turnId: 'compaction-turn', + runId: started.result.turn.runId, + }) + ).status, + 'running', + ); + assert.equal( + sink.frames.some( + (frame) => + frame.kind === 'subscription.session_projection' && + frame.snapshot.rootTurn?.status === 'completed', + ), + false, + ); + release.resolve(); + await fixture.coordinator.whenIdle(fixture.sessionId); + } finally { + release.resolve(); + observer.close(); + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + test('startup recovery replays one admitted safe-boundary continuation without a UserMessage', async () => { const workspaceIdentity = 'workspace-safe-boundary-recovery'; const fixture = await createFailureFixture({ diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 1a3a604f114..2a386b86096 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -612,6 +612,28 @@ test('does not replay settled transcript steps when the active step reaches term ); }); +test('projects each live compaction boundary immediately with its original identity and timestamp', () => { + const projector = projectorAt(); + for (const sequence of [1, 2]) { + const event = { + type: 'context_compaction_applied' as const, + id: `compaction-${sequence}`, + turnId: 'turn-1', + ts: 123456789 + sequence, + }; + const update = projector.accept({ + kind: 'subscription.session_event', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence, + sessionId: 'session-1', + runId: 'run-1', + event, + }); + assert.deepEqual(update.events, [event]); + } +}); + test('marks Runtime Host tool results whose durable content is omitted', () => { const projector = projectorAt(); diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index e3f28ac54d5..22b1b2a1e72 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -631,6 +631,14 @@ function projectSessionEvent( frame: Extract, ): SessionEvent { const event = frame.event; + if (event.type === 'context_compaction_applied') { + return { + type: event.type, + id: event.id, + turnId: event.turnId, + ts: event.ts, + }; + } if (event.type === 'steering_message') { const steering: SessionSteeringEvent = { type: 'steering_message', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 63798cbf587..3a4a71f0444 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -104,11 +104,11 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 216 as const; -// 216: token_usage.lastRequestAnchor may carry completedAt, the provider -// request settlement time, and transcripts may carry -// context_compaction_applied system notes. Older peers reject both in the -// closed transcript shape. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 217 as const; +// 217: token_usage.lastRequestAnchor may carry completedAt, the provider +// request settlement time; transcripts may carry context_compaction_applied +// system notes; and session subscriptions forward the same boundary during +// running turns. Older peers reject all three in their closed shapes. // 215: WorkHub coordination uses canonical configuration and scoped delegated permissions. // 212: Session catalogs carry `backgroundActivity` and `backgroundActivityVersion` // for Host-owned activity and generation-scoped ordering independent of Session diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 1ef4609d3b1..f754a0a2ca6 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -233,11 +233,18 @@ export interface SessionSteeringEvent { content: MessageContent; } +export interface SessionContextCompactionAppliedEvent { + type: 'context_compaction_applied'; + id: string; + turnId: string; + ts: number; +} + export interface SessionEventFrame extends SubscriptionEnvelope { kind: 'subscription.session_event'; sessionId: string; runId: string; - event: SessionToolEvent | SessionSteeringEvent; + event: SessionToolEvent | SessionSteeringEvent | SessionContextCompactionAppliedEvent; } export interface SessionTranscriptAdvancedFrame extends SubscriptionEnvelope { @@ -801,8 +808,22 @@ function decodeAssistantDelta(value: unknown): SessionAssistantDelta { }; } -function decodeSessionFrameEvent(value: unknown): SessionToolEvent | SessionSteeringEvent { +function decodeSessionFrameEvent(value: unknown): SessionEventFrame['event'] { const record = requireRecord(value, 'Session event'); + if (record.type === 'context_compaction_applied') { + assertExactKeys(record, 'Session context compaction applied event', [ + 'type', + 'id', + 'turnId', + 'ts', + ]); + return { + type: record.type, + id: requireId(record.id, 'Session context compaction applied event id'), + turnId: requireEntityId(record.turnId, 'turnId'), + ts: requireCount(record.ts, 'Session context compaction applied event timestamp'), + }; + } if (record.type === 'steering_message') return decodeSessionSteeringEvent(record); return decodeSessionToolEvent(record); } diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index 0bd154a86a1..3ad565a90b9 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -4021,6 +4021,7 @@ function isRuntimeSessionForwardedEvent( event.type === 'tool_result_preview' || event.type === 'tool_result' || event.type === 'steering_message' || + event.type === 'context_compaction_applied' || event.type === 'provider_retry' ); } diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index a25332df3f3..a9b6c45a6b3 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -45,8 +45,6 @@ import { type SessionEventFrame, type SessionRuntimeResourcePtyDataFrame, type OrderedSubscriptionFrame, - type SessionSteeringEvent, - type SessionToolEvent, type SessionTranscriptAdvancedFrame, type SessionTranscriptPageInput, type OperationOutcome, @@ -104,6 +102,7 @@ type RuntimeSessionForwardedEventType = | 'tool_result_preview' | 'tool_result' | 'steering_message' + | 'context_compaction_applied' | 'provider_retry'; export type RuntimeSessionForwardedEvent = Extract< @@ -2011,7 +2010,15 @@ function projectSessionEvent( >, sessionId: string, shared = false, -): SessionToolEvent | SessionSteeringEvent { +): SessionEventFrame['event'] { + if (event.type === 'context_compaction_applied') { + return { + type: event.type, + id: event.id, + turnId: event.turnId, + ts: event.ts, + }; + } if (event.type === 'steering_message') { // The durable steering echo: forwarded verbatim so subscribers render the // interjection in place instead of depending on observing the transient diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index 257a1b041db..4d9c0595e44 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -29,6 +29,7 @@ import type { SessionHeader } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { z } from 'zod'; +import { deferred } from '@maka/core/test-only/async-primitives'; import { AiSdkBackend } from '../ai-sdk-backend.js'; import { buildDefaultContextBudgetPolicy } from '../context-budget-policy.js'; import { @@ -102,6 +103,8 @@ interface MidTurnFixture { } interface MidTurnFixtureOptions { + /** Hold a provider request open to inspect live events before settlement. */ + beforeRequest?: (call: number) => Promise; contextWindow?: number; /** Omit the model's context window entirely (unknown model metadata). */ withoutContextWindow?: boolean; @@ -308,6 +311,7 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { throw Object.assign(new Error('aborted'), { name: 'AbortError' }); } const call = model.doStreamCalls.length; + await options.beforeRequest?.(call); if (options.firstRequestContextOverflow && call === 1) { throw Object.assign(new Error('prompt is too long: 213462 tokens > 200000 maximum'), { name: 'AI_APICallError', @@ -765,6 +769,47 @@ function compactionDecisions( } function defineMidTurnSuite(consumer: ConsumerMode): void { + test('publishes the applied boundary while the post-compaction request is still running', { + timeout: 5_000, + }, async () => { + const requestStarted = deferred(); + const requestHeld = deferred(); + const fixture = buildFixture({ + beforeRequest: async (call) => { + if (call !== 3) return; + requestStarted.resolve(); + await requestHeld.promise; + }, + }); + const turn = runFixtureTurn(fixture, consumer); + try { + await requestStarted.promise; + assert.equal(fixture.recorded.length, 1, 'the checkpoint has been persisted'); + assert.equal( + fixture.events.some((event) => event.type === 'complete'), + false, + ); + assert.equal( + fixture.events.filter((event) => event.type === 'context_compaction_applied').length, + 1, + 'the live boundary must arrive before the next request settles', + ); + const boundaries = fixture.ledger.filter( + (event) => + event.content?.kind === 'system_note' && + event.content.note === 'context_compaction_applied', + ); + assert.equal(boundaries.length, 1, 'the stream persists exactly one canonical boundary'); + const applied = fixture.events.find((event) => event.type === 'context_compaction_applied'); + assert.equal(boundaries[0]?.id, applied?.id); + assert.equal(boundaries[0]?.ts, applied?.ts); + assert.equal(boundaries[0]?.modelVisibility, 'hidden'); + } finally { + requestHeld.resolve(); + await turn; + } + }); + test('keeps user_stop when stopping while a usage-triggered fold is summarizing', async () => { // The fold now starts from the provider's real usage instead of a local // estimate, but a user stop during its summarizer call must still end the @@ -1035,6 +1080,10 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', ); assert.equal(failedOpen?.failOpenReason, 'write_failed'); + assert.equal( + fixture.events.some((event) => event.type === 'context_compaction_applied'), + false, + ); }); test('a malformed summarizer completion fails open end-to-end with its granular reason', async () => { @@ -1216,6 +1265,9 @@ function defineMidTurnSuite(consumer: ConsumerMode): void { assert.equal(fixture.summarizerCalls, 2); assert.equal(fixture.recorded.length, 2); + const applied = fixture.events.filter((event) => event.type === 'context_compaction_applied'); + assert.equal(applied.length, 2, 'each applied fold publishes its own boundary'); + assert.notEqual(applied[0]?.id, applied[1]?.id); // The second fold covers the step the first one left out. assert.equal( fixture.recorded[1]!.coverage.eventCount > fixture.recorded[0]!.coverage.eventCount, diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 5e94c9d347e..8cc2ba3cdb3 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -423,6 +423,9 @@ type ProjectionSamples = { }; const PROJECTION_SAMPLES: ProjectionSamples = { + context_compaction_applied: { + subject: { type: 'context_compaction_applied', id: 'e', turnId: 'turn-1', ts: 1 }, + }, text_delta: { subject: { type: 'text_delta', id: 'e', turnId: 'turn-1', ts: 1, messageId: 'm1', text: 'h' }, }, diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 0f599354df1..5599a0db600 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -1059,7 +1059,15 @@ export class AiSdkTurn { // recorded when each compaction lands, so it exists mid-turn and a stop or // stream error cannot erase it — unlike the settlement-time display note. const recordCompactionApplied = async (): Promise => { - await this.recordSystemNote('context_compaction_applied', turnId); + // The consumer persists the hidden note and forwards the same boundary + // to live subscribers. Await its ack before dispatching the replacement + // prompt, so even a slow consumer observes the boundary before new usage. + await queue.pushAndWaitUntilConsumed({ + type: 'context_compaction_applied', + id: this.deps.newId(), + turnId, + ts: this.deps.now(), + }); }; const trace = new RunTrace({ sessionId: this.deps.backend.sessionId, diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 75175e05427..b7cdb2636f6 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -488,6 +488,15 @@ function mapBackendSessionEvent( refs: { toolCallId: event.toolUseId }, }; + case 'context_compaction_applied': + return { + ...base, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: { kind: 'system_note', note: event.type }, + }; + // ── Steering: a user message injected mid-turn at a step boundary ───── // Persisted as a first-class user event so the ledger, transcript, and // future-turn context all carry the interjection in place. diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index 5791d883961..f02475b461d 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -133,7 +133,7 @@ test('the context usage share resolves declared, then metered, then metadata win // The user's declaration wins over every reported window. assert.equal( await render({ - reading: { kind: 'measured', tokens: 40_000, meteredWindow: 80_000 }, + reading: { kind: 'measured', tokens: 40_000, contextWindow: 80_000 }, declaredContextWindow: 100_000, metadataContextWindow: 64_000, }), @@ -142,7 +142,7 @@ test('the context usage share resolves declared, then metered, then metadata win // The metered window was frozen against the same request as the tokens, // so it outranks the catalog's metadata window. assert.equal( - await render({ reading: { kind: 'measured', tokens: 40_000, meteredWindow: 80_000 }, metadataContextWindow: 64_000 }), + await render({ reading: { kind: 'measured', tokens: 40_000, contextWindow: 80_000 }, metadataContextWindow: 64_000 }), '50%', ); // Metadata is the fallback… @@ -156,7 +156,7 @@ test('the context usage share resolves declared, then metered, then metadata win ), 'Usage'); // A later successful measurement restores the share in the same mounted control. assert.equal(await render( - { reading: { kind: 'measured', tokens: 10_000, meteredWindow: 100_000 } }, + { reading: { kind: 'measured', tokens: 10_000, contextWindow: 100_000 } }, 'Context window: 10% used (10K / 100K tokens).', ), '10%'); assert.equal(await render( diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index a467e330d6e..08cec37e2e1 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -2666,7 +2666,7 @@ function ContextUsageAction(props: { const copy = getConversationCopy(useUiLocale()).messages; const { reading } = props; const usageTokens = reading.kind === 'measured' ? reading.tokens : undefined; - const meteredWindow = reading.kind === 'measured' ? reading.meteredWindow : undefined; + const meteredWindow = reading.kind === 'measured' ? reading.contextWindow : undefined; // A window from any source is enough to show a share, and the order is a // claim about which window the number was earned against: the user's // declaration first — it is the user's intent, and the only one that arms diff --git a/packages/ui/src/context-usage-reading.ts b/packages/ui/src/context-usage-reading.ts index 757cf604811..188b86bd020 100644 --- a/packages/ui/src/context-usage-reading.ts +++ b/packages/ui/src/context-usage-reading.ts @@ -24,6 +24,6 @@ export type ContextUsageReading = readonly kind: 'measured'; readonly tokens: number; /** Frozen alongside this measurement; never borrowed from another reading. */ - readonly meteredWindow?: number; + readonly contextWindow?: number; } | { readonly kind: 'stale'; readonly reason: 'compaction' }; From aec2f1e3aef5b6c963815260888ea9a0ad4741f5 Mon Sep 17 00:00:00 2001 From: liuzhaochen03 Date: Tue, 6 Oct 2026 22:56:38 +0800 Subject: [PATCH 11/11] test(ui): align context usage assertions with shortened tooltips Preserve the upstream measured and unavailable tooltip copy in the post-compaction component assertions. The branch compatibility bump was already reconciled while replaying the protocol changes: epoch 214 is ahead of main at 212 and the 213 claims in open PRs #5394 and #5969. Generated-by: Codex --- packages/ui/src/__tests__/composer-context-usage.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/__tests__/composer-context-usage.test.tsx b/packages/ui/src/__tests__/composer-context-usage.test.tsx index f02475b461d..e9d944a888d 100644 --- a/packages/ui/src/__tests__/composer-context-usage.test.tsx +++ b/packages/ui/src/__tests__/composer-context-usage.test.tsx @@ -157,11 +157,11 @@ test('the context usage share resolves declared, then metered, then metadata win // A later successful measurement restores the share in the same mounted control. assert.equal(await render( { reading: { kind: 'measured', tokens: 10_000, contextWindow: 100_000 } }, - 'Context window: 10% used (10K / 100K tokens).', + 'Context: 10% (10K / 100K tokens)', ), '10%'); assert.equal(await render( { reading: { kind: 'unavailable' }, declaredContextWindow: 100_000 }, - 'No usage data is available for this request.', + 'No context usage data', ), 'Usage'); } finally { await act(() => root.unmount());