Files
qwen-code/packages/web-shell/client/utils/contextCompression.ts
ytahdn a9ff4567f7 feat(web-shell): render the compression outcome from structured meta (#12141)
* feat(web-shell): render the compression outcome from structured meta

The compression commands stream their outcome as English sentences over ACP,
which a host that owns its own UI language cannot render. They now carry the
outcome as machine-readable `_meta` next to the sentence, the transcript record
and replay pass it through, and the web shell renders it in the user's language
with the token counts formatted for that language.

A compression that finds nothing to strip reports a terminal no-op phase rather
than reusing the pending one: the payload-driven row has to state the outcome,
and a pending row is dropped once the turn ends.

The invocation note rides its own `_meta` key rather than sharing the result's.
The daemon UI reducer folds a turn's text frames into one block and spreads
`_meta` key by key, so a shared key would be overwritten by the frame that
follows. Nothing else reads the new keys and no frame gains the
discrete-message marker, so a host that renders text as-is sees the same
sentences it saw before.

Opening a session whose last action was a compression also reconciles the
composer's context counters once. They are seeded from the last usage frame in
the replay, and a compression emits none, so they described the context it
replaced; a read is only written back while the session is live and its model
and context window have settled, which is what the reconcile waits for.

* docs(design): describe the structured compression outcome

The design records why the outcome travels as metadata beside the sentence, why
the invocation note needs its own key, why the no-op is a terminal phase, and
how the composer counters reconcile on an attach that ends on a compression. It
also keeps the compatibility analysis for other clients, which is a constraint
of this change rather than a follow-up.
2026-09-18 07:06:37 +00:00

92 lines
2.9 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Structured companion to the English sentence `/compress` streams over ACP.
*
* The daemon ships on its own release cadence, so this is parsed defensively:
* an unreadable payload falls back to rendering the sentence as before rather
* than dropping the message.
*/
export interface ContextCompressionResult {
originalTokenCount: number;
newTokenCount: number;
originalTokenCountIsEstimated: boolean;
newTokenCountIsEstimated: boolean;
/** Server-authored advisory. Free-form text, not a translation key. */
warning?: string;
}
export type ContextCompressionMeta =
| { phase: 'notice'; instructionsLimit: number }
| { phase: 'progress' }
| { phase: 'noop' }
| { phase: 'done'; result: ContextCompressionResult };
export function parseContextCompressionMeta(
value: unknown,
): ContextCompressionMeta | undefined {
if (!isRecord(value)) return undefined;
if (value['phase'] === 'notice') {
const instructionsLimit = toCount(value['instructionsLimit']);
return instructionsLimit === undefined
? undefined
: { phase: 'notice', instructionsLimit };
}
if (value['phase'] === 'progress') return { phase: 'progress' };
if (value['phase'] === 'noop') return { phase: 'noop' };
if (value['phase'] !== 'done') return undefined;
const result = parseContextCompressionResult(value);
return result ? { phase: 'done', result } : undefined;
}
function parseContextCompressionResult(
value: unknown,
): ContextCompressionResult | undefined {
if (!isRecord(value)) return undefined;
const originalTokenCount = toCount(value['originalTokenCount']);
const newTokenCount = toCount(value['newTokenCount']);
if (originalTokenCount === undefined || newTokenCount === undefined) {
return undefined;
}
const warning = value['warning'];
return {
originalTokenCount,
newTokenCount,
originalTokenCountIsEstimated:
value['originalTokenCountIsEstimated'] === true,
newTokenCountIsEstimated: value['newTokenCountIsEstimated'] === true,
...(typeof warning === 'string' && warning.length > 0 ? { warning } : {}),
};
}
/**
* Token count for a compression line, grouped for the UI language rather than
* the browser's locale. `~` marks a locally estimated count, the same
* convention the TUI banner uses (#9309).
*/
export function formatCompressionTokens(
count: number,
isEstimated: boolean,
language: string,
): string {
return `${isEstimated ? '~' : ''}${count.toLocaleString(language)}`;
}
/** A finite, non-negative integer count — token totals and character limits. */
function toCount(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isFinite(value) &&
Number.isInteger(value) &&
value >= 0
? value
: undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}