Merge pull request #2094: fix(coding-agent): cache status line context usage
This commit is contained in:
@@ -121,6 +121,18 @@ function tokensForMessage(msg: AgentMessage): number {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
interface MessageTokenTotalsCache {
|
||||
messagesRef: readonly AgentMessage[];
|
||||
stableCount: number;
|
||||
stableTokens: number;
|
||||
lastStableMessage: AgentMessage | undefined;
|
||||
lastStableFingerprint: string | undefined;
|
||||
}
|
||||
|
||||
function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
||||
return segments.includes("context_pct") || segments.includes("context_total");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// StatusLineComponent
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -160,20 +172,19 @@ export class StatusLineComponent implements Component {
|
||||
} | null = null;
|
||||
#usageFetchedAt = 0;
|
||||
#usageInFlight = false;
|
||||
// Context breakdown — incremental cache. Replaces the previous 2-second
|
||||
// TTL design (which re-walked every message on each refresh and produced
|
||||
// ~1.1 s sync freezes on 2,000+ message sessions because `updateEditorTopBorder`
|
||||
// is called on every agent event in event-controller). The new scheme
|
||||
// caches by message-object identity (a Symbol-keyed sidecar on each
|
||||
// message) plus a cheap content fingerprint, so in-place mutations of
|
||||
// an existing message (post-hoc error attachment, retry-truncated
|
||||
// branch rebuild, replaceMessages with the same length) are detected
|
||||
// and recomputed.
|
||||
// Context breakdown — incremental rolling cache. The status line refreshes
|
||||
// on every agent event, so the hot path must not re-tokenize the full
|
||||
// message list. Stable messages are accumulated once; normal streaming
|
||||
// refreshes only recompute the current tail message and newly appended
|
||||
// entries. History rewrites/compaction replace or shrink the message array
|
||||
// and rebuild this cache. Stable messages are treated as immutable after
|
||||
// promotion, matching the normal append-only session flow.
|
||||
// Cached non-message total (system prompt + tools + skills). Invalidated
|
||||
// when the inputs-identity fingerprint changes (model swap, skill toggle,
|
||||
// tool registration).
|
||||
#nonMessageTokensCache: number | undefined;
|
||||
#nonMessageInputsKey: string | undefined;
|
||||
#messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
|
||||
|
||||
constructor(private readonly session: AgentSession) {
|
||||
this.#settings = {
|
||||
@@ -508,24 +519,79 @@ export class StatusLineComponent implements Component {
|
||||
this.#nonMessageInputsKey = inputsKey;
|
||||
}
|
||||
|
||||
// 2) Message tokens — incremental. The sidecar cache lives on the
|
||||
// message object itself (Symbol-keyed), keyed by identity and
|
||||
// validated by a cheap content fingerprint. Mutations that
|
||||
// replace messages (replaceMessages, branch rebuild, compaction)
|
||||
// yield fresh objects → cache miss → recompute. In-place
|
||||
// mutations on the same object are caught by fingerprint
|
||||
// mismatch. The LAST message is always recomputed because it
|
||||
// may still be growing during streaming.
|
||||
let messagesTokens = 0;
|
||||
const lastIdx = messages.length - 1;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
messagesTokens += i === lastIdx ? estimateTokens(messages[i]) : tokensForMessage(messages[i]);
|
||||
}
|
||||
// 2) Message tokens — incremental rolling total. The sidecar cache lives
|
||||
// on each stable message object (all but the current tail). Normal
|
||||
// streaming turns only recompute the last message and newly appended
|
||||
// entries. Full rebuild only when the message array is replaced,
|
||||
// shrinks, or the recently-promoted stable tail mutates in place.
|
||||
const messagesTokens = this.#getCachedMessageTokens(messages);
|
||||
|
||||
const usedTokens = this.#nonMessageTokensCache + messagesTokens;
|
||||
return { usedTokens, contextWindow };
|
||||
}
|
||||
|
||||
#getCachedMessageTokens(messages: readonly AgentMessage[]): number {
|
||||
const cache = this.#messageTokenTotalsCache;
|
||||
if (!cache || cache.messagesRef !== messages || messages.length <= cache.stableCount) {
|
||||
return this.#rebuildMessageTokenTotals(messages);
|
||||
}
|
||||
|
||||
let stableTokens = cache.stableTokens;
|
||||
let stableCount = cache.stableCount;
|
||||
const stableLimit = Math.max(0, messages.length - 1);
|
||||
|
||||
if (
|
||||
cache.lastStableMessage &&
|
||||
stableCount > 0 &&
|
||||
messages[stableCount - 1] === cache.lastStableMessage &&
|
||||
cache.lastStableFingerprint !== undefined &&
|
||||
cache.lastStableFingerprint !== messageFingerprint(cache.lastStableMessage)
|
||||
) {
|
||||
return this.#rebuildMessageTokenTotals(messages);
|
||||
}
|
||||
|
||||
while (stableCount < stableLimit) {
|
||||
const promoted = messages[stableCount]!;
|
||||
stableTokens += tokensForMessage(promoted);
|
||||
stableCount++;
|
||||
}
|
||||
|
||||
const lastStableMessage = stableCount > 0 ? messages[stableCount - 1] : undefined;
|
||||
const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
|
||||
this.#messageTokenTotalsCache = {
|
||||
messagesRef: messages,
|
||||
stableCount,
|
||||
stableTokens,
|
||||
lastStableMessage,
|
||||
lastStableFingerprint,
|
||||
};
|
||||
return stableTokens + lastTokens;
|
||||
}
|
||||
|
||||
#rebuildMessageTokenTotals(messages: readonly AgentMessage[]): number {
|
||||
let stableTokens = 0;
|
||||
const stableLimit = Math.max(0, messages.length - 1);
|
||||
for (let i = 0; i < stableLimit; i++) {
|
||||
stableTokens += tokensForMessage(messages[i]!);
|
||||
}
|
||||
|
||||
const lastStableMessage = stableLimit > 0 ? messages[stableLimit - 1] : undefined;
|
||||
const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
|
||||
|
||||
this.#messageTokenTotalsCache = {
|
||||
messagesRef: messages,
|
||||
stableCount: stableLimit,
|
||||
stableTokens,
|
||||
lastStableMessage,
|
||||
lastStableFingerprint,
|
||||
};
|
||||
return stableTokens + lastTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an identity fingerprint for the non-message inputs (system prompt,
|
||||
* tools, skills). When this changes, the non-message token cache must be
|
||||
@@ -540,7 +606,11 @@ export class StatusLineComponent implements Component {
|
||||
return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
|
||||
}
|
||||
|
||||
#buildSegmentContext(width: number, segmentOptions: StatusLineSettings["segmentOptions"]): SegmentContext {
|
||||
#buildSegmentContext(
|
||||
width: number,
|
||||
segmentOptions: StatusLineSettings["segmentOptions"],
|
||||
includeContext: boolean,
|
||||
): SegmentContext {
|
||||
const state = this.session.state;
|
||||
|
||||
// Trigger background fetch (5-min TTL); render uses cached value
|
||||
@@ -560,10 +630,13 @@ export class StatusLineComponent implements Component {
|
||||
tokensPerSecond: this.#getTokensPerSecond(),
|
||||
};
|
||||
|
||||
// Context usage — aligned with /context command so both surfaces report the same value
|
||||
const breakdown = this.getCachedContextBreakdown();
|
||||
const contextTokens = breakdown.usedTokens;
|
||||
const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
|
||||
let contextTokens = 0;
|
||||
let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
|
||||
if (includeContext) {
|
||||
const breakdown = this.getCachedContextBreakdown();
|
||||
contextTokens = breakdown.usedTokens;
|
||||
contextWindow = breakdown.contextWindow || contextWindow;
|
||||
}
|
||||
const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
||||
|
||||
return {
|
||||
@@ -631,7 +704,9 @@ export class StatusLineComponent implements Component {
|
||||
|
||||
#buildStatusLine(width: number): string {
|
||||
const effectiveSettings = this.#resolveSettings();
|
||||
const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions);
|
||||
const includeContext =
|
||||
hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments);
|
||||
const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions, includeContext);
|
||||
const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
|
||||
|
||||
const bgAnsi = theme.getBgAnsi("statusLineBg");
|
||||
|
||||
Reference in New Issue
Block a user