diff --git a/CHANGELOG.md b/CHANGELOG.md index 5199c4ae50..ebc503234b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,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__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index 301f8aeffa..d2038ed08e 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 0a7f0abd3d..dead0dea8a 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(); }); @@ -254,7 +255,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(); }); @@ -276,14 +277,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(); }); @@ -310,7 +311,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(); }); @@ -355,7 +356,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(); }); @@ -381,7 +382,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/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e8ecc9fb4d..14049ee31a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2430,7 +2430,7 @@ function AppShellContent({ activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} - latestRequestUsageTokens={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)} + latestRequestUsage={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)} onOpenContextUsage={() => commands.toggleTool('inspector')} LiveContextUsageProbe={LiveContextUsageProbe} contextUsageSessionId={ownerActiveId} 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 ce32b827fe..2078f8c190 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 e6852c37e2..17f85d7c5b 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 @@ -43,6 +43,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; } /** @@ -55,6 +63,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, @@ -74,6 +86,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 91b55b8256..467d6f9121 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -30,7 +30,13 @@ import { UserQuestionPrompt, } from '@maka/ui'; import type { ComposerHandle } from '@maka/ui'; -export { selectLatestRequestUsage } from './application/contracts/session-inspector/latest-request-usage.js'; +import { + resolveContextUsage, + selectLatestRequestUsage, + type LatestRequestUsage, +} from './application/contracts/session-inspector/latest-request-usage.js'; +export { selectLatestRequestUsage }; +import type { LiveContextUsage } from './application/contracts/session-inspector/live-context-usage.js'; import { useComposerMentionsContext } from './composer-mentions.js'; import { readNewTaskReloadDraft, @@ -122,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: @@ -149,7 +155,7 @@ interface ChatComposerRegionProps * ceiling. */ children: ( - usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined, + usage: LiveContextUsage | undefined, ) => ReactNode; }>; directoryComposerProps: Pick< @@ -175,7 +181,7 @@ export function ChatComposerRegion({ respondToUserForm, stop, boundaryUnreadableNotice, - latestRequestUsageTokens, + latestRequestUsage, onOpenContextUsage, LiveContextUsageProbe, directoryComposerProps, @@ -196,14 +202,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; @@ -265,47 +263,55 @@ 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/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index 03d0076a7a..9e8bdfa4a7 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 { WorkHubComposer } from './workhub-composer.js'; import { WorkHubConversation } from './workhub-conversation.js'; @@ -98,10 +101,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 = controller.newWorkDefaults.thinkingLevel && thinkingLevels.includes(controller.newWorkDefaults.thinkingLevel) ? controller.newWorkDefaults.thinkingLevel : undefined; + const locale = useUiLocale(); const t = workHubLiveCopy[locale]; const shortcutLabel = navigator.platform.toLowerCase().includes('mac') ? '⌘⇧K' : 'Ctrl+Shift+K'; @@ -401,9 +413,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 8a31e20511..455e23860c 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2492,7 +2492,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { turns={12} composer={{ contextUsage: { - usageTokens: 37_000, + reading: { kind: 'measured', tokens: 37_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 9f6c229f40..f11cb531fe 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 dcef7b5fb0..21fbdf4988 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -32,6 +32,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'; @@ -458,14 +459,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; @@ -2493,13 +2488,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 @@ -2508,17 +2505,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; }; @@ -541,6 +542,7 @@ const CONVERSATION_COPY = { contextUsageNoWindow: (used) => `已用 ${formatCompactTokenCount(used)} token;上下文窗口上限未知`, contextUsageUnavailable: '暂无用量数据', + contextUsageCompacted: '上下文已压缩,用量将在下一次请求完成后更新。', contextUsageOpen: '打开用量追踪', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', }, @@ -667,6 +669,7 @@ const CONVERSATION_COPY = { contextUsageNoWindow: (used) => `已用 ${formatCompactTokenCount(used)} token;上下文視窗上限未知`, contextUsageUnavailable: '暫無用量資料', + contextUsageCompacted: '上下文已壓縮,用量將在下一次請求完成後更新。', contextUsageOpen: '開啟用量追蹤', stepLimit: '已達到本輪工具步驟上限,任務可能尚未完成。傳送“繼續”即可接著處理。', }, @@ -790,6 +793,8 @@ const CONVERSATION_COPY = { contextUsageNoWindow: (used) => `This request used ${formatCompactTokenCount(used)} tokens; no context limit is available for this model.`, contextUsageUnavailable: 'No usage data is available for this request.', + 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 b66a96e947..0a178e4528 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -37,6 +37,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';