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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 71 additions & 6 deletions apps/desktop/src/main/__tests__/live-context-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ describe('createLiveContextUsageTracker', () => {
assert.equal(query.pending.length, 1);
query.pending[0]!.resolve(available());
await Promise.resolve();
assert.deepEqual(seen, [{ usageTokens: 79_436, contextWindow: 128_000 }]);
// 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 }]);
tracker.dispose();
});

Expand Down Expand Up @@ -183,6 +185,7 @@ describe('createLiveContextUsageTracker', () => {
query.pending[1]!.resolve(available({ inputTokens: 52_000 }));
await Promise.resolve();
assert.deepEqual(seen, [
undefined,
{ usageTokens: 40_000, contextWindow: 128_000 },
{ usageTokens: 52_000, contextWindow: 128_000 },
]);
Expand Down Expand Up @@ -228,7 +231,7 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
query.pending[0]!.resolve(available({ inputTokens: 10_000 }));
await Promise.resolve();
assert.deepEqual(seen, [{ usageTokens: 60_000, contextWindow: 128_000 }]);
assert.deepEqual(seen, [undefined, { usageTokens: 60_000, contextWindow: 128_000 }]);
tracker.dispose();
});

Expand All @@ -251,7 +254,63 @@ describe('createLiveContextUsageTracker', () => {
query.pending[1]!.reject(new Error('host not ready'));
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(seen, [{ usageTokens: 79_436, contextWindow: 128_000 }]);
assert.deepEqual(seen, [undefined, { usageTokens: 79_436, contextWindow: 128_000 }]);
tracker.dispose();
});

it('clears the previous target’s reading before the first read on a new one', async () => {
const timer = fakeTimer();
const query = scriptedQuery();
const seen: unknown[] = [];
const tracker = createLiveContextUsageTracker({
query: query.query,
delayMs: 400,
schedule: timer.schedule,
cancel: timer.cancel,
onChange: (usage) => seen.push(usage),
});
tracker.setTarget({ sessionId: 's1', route: ROUTE });
query.pending[0]!.resolve(available());
await Promise.resolve();

// 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]);

// …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]);
tracker.dispose();
});

it('keeps the standing value when re-aimed at the same target', async () => {
const timer = fakeTimer();
const query = scriptedQuery();
const seen: unknown[] = [];
const tracker = createLiveContextUsageTracker({
query: query.query,
delayMs: 400,
schedule: timer.schedule,
cancel: timer.cancel,
onChange: (usage) => seen.push(usage),
});
tracker.setTarget({ sessionId: 's1', route: ROUTE });
query.pending[0]!.resolve(available());
await Promise.resolve();

// An identical re-aim is not a target change: the value still answers the
// question, so clearing it would only flicker. The re-read happens, and a
// failure keeps the value standing as always.
tracker.setTarget({ sessionId: 's1', route: { ...ROUTE } });
assert.equal(query.pending.length, 2);
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 }]);
tracker.dispose();
});

Expand All @@ -271,7 +330,8 @@ describe('createLiveContextUsageTracker', () => {
assert.equal(timer.scheduled, 0);
query.pending[0]!.resolve(available());
await Promise.resolve();
assert.deepEqual(seen, [undefined]);
// Once for aiming, once for the target going away.
assert.deepEqual(seen, [undefined, undefined]);
tracker.dispose();
});

Expand All @@ -293,7 +353,9 @@ describe('createLiveContextUsageTracker', () => {
await Promise.resolve();
query.pending[0]!.resolve(available({ inputTokens: 99_000 }));
await Promise.resolve();
assert.deepEqual(seen, [{ usageTokens: 5_000, contextWindow: 128_000 }]);
// 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 }]);
tracker.dispose();
});

Expand All @@ -318,8 +380,10 @@ describe('createLiveContextUsageTracker', () => {
query.pending[1]!.resolve(available());
await Promise.resolve();
assert.deepEqual(seen, [
undefined,
{ usageTokens: 79_436, contextWindow: 128_000 },
undefined,
undefined,
]);
tracker.dispose();
});
Expand All @@ -341,6 +405,7 @@ describe('createLiveContextUsageTracker', () => {
assert.equal(timer.scheduled, 0);
query.pending[0]!.resolve(available());
await Promise.resolve();
assert.deepEqual(seen, []);
// Only the aiming clear lands; the disposed tracker's read is dropped.
assert.deepEqual(seen, [undefined]);
});
});
21 changes: 18 additions & 3 deletions apps/desktop/src/renderer/chat-composer-region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,16 @@ interface ChatComposerRegionProps
sessionId: string | undefined;
model: string | undefined;
providerType: string | undefined;
children: (usage: { readonly usageTokens: number } | undefined) => ReactNode;
/**
* The snapshot's reading as a PAIR: the metered tokens and the window the
* same request was metered against. Dropping the window would leave the
* gauge to divide the snapshot's numerator by whatever window the live
* catalog currently reports — one row's tokens against another row's
* ceiling.
*/
children: (
usage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
) => ReactNode;
}>;
directoryComposerProps: Pick<
ComponentProps<typeof Composer>,
Expand Down Expand Up @@ -296,14 +305,20 @@ 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: { readonly usageTokens: number } | undefined) => (
const renderComposer = (
liveContextUsage: { readonly usageTokens: number; readonly contextWindow?: number } | undefined,
) => (
<ComposerGoalProjectionConsumer>
{(goalProjection) => (
<Composer
ref={composerRef}
{...composerRest}
contextUsage={contextUsage && liveContextUsage
? { ...contextUsage, usageTokens: liveContextUsage.usageTokens }
? {
...contextUsage,
usageTokens: liveContextUsage.usageTokens,
meteredContextWindow: liveContextUsage.contextWindow,
}
: contextUsage}
// AppShell carries staged attachments into both queued and steering
// follow-ups. Other Composer hosts remain gated by default because a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ export interface LiveContextUsage {
*
* "Used" is `inputTokens` — the prompt of the most recent settled request.
* That is deliberately NOT input+output: the snapshot does not carry output,
* and the inspector's context bar reads the same field, so both indicators in
* the window draw one number from one row and cannot disagree mid-turn.
* and the inspector's context bar reads the same field. The frozen
* `contextWindow` travels with the tokens, so the gauge can divide one row's
* 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.
*/
export function liveContextUsageFromDiagnostics(
diagnostics: ContextDiagnosticsResult | undefined,
Expand Down Expand Up @@ -79,6 +82,22 @@ export interface LiveContextUsageTarget {
readonly route: LiveContextRoute;
}

/**
* Identity, not reference: two targets answer the same question when their
* session and route match field by field.
*/
function sameLiveContextUsageTarget(
left: LiveContextUsageTarget | undefined,
right: LiveContextUsageTarget | undefined,
): boolean {
if (left === undefined || right === undefined) return left === right;
return (
left.sessionId === right.sessionId &&
left.route.model === right.route.model &&
left.route.providerType === right.route.providerType
);
}

export interface LiveContextUsageTracker {
/** Aims the tracker at a session, or at nothing. Reads immediately. */
setTarget(target: LiveContextUsageTarget | undefined): void;
Expand All @@ -100,7 +119,11 @@ export interface LiveContextUsageTracker {
* trace-relevant live events, coalesced — with the three protections the
* inspector proved out: a revision counter drops reads that answer an older
* question, a failed read keeps the last value standing, and a target change
* discards whatever is still in flight.
* clears that value first and discards whatever is still in flight. The last
* two compose rather than collide: the value kept standing is only ever the
* CURRENT target's, because switching targets clears the previous target's
* reading before the first read on the new one — a rejected first read must
* not pin the old target's number in place.
*
* Framework-free on purpose: the timer and the query are injected, so the
* policy is testable without a DOM, and the hook in
Expand Down Expand Up @@ -143,14 +166,21 @@ export function createLiveContextUsageTracker(input: {
setTarget(next) {
// Any target change — another session, another route, or none — makes
// the current reading unanswerable until the next read lands, and
// invalidates every read already in flight.
// invalidates every read already in flight. A changed target clears the
// reading on screen BEFORE the first read on the new one: that reading
// answers the previous target's question, and a rejected first read
// would otherwise pin it there indefinitely. Re-aiming at the SAME
// target does not clear — the standing value still answers it, and
// blanking it would flicker.
revision += 1;
const changed = !sameLiveContextUsageTarget(target, next);
target = next;
coalescer.cancel();
if (!next) {
input.onChange(undefined);
return;
}
if (changed) input.onChange(undefined);
refresh();
},
observe(event) {
Expand Down
70 changes: 70 additions & 0 deletions packages/ui/src/__tests__/composer-context-usage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,73 @@ test('the context usage action opens its host trace surface', async () => {
Object.assign(globalThis, original);
}
});

test('the context usage share resolves declared, then metered, then metadata window', async () => {
const original = {
document: globalThis.document,
window: globalThis.window,
IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}).IS_REACT_ACT_ENVIRONMENT,
};
const { document, window } = parseHTML('<div id="root"></div>');
window.getComputedStyle = () => ({
direction: 'ltr',
writingMode: 'horizontal-tb',
getPropertyValue: () => '',
}) as unknown as CSSStyleDeclaration;
Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true });
const container = document.querySelector('#root');
assert.ok(container);
const root = createRoot(container);

const render = async (
contextUsage: {
usageTokens?: number;
declaredContextWindow?: number;
meteredContextWindow?: number;
metadataContextWindow?: number;
},
) => {
await act(() => root.render(
<LocaleProvider locale="en">
<Composer
contextUsage={{ ...contextUsage, onOpen: () => undefined }}
onSend={() => undefined}
onStop={() => undefined}
/>
</LocaleProvider>,
));
const action = container.querySelector<HTMLButtonElement>(
'button[aria-label="Open usage trace"]',
);
assert.ok(action);
return action.textContent?.trim();
};

try {
// The user's declaration wins over every reported window.
assert.equal(
await render({
usageTokens: 40_000,
declaredContextWindow: 100_000,
meteredContextWindow: 80_000,
metadataContextWindow: 64_000,
}),
'40%',
);
// 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 }),
'50%',
);
// Metadata is the fallback…
assert.equal(await render({ usageTokens: 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');
} finally {
await act(() => root.unmount());
Object.assign(globalThis, original);
}
});
22 changes: 16 additions & 6 deletions packages/ui/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,12 @@ export const Composer = forwardRef<
contextUsage?: {
usageTokens?: number;
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;
Expand Down Expand Up @@ -2247,16 +2253,20 @@ export const Composer = forwardRef<
function ContextUsageAction(props: {
usageTokens?: number;
declaredContextWindow?: number;
meteredContextWindow?: number;
metadataContextWindow?: number;
onOpen(): void;
}) {
const copy = getConversationCopy(useUiLocale()).messages;
// A window from either source is enough to show a share: the user's
// declaration when there is one, otherwise the model's reported window. The
// distinction matters for the compaction threshold, which only a declaration
// arms, not for reading a number off the screen. With no window at all the
// usage stands on its own.
const window = props.declaredContextWindow ?? props.metadataContextWindow;
// 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
// the compaction threshold — then the metered window frozen alongside the
// usage, so a live reading keeps its numerator and denominator from the
// 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)}%`
Expand Down