mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): preserve raw context usage estimates (#16360)
This commit is contained in:
1 parent
0cc37bf444
commit
2be58e52ee
13 files changed
+268
-332
No files matched your search
@@ -12,7 +12,7 @@ Every request a session sends carries the same prefix before any of your convers
|
||||
|
||||
`/context` prints the breakdown by category; `detail` adds per-item rows — each built-in tool, each MCP tool, each context file, each listed skill — so you can see which single entry is the expensive one. Read it on the **first turn** of a session, where the conversation is still empty and everything you see is prefix.
|
||||
|
||||
The categories are what `/context` reports plus two bookkeeping rows: `startupContext` (the environment block sent as the first user turn) and an explicit residual for whatever the categories do not attribute, so the parts always add up to the total.
|
||||
Categories and per-item details are independent local estimates, including `messages`, which is measured from conversation content. `startupContext` covers the environment block sent as the first user turn. These estimates are not scaled or capped to match the last request's input total, so their sum can be higher or lower than that total. The total, available capacity and compaction tier retain their own usage source.
|
||||
|
||||
## Use idle cost, not percent of window
|
||||
|
||||
|
||||
@@ -180,6 +180,13 @@ beforeAll(async () => {
|
||||
const hasToolResult =
|
||||
messages.includes('"role":"tool"') || messages.includes('"tool_call_id"');
|
||||
|
||||
if (messages.includes('context-estimates-e2e')) {
|
||||
return {
|
||||
content: 'ok',
|
||||
usage: { prompt_tokens: 7, completion_tokens: 1, total_tokens: 8 },
|
||||
};
|
||||
}
|
||||
|
||||
const guardMarker = messages.match(/todo-guard-e2e-\d+/g)?.at(-1);
|
||||
if (guardMarker) {
|
||||
const guardTodoId = `${guardMarker}-item`;
|
||||
@@ -462,6 +469,46 @@ async function turnStatus(
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describePOSIX('qwen serve — context usage estimates', () => {
|
||||
it('preserves message and overhead estimates above provider usage', async () => {
|
||||
const session = await client.createOrAttachSession({
|
||||
workspaceCwd: workspaceDir,
|
||||
sessionScope: 'thread',
|
||||
});
|
||||
try {
|
||||
const accepted = asAccepted(
|
||||
await client.promptNonBlocking(session.sessionId, {
|
||||
prompt: [
|
||||
{ type: 'text', text: 'context-estimates-e2e'.padEnd(772, 'x') },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(accepted).toBeDefined();
|
||||
if (!accepted) return;
|
||||
await expect
|
||||
.poll(() => turnStatus(session.sessionId, accepted.promptId), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toMatchObject({ state: 'completed', resultText: 'ok' });
|
||||
|
||||
const status = await client.sessionContextUsage(session.sessionId, {
|
||||
detail: true,
|
||||
});
|
||||
expect(status.usage.totalTokens).toBe(7);
|
||||
expect(status.usage.isEstimated).toBe(false);
|
||||
expect(status.usage.breakdown.messages).toBeGreaterThanOrEqual(193);
|
||||
expect(status.usage.breakdown.systemPrompt).toBeGreaterThan(7);
|
||||
expect(status.usage.builtinTools.some((tool) => tool.tokens > 7)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(status.usage.breakdown).not.toHaveProperty('unattributed');
|
||||
expect(status.formattedText).not.toContain('Unattributed');
|
||||
} finally {
|
||||
await client.closeSession(session.sessionId).catch(() => undefined);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describePOSIX('qwen serve — pollable turn results', () => {
|
||||
it('returns only the final parent answer after a tool boundary', async () => {
|
||||
const session = await client.createOrAttachSession({
|
||||
|
||||
@@ -692,6 +692,7 @@ export interface ServeSessionContextStatus {
|
||||
};
|
||||
}
|
||||
|
||||
/** Local category estimates are independent of the reported totalTokens. */
|
||||
export interface ServeContextCategoryBreakdown {
|
||||
systemPrompt: number;
|
||||
builtinTools: number;
|
||||
@@ -703,13 +704,14 @@ export interface ServeContextCategoryBreakdown {
|
||||
/** Startup prelude outside the skill listing. Absent from older servers. */
|
||||
startupContext?: number;
|
||||
/**
|
||||
* Conversation tokens after the startup prelude. When `totalTokens` is 0
|
||||
* Independent conversation estimate after the startup prelude, never derived
|
||||
* from or capped by totalTokens. When `totalTokens` is 0
|
||||
* (no provider count yet: after a model switch, `/restore` or a resume) this
|
||||
* is a local estimate of the history rather than 0, so the rows include the
|
||||
* conversation. Older servers report 0 there.
|
||||
*/
|
||||
messages: number;
|
||||
/** Provider total not accounted for by any category. Absent from older servers. */
|
||||
/** @deprecated Legacy residual; accepted from old servers but no longer emitted or displayed. */
|
||||
unattributed?: number;
|
||||
/** Cached prefix tokens; an annotation that overlaps categories. Absent from older servers. */
|
||||
cachedTokens?: number;
|
||||
|
||||
@@ -556,16 +556,17 @@ describe('collectContextData (contextCommand)', () => {
|
||||
expect(data.memoryFiles.map((file) => file.path)).toEqual([
|
||||
t('auto memory'),
|
||||
]);
|
||||
if (total !== 1) {
|
||||
expect(data.breakdown.instructionFiles).toBe(
|
||||
estimateContextTextTokens(instructions + '\n'),
|
||||
);
|
||||
expect(data.breakdown.memoryFiles).toBe(
|
||||
estimateContextTextTokens(policy),
|
||||
);
|
||||
}
|
||||
expect(data.breakdown.instructionFiles).toBe(
|
||||
estimateContextTextTokens(instructions + '\n'),
|
||||
);
|
||||
expect(data.breakdown.memoryFiles).toBe(
|
||||
estimateContextTextTokens(policy),
|
||||
);
|
||||
expect(data.breakdown.messages).toBe(0);
|
||||
if (total) {
|
||||
expect(sumRows(data.breakdown)).toBe(total);
|
||||
expect(data.breakdown.freeSpace).toBe(
|
||||
data.contextWindowSize - total - data.breakdown.autocompactBuffer,
|
||||
);
|
||||
} else {
|
||||
expect(data.breakdown.freeSpace).toBe(
|
||||
data.contextWindowSize -
|
||||
@@ -590,7 +591,7 @@ describe('collectContextData (contextCommand)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('scales nonzero instruction and auto-memory rows and their details together', async () => {
|
||||
it('preserves instruction and auto-memory estimates above the provider total', async () => {
|
||||
const sources = {
|
||||
getUserMemory: () =>
|
||||
'--- Context from: QWEN.md ---\n' +
|
||||
@@ -606,23 +607,16 @@ describe('collectContextData (contextCommand)', () => {
|
||||
);
|
||||
const raw = await collect(0);
|
||||
const total = 1000;
|
||||
const scale = total / sumRows(raw.breakdown);
|
||||
expect(scale).toBeLessThan(1);
|
||||
const scaled = await collect(total);
|
||||
expect(sumRows(raw.breakdown)).toBeGreaterThan(total);
|
||||
const data = await collect(total);
|
||||
|
||||
for (const category of ['instructionFiles', 'memoryFiles'] as const) {
|
||||
const expected = Math.floor(raw.breakdown[category]! * scale);
|
||||
expect(expected).toBeGreaterThan(0);
|
||||
expect(scaled.breakdown[category]).toBe(expected);
|
||||
expect(scaled[category]).toEqual(
|
||||
raw[category]!.map((entry) => ({
|
||||
...entry,
|
||||
tokens: Math.round(entry.tokens * scale),
|
||||
})),
|
||||
);
|
||||
expect(raw.breakdown[category]).toBeGreaterThan(0);
|
||||
expect(data.breakdown[category]).toBe(raw.breakdown[category]);
|
||||
expect(data[category]).toEqual(raw[category]);
|
||||
}
|
||||
expect(scaled.breakdown.unattributed).toBe(0);
|
||||
expect(sumRows(scaled.breakdown)).toBe(total);
|
||||
expect(data.breakdown).not.toHaveProperty('unattributed');
|
||||
expect(data.breakdown.messages).toBe(0);
|
||||
});
|
||||
|
||||
function sumRows(breakdown: ContextCategoryBreakdown): number {
|
||||
@@ -634,8 +628,7 @@ describe('collectContextData (contextCommand)', () => {
|
||||
breakdown.memoryFiles +
|
||||
breakdown.skills +
|
||||
(breakdown.startupContext ?? 0) +
|
||||
breakdown.messages +
|
||||
(breakdown.unattributed ?? 0)
|
||||
breakdown.messages
|
||||
);
|
||||
}
|
||||
|
||||
@@ -766,7 +759,9 @@ describe('collectContextData (contextCommand)', () => {
|
||||
);
|
||||
expect(data.totalTokens).toBe(total);
|
||||
if (total) {
|
||||
expect(sumRows(data.breakdown)).toBe(total);
|
||||
expect(data.breakdown.freeSpace).toBe(
|
||||
200_000 - total - data.breakdown.autocompactBuffer,
|
||||
);
|
||||
} else {
|
||||
expect(data.breakdown.freeSpace).toBe(
|
||||
200_000 -
|
||||
@@ -806,7 +801,7 @@ describe('collectContextData (contextCommand)', () => {
|
||||
expect(data.breakdown.messages).toBe(300);
|
||||
});
|
||||
|
||||
it('closes the rows against the provider total, reporting the gap as unattributed', async () => {
|
||||
it('allows estimates below the provider total without generating a residual', async () => {
|
||||
const data = await collectContextData(
|
||||
makeChatConfig({
|
||||
total: 100_000,
|
||||
@@ -815,53 +810,108 @@ describe('collectContextData (contextCommand)', () => {
|
||||
false,
|
||||
);
|
||||
|
||||
expect(data.breakdown.unattributed).toBeGreaterThan(0);
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
expect(sumRows(data.breakdown)).toBeLessThan(data.totalTokens);
|
||||
expect(data.breakdown.messages).toBe(300);
|
||||
expect(data.breakdown).not.toHaveProperty('unattributed');
|
||||
});
|
||||
|
||||
it('scales every row down when the estimates exceed the provider total', async () => {
|
||||
const config = makeChatConfig({
|
||||
total: 5_000,
|
||||
cached: 4_000,
|
||||
history: [
|
||||
it.each([
|
||||
{ total: 0, cached: 0, estimated: false },
|
||||
{ total: 7, cached: 0, estimated: false },
|
||||
{ total: 100_000, cached: 90_000, estimated: false },
|
||||
{ total: 500, cached: 0, estimated: true },
|
||||
])(
|
||||
'keeps category and detail estimates independent of usage: %j',
|
||||
async (usage) => {
|
||||
const mcpSchema = {
|
||||
name: 'mcp__server__report',
|
||||
description: 'Build a report. '.repeat(40),
|
||||
};
|
||||
const mcpTool = Object.defineProperties(
|
||||
Object.create(DiscoveredMCPTool.prototype),
|
||||
{
|
||||
name: { value: mcpSchema.name },
|
||||
serverName: { value: 'server' },
|
||||
serverToolName: { value: 'report' },
|
||||
schema: { value: mcpSchema },
|
||||
},
|
||||
);
|
||||
const builtinTool = {
|
||||
name: 'read_file',
|
||||
schema: { name: 'read_file', description: 'Read a file.' },
|
||||
};
|
||||
const history = [
|
||||
prelude,
|
||||
{ role: 'user', parts: [{ text: 'c'.repeat(40_000) }] },
|
||||
],
|
||||
});
|
||||
vi.mocked(config.getSystemPrompt).mockReturnValue('s'.repeat(40_000));
|
||||
const data = await collectContextData(config, true);
|
||||
{ role: 'user', parts: [{ text: 'c'.repeat(772) }] },
|
||||
skillResponse(trackedBody),
|
||||
];
|
||||
const config = makeChatConfig({
|
||||
...usage,
|
||||
history,
|
||||
tools: [skillToolDouble, mcpTool, builtinTool],
|
||||
skillList: trackedSkillList,
|
||||
});
|
||||
vi.mocked(config.getSystemPrompt).mockReturnValue('s'.repeat(40_000));
|
||||
vi.mocked(config.getUserMemory).mockReturnValue(
|
||||
'--- Context from: QWEN.md ---\nProject instructions.\n--- End of Context from: QWEN.md ---',
|
||||
);
|
||||
vi.mocked(config.getAutoMemoryPrompt).mockReturnValue('Memory policy.');
|
||||
const data = await collectContextData(config, true);
|
||||
const chat = config.getLlmClient().getChat();
|
||||
vi.mocked(chat.getLastPromptTokenCount).mockReturnValue(0);
|
||||
const raw = await collectContextData(config, true);
|
||||
|
||||
expect(data.breakdown.unattributed).toBe(0);
|
||||
expect(sumRows(data.breakdown)).toBe(5_000);
|
||||
// The `scale < 1` branch forces `unattributed` to 0, so the row must be
|
||||
// suppressed in the text renderer rather than printed as `0 tokens`.
|
||||
// `totalTokens` is nonzero, so this witnesses the `> 0` guard and not
|
||||
// the `hasTokenCount` gate.
|
||||
expect(formatContextUsageText(data)).not.toContain('Unattributed');
|
||||
// The cached count is an annotation, never a term of the row sum, and
|
||||
// never subtracted when deriving `messages` (this is the only branch that
|
||||
// still derives it by subtraction).
|
||||
expect(data.breakdown.cachedTokens).toBe(4_000);
|
||||
// Each row pinned against its own unscaled value: dropping `* scale` from
|
||||
// a row this PR added leaves the two identities above intact, because
|
||||
// `messages` absorbs the difference.
|
||||
expect(data.breakdown.skills).toBeLessThan(
|
||||
estimateContextTextTokens(listingReminder),
|
||||
);
|
||||
expect(data.breakdown.startupContext).toBeLessThan(
|
||||
estimateContextTextTokens(environmentReminder),
|
||||
);
|
||||
expect(data.skills[0]!.tokens).toBeLessThan(
|
||||
estimateContextTextTokens(skillEntry),
|
||||
);
|
||||
});
|
||||
for (const category of [
|
||||
'systemPrompt',
|
||||
'builtinTools',
|
||||
'mcpTools',
|
||||
'instructionFiles',
|
||||
'memoryFiles',
|
||||
'skills',
|
||||
'startupContext',
|
||||
'messages',
|
||||
] as const) {
|
||||
expect(data.breakdown[category]).toBe(raw.breakdown[category]);
|
||||
}
|
||||
for (const details of [
|
||||
'builtinTools',
|
||||
'mcpTools',
|
||||
'instructionFiles',
|
||||
'memoryFiles',
|
||||
'skills',
|
||||
] as const) {
|
||||
expect(data[details]).toEqual(raw[details]);
|
||||
}
|
||||
expect(data.totalTokens).toBe(usage.total);
|
||||
expect(data.isEstimated).toBe(!usage.total || usage.estimated);
|
||||
expect(data.breakdown.messages).toBe(193);
|
||||
expect(data.breakdown).not.toHaveProperty('unattributed');
|
||||
expect(formatContextUsageText(data)).not.toContain('Unattributed');
|
||||
expect(data.breakdown.cachedTokens).toBe(usage.cached);
|
||||
expect(data.breakdown.freeSpace).toBe(
|
||||
Math.max(
|
||||
0,
|
||||
data.contextWindowSize -
|
||||
(usage.total || sumRows(raw.breakdown)) -
|
||||
data.breakdown.autocompactBuffer,
|
||||
),
|
||||
);
|
||||
expect(data.breakdown.startupContext).toBe(
|
||||
estimateContextTextTokens(environmentReminder),
|
||||
);
|
||||
expect(data.skills[0]!.tokens).toBe(
|
||||
estimateContextTextTokens(skillEntry),
|
||||
);
|
||||
expect(data.skills[0]!.bodyTokens).toBe(
|
||||
estimateContextTextTokens(trackedBody),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('charges nested tool media at the flat image budget, not as base64 text', async () => {
|
||||
// Tool media rides on `functionResponse.parts[k].inlineData.data` — the
|
||||
// carrier core's tools actually use. Serializing the whole part bills
|
||||
// 400,000 ASCII chars (~100,000 tokens) against a 40,000 provider total,
|
||||
// which collapses `scale` and deflates every row, including this
|
||||
// ASCII-only one.
|
||||
// 400,000 ASCII chars (~100,000 tokens) as conversation text.
|
||||
const withScreenshot = [
|
||||
prelude,
|
||||
{
|
||||
@@ -895,15 +945,21 @@ describe('collectContextData (contextCommand)', () => {
|
||||
false,
|
||||
);
|
||||
|
||||
// The screenshot must never reach `scale`, so no overhead row moves.
|
||||
// Media belongs to messages, so no overhead row moves.
|
||||
expect(control.breakdown.systemPrompt).toBeGreaterThan(1_000);
|
||||
expect(billed.breakdown.systemPrompt).toBe(
|
||||
control.breakdown.systemPrompt,
|
||||
);
|
||||
expect(billed.breakdown.skills).toBe(control.breakdown.skills);
|
||||
// Charged at the flat per-image budget rather than at chars/4.
|
||||
expect(billed.breakdown.messages).toBeLessThan(5_000);
|
||||
expect(sumRows(billed.breakdown)).toBe(40_000);
|
||||
expect(billed.breakdown.messages).toBe(
|
||||
estimateContextTextTokens(
|
||||
JSON.stringify({
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
response: { output: 'ok' },
|
||||
}),
|
||||
) + resolveSlimmingConfig(undefined).imageTokenEstimate,
|
||||
);
|
||||
});
|
||||
|
||||
it('charges a top-level media part at the flat image budget', async () => {
|
||||
@@ -938,7 +994,6 @@ describe('collectContextData (contextCommand)', () => {
|
||||
// An exact delta, not an upper bound: the text part is 400 ASCII chars
|
||||
// (100 tokens) and the image is billed at the flat per-image budget.
|
||||
expect(data.breakdown.messages).toBe(100 + imageTokens);
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
});
|
||||
|
||||
it('does not deflate rows when the total is itself a char/4 estimate', async () => {
|
||||
@@ -967,18 +1022,17 @@ describe('collectContextData (contextCommand)', () => {
|
||||
unscaled.breakdown.startupContext,
|
||||
);
|
||||
expect(data.breakdown.skills).toBe(unscaled.breakdown.skills);
|
||||
// The rows still close against the estimated total.
|
||||
expect(sumRows(data.breakdown)).toBe(estimatedTotal);
|
||||
expect(data.breakdown.messages).toBe(estimateContextTextTokens(cjk));
|
||||
expect(data.breakdown.messages).toBe(unscaled.breakdown.messages);
|
||||
});
|
||||
|
||||
it('does not deflate overhead rows against a provider total when only the conversation overshoots', async () => {
|
||||
it('preserves conversation estimates when only the conversation overshoots', async () => {
|
||||
// Two ordinary triggers push `rawContent` past a provider-reported total
|
||||
// without any overhead row being wrong: the CJK-aware estimator runs
|
||||
// several times higher than the provider's count on zh-heavy text, and
|
||||
// `totalTokens` is the last request's prompt count, which excludes the
|
||||
// answer added since. Scaling the rows by total/rawContent deflated every
|
||||
// exactly-measured row; only an overhead-side overshoot may scale them.
|
||||
// A conversation-side overshoot is absorbed by the `messages` cap.
|
||||
// row. Neither case should cap the independently measured conversation.
|
||||
const cjkHistory: Content[] = [
|
||||
prelude,
|
||||
{ role: 'user', parts: [{ text: '中'.repeat(100_000) }] },
|
||||
@@ -996,7 +1050,8 @@ describe('collectContextData (contextCommand)', () => {
|
||||
expect(cjkData.breakdown.systemPrompt).toBe(
|
||||
cjkUnscaled.breakdown.systemPrompt,
|
||||
);
|
||||
expect(sumRows(cjkData.breakdown)).toBe(57_000);
|
||||
expect(cjkData.breakdown.messages).toBe(cjkUnscaled.breakdown.messages);
|
||||
expect(cjkData.breakdown.messages).toBe(150_000);
|
||||
|
||||
// Pure ASCII with an exact estimator: the stale-by-one-response gap
|
||||
// alone overshoots the provider total on every completed turn.
|
||||
@@ -1017,16 +1072,16 @@ describe('collectContextData (contextCommand)', () => {
|
||||
expect(asciiData.breakdown.systemPrompt).toBe(
|
||||
asciiUnscaled.breakdown.systemPrompt,
|
||||
);
|
||||
expect(sumRows(asciiData.breakdown)).toBe(15_000);
|
||||
expect(asciiData.breakdown.messages).toBe(
|
||||
asciiUnscaled.breakdown.messages,
|
||||
);
|
||||
expect(asciiData.breakdown.messages).toBe(10_100);
|
||||
});
|
||||
|
||||
it('closes the rows against an estimated total that sits below the measured overhead', async () => {
|
||||
it('preserves estimates above a total seeded by compression or resume', async () => {
|
||||
// The total core stamps after a compression or resume covers the
|
||||
// compressed history alone, so a session carrying heavy declarations can
|
||||
// stamp a total smaller than the measured overhead. The rows must still
|
||||
// close against `Used` — scaling against `rawOverhead` is unconditional,
|
||||
// so this state scales the overhead rows down to the stamped total
|
||||
// instead of letting the category rows sum above it.
|
||||
// stamp a total smaller than the measured overhead.
|
||||
const data = await collectContextData(
|
||||
makeChatConfig({
|
||||
total: 500,
|
||||
@@ -1037,24 +1092,13 @@ describe('collectContextData (contextCommand)', () => {
|
||||
);
|
||||
|
||||
expect(data.totalTokens).toBe(500);
|
||||
expect(sumRows(data.breakdown)).toBeLessThanOrEqual(data.totalTokens);
|
||||
// The rendered category rows must not exceed the printed `Used` either.
|
||||
expect(data.isEstimated).toBe(true);
|
||||
expect(data.breakdown.systemPrompt).toBeGreaterThan(data.totalTokens);
|
||||
expect(data.breakdown.messages).toBe(300);
|
||||
const text = formatContextUsageText(data);
|
||||
const usedMatch = text.match(/^ {2}Used +([\d.]+)(k?) tokens \(/m);
|
||||
expect(usedMatch).not.toBeNull();
|
||||
const used = Number(usedMatch![1]) * (usedMatch![2] === 'k' ? 1000 : 1);
|
||||
expect(used).toBe(500);
|
||||
const rowPattern = /^ {2}([A-Za-z][^\n]*?) +([\d.]+)(k?) tokens \(/gm;
|
||||
let rowCount = 0;
|
||||
for (const match of text.matchAll(rowPattern)) {
|
||||
const label = match[1]!;
|
||||
if (label === 'Used' || label === 'Free') continue;
|
||||
const value = Number(match[2]) * (match[3] === 'k' ? 1000 : 1);
|
||||
expect(value).toBeLessThanOrEqual(used);
|
||||
rowCount += 1;
|
||||
}
|
||||
// The assertion above is vacuous unless the category rows were found.
|
||||
expect(rowCount).toBeGreaterThanOrEqual(6);
|
||||
expect(text).toMatch(/^ {2}Used +500 tokens \(/m);
|
||||
expect(text).toMatch(/^ {2}Messages +300 tokens \(/m);
|
||||
expect(text).toContain('Token usage is estimated');
|
||||
});
|
||||
|
||||
it('never derives messages from the cached count', async () => {
|
||||
@@ -1070,7 +1114,6 @@ describe('collectContextData (contextCommand)', () => {
|
||||
// The old `total − cached` derivation would print 10_000 here.
|
||||
expect(data.breakdown.messages).toBe(300);
|
||||
expect(data.breakdown.cachedTokens).toBe(90_000);
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
});
|
||||
|
||||
it('bills a tracked skill body under skills and keeps it out of messages', async () => {
|
||||
@@ -1101,7 +1144,6 @@ describe('collectContextData (contextCommand)', () => {
|
||||
estimateContextTextTokens(listingReminder) +
|
||||
estimateContextTextTokens(trackedBody),
|
||||
);
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
const text = formatContextUsageText(data);
|
||||
expect(text).not.toContain('report-builder (body loaded)');
|
||||
expect(text).not.toContain('report-builder (active)');
|
||||
@@ -1201,7 +1243,6 @@ describe('collectContextData (contextCommand)', () => {
|
||||
loaded: true,
|
||||
bodyTokens: estimateContextTextTokens(windowsBody),
|
||||
});
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
});
|
||||
|
||||
it('bills a skill response whose body was never tracked as messages', async () => {
|
||||
@@ -1241,7 +1282,6 @@ describe('collectContextData (contextCommand)', () => {
|
||||
|
||||
expect(untracked.breakdown.messages).toBe(expected);
|
||||
expect(trackingOtherBody.breakdown.messages).toBe(expected);
|
||||
expect(sumRows(untracked.breakdown)).toBe(100_000);
|
||||
});
|
||||
|
||||
it('gives a detail row to a listing entry listSkills does not return', async () => {
|
||||
@@ -1332,10 +1372,9 @@ describe('collectContextData (contextCommand)', () => {
|
||||
estimateContextTextTokens(delta),
|
||||
);
|
||||
expect(data.breakdown.messages).toBe(300);
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
});
|
||||
|
||||
it('prints the startup context, unattributed and cached rows in text output', async () => {
|
||||
it('prints startup context and cached rows while ignoring legacy residuals', async () => {
|
||||
const data = await collectContextData(
|
||||
makeChatConfig({
|
||||
total: 100_000,
|
||||
@@ -1344,10 +1383,11 @@ describe('collectContextData (contextCommand)', () => {
|
||||
}),
|
||||
false,
|
||||
);
|
||||
data.breakdown.unattributed = 900;
|
||||
const text = formatContextUsageText(data);
|
||||
|
||||
expect(text).toContain('Startup context');
|
||||
expect(text).toContain('Unattributed');
|
||||
expect(text).not.toContain('Unattributed');
|
||||
expect(text).toContain('Cached prefix');
|
||||
});
|
||||
|
||||
@@ -1395,7 +1435,6 @@ describe('collectContextData (contextCommand)', () => {
|
||||
estimateContextTextTokens(JSON.stringify(toolCall)) +
|
||||
estimateContextTextTokens(JSON.stringify(toolResponse)),
|
||||
);
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
});
|
||||
|
||||
it('bills generated control declarations without undeclared MCP schemas', async () => {
|
||||
@@ -1503,9 +1542,12 @@ describe('collectContextData (contextCommand)', () => {
|
||||
);
|
||||
expect(detailed.mcpTools).toEqual([]);
|
||||
expect(detailed.breakdown.mcpTools).toBe(0);
|
||||
if (options.total > 0) {
|
||||
expect(sumRows(detailed.breakdown)).toBe(options.total);
|
||||
}
|
||||
expect(detailed.breakdown.messages).toBe(300);
|
||||
const reference = options.declared.length ? unscaled : undeclared;
|
||||
expect(detailed.builtinTools).toEqual(reference.builtinTools);
|
||||
expect(detailed.breakdown.builtinTools).toBe(
|
||||
reference.breakdown.builtinTools,
|
||||
);
|
||||
}
|
||||
|
||||
const exposed = await collectContextData(
|
||||
@@ -1557,7 +1599,6 @@ describe('collectContextData (contextCommand)', () => {
|
||||
);
|
||||
expect(data.breakdown.messages).toBe(100);
|
||||
expect(data.builtinTools).toEqual([]);
|
||||
expect(sumRows(data.breakdown)).toBe(100_000);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -277,8 +277,7 @@ function measureStartupPrelude(prelude: Content[]): StartupPreludeCost {
|
||||
* qwen-code attaches media to `functionResponse.parts` (an extension to the
|
||||
* `@google/genai` schema; see `coreToolScheduler.createFunctionResponsePart`),
|
||||
* and `JSON.stringify`ing the whole part would bill that raw base64 as ASCII
|
||||
* text — a single ~100 KB screenshot alone is enough to push the estimate past
|
||||
* the provider total and deflate every category row through `scale`. Nested
|
||||
* text, greatly inflating the conversation estimate. Nested
|
||||
* media is charged at the flat per-image budget core uses for the same carrier
|
||||
* (`estimatePartChars` in `compactionInputSlimming`).
|
||||
*/
|
||||
@@ -646,30 +645,9 @@ export async function collectContextData(
|
||||
0,
|
||||
);
|
||||
|
||||
let totalTokens: number;
|
||||
let displaySystemPrompt: number;
|
||||
let displayBuiltinTools: number;
|
||||
let displayMcpTools: number;
|
||||
let displayInstructionFiles: number;
|
||||
let displayMemoryFiles: number;
|
||||
let displaySkills: number;
|
||||
let displayStartupContext: number;
|
||||
let messagesTokens: number;
|
||||
let unattributedTokens = 0;
|
||||
let freeSpace: number;
|
||||
let detailBuiltinTools: ContextToolDetail[];
|
||||
let detailMcpTools: ContextToolDetail[];
|
||||
let detailInstructionFiles: ContextMemoryDetail[];
|
||||
let detailMemoryFiles: ContextMemoryDetail[];
|
||||
let detailSkills: ContextSkillDetail[];
|
||||
const totalTokens = hasTokenCount ? apiTotalTokens : 0;
|
||||
|
||||
// `displayBuiltinTools` floors at 0, so when the billed Skill definition and
|
||||
// the MCP schemas together exceed the declared tool list, that excess would
|
||||
// push the estimate past the window, or come straight out of `messages`
|
||||
// against a provider total.
|
||||
// Charge it to `mcpTools`, and charge whatever the MCP schemas cannot absorb
|
||||
// to the Skill definition `skills` carries, so the three rows still account
|
||||
// for exactly `allToolsTokens` plus the listing and the loaded bodies.
|
||||
// Keep the tool-category allocation bounded by the declared tool-list estimate.
|
||||
const clampDeficit = Math.max(
|
||||
0,
|
||||
skillToolDefinitionTokens + mcpToolsTotalTokens - allToolsTokens,
|
||||
@@ -684,118 +662,13 @@ export async function collectContextData(
|
||||
// The MCP detail rows sit under the mcp row, so they carry its deficit too.
|
||||
const mcpDetailShare =
|
||||
mcpToolsTotalTokens > 0 ? clampedMcpTools / mcpToolsTotalTokens : 1;
|
||||
const scaleTokens = <T extends { tokens: number }>(
|
||||
items: T[],
|
||||
factor: number,
|
||||
): T[] =>
|
||||
factor < 1
|
||||
? items.map((item) => ({
|
||||
const detailMcpTools =
|
||||
mcpDetailShare < 1
|
||||
? mcpTools.map((item) => ({
|
||||
...item,
|
||||
tokens: Math.round(item.tokens * factor),
|
||||
tokens: Math.round(item.tokens * mcpDetailShare),
|
||||
}))
|
||||
: items;
|
||||
|
||||
if (!hasTokenCount) {
|
||||
totalTokens = 0;
|
||||
displaySystemPrompt = systemPromptTokens;
|
||||
displaySkills = clampedSkills;
|
||||
displayStartupContext = startupContextTokens;
|
||||
displayBuiltinTools = clampedBuiltinTools;
|
||||
displayMcpTools = clampedMcpTools;
|
||||
displayInstructionFiles = instructionFilesTokens;
|
||||
displayMemoryFiles = memoryFilesTokens;
|
||||
// Include the conversation: a `/model` switch, `/restore` or a resume
|
||||
// zeroes the provider count while leaving `this.history` intact, and such a
|
||||
// session must not report a 100K history as free window. The same estimate
|
||||
// drives the tier, so it is reported as `messages` rather than hidden.
|
||||
messagesTokens = conversationTokens;
|
||||
freeSpace = Math.max(0, contextWindowSize - rawContent - autocompactBuffer);
|
||||
detailBuiltinTools = builtinTools;
|
||||
detailMcpTools = scaleTokens(mcpTools, mcpDetailShare);
|
||||
detailInstructionFiles = instructionFiles;
|
||||
detailMemoryFiles = memoryFiles;
|
||||
detailSkills = skills;
|
||||
} else {
|
||||
totalTokens = apiTotalTokens;
|
||||
|
||||
// Categories partition the request by content (#12033). When the overhead
|
||||
// exceeds the provider total it is scaled down as a whole; when it falls
|
||||
// short, the gap is reported as `unattributed` rather than folded into
|
||||
// another category. The cached count is never subtracted: a cache hit spans
|
||||
// several categories, so it is only an annotation.
|
||||
//
|
||||
// Only the overhead is scaled, and only against itself. Conversation content
|
||||
// is deliberately kept out of the denominator: `conversationTokens` measures
|
||||
// a strictly larger content set than the last request's `promptTokenCount`
|
||||
// (which excludes the answer `history` already carries) with a CJK-aware
|
||||
// estimator, so including it deflates every exactly-measured row. The total
|
||||
// is not always provider-reported either — `compressFast()` and resume
|
||||
// seeding stamp a char/4 estimate of the compressed history alone, which can
|
||||
// sit below the overhead — so the clamp must stay armed there too, or the
|
||||
// rows overshoot the total with no row able to report it. Conversation-side
|
||||
// overshoot is absorbed by the `messages` cap below.
|
||||
const scale = rawOverhead > totalTokens ? totalTokens / rawOverhead : 1;
|
||||
|
||||
// The clamped categories partition `rawOverhead` before scaling. Flooring
|
||||
// each share keeps their sum at or below `totalTokens`; independently
|
||||
// rounding them can overshoot the total by a token with no negative row
|
||||
// available to absorb the excess.
|
||||
displaySystemPrompt = Math.floor(systemPromptTokens * scale);
|
||||
displayBuiltinTools = Math.floor(clampedBuiltinTools * scale);
|
||||
displayMcpTools = Math.floor(clampedMcpTools * scale);
|
||||
displayInstructionFiles = Math.floor(instructionFilesTokens * scale);
|
||||
displayMemoryFiles = Math.floor(memoryFilesTokens * scale);
|
||||
displaySkills = Math.floor(clampedSkills * scale);
|
||||
displayStartupContext = Math.floor(startupContextTokens * scale);
|
||||
|
||||
const attributedOverhead =
|
||||
displaySystemPrompt +
|
||||
displayBuiltinTools +
|
||||
displayMcpTools +
|
||||
displayInstructionFiles +
|
||||
displayMemoryFiles +
|
||||
displaySkills +
|
||||
displayStartupContext;
|
||||
|
||||
if (scale < 1) {
|
||||
// Fully attributed; messages absorbs the per-row rounding so the rows
|
||||
// sum to the total exactly.
|
||||
messagesTokens = Math.max(0, totalTokens - attributedOverhead);
|
||||
} else {
|
||||
// Unscaled, the overhead already fits inside the total
|
||||
// (`rawOverhead <= totalTokens`), so `messages` is capped only by the
|
||||
// conversation estimate. A genuine shortfall still surfaces as
|
||||
// `unattributed`.
|
||||
messagesTokens = Math.min(
|
||||
conversationTokens,
|
||||
Math.max(0, totalTokens - attributedOverhead),
|
||||
);
|
||||
unattributedTokens = Math.max(
|
||||
0,
|
||||
totalTokens - attributedOverhead - messagesTokens,
|
||||
);
|
||||
}
|
||||
|
||||
freeSpace = Math.max(
|
||||
0,
|
||||
contextWindowSize - totalTokens - autocompactBuffer,
|
||||
);
|
||||
|
||||
detailBuiltinTools = scaleTokens(builtinTools, scale);
|
||||
detailMcpTools = scaleTokens(mcpTools, scale * mcpDetailShare);
|
||||
detailInstructionFiles = scaleTokens(instructionFiles, scale);
|
||||
detailMemoryFiles = scaleTokens(memoryFiles, scale);
|
||||
detailSkills =
|
||||
scale < 1
|
||||
? skills.map((item) => ({
|
||||
...item,
|
||||
tokens: Math.round(item.tokens * scale),
|
||||
bodyTokens: item.bodyTokens
|
||||
? Math.round(item.bodyTokens * scale)
|
||||
: undefined,
|
||||
}))
|
||||
: skills;
|
||||
}
|
||||
: mcpTools;
|
||||
|
||||
// Tier classification: prefer the API-reported total when available.
|
||||
// When no API call has happened yet (first /context, --continue resume,
|
||||
@@ -817,17 +690,20 @@ export async function collectContextData(
|
||||
// TODO: use estimatePromptTokens(history, undefined, 0, 0,
|
||||
// imageTokenEstimate) here for same-source-of-truth as the cheap gate.
|
||||
const tierTokens = hasTokenCount ? apiTotalTokens : rawContent;
|
||||
const freeSpace = Math.max(
|
||||
0,
|
||||
contextWindowSize - tierTokens - autocompactBuffer,
|
||||
);
|
||||
|
||||
const breakdown: ContextCategoryBreakdown = {
|
||||
systemPrompt: displaySystemPrompt,
|
||||
builtinTools: displayBuiltinTools,
|
||||
mcpTools: displayMcpTools,
|
||||
instructionFiles: displayInstructionFiles,
|
||||
memoryFiles: displayMemoryFiles,
|
||||
skills: displaySkills,
|
||||
startupContext: displayStartupContext,
|
||||
messages: messagesTokens,
|
||||
unattributed: unattributedTokens,
|
||||
systemPrompt: systemPromptTokens,
|
||||
builtinTools: clampedBuiltinTools,
|
||||
mcpTools: clampedMcpTools,
|
||||
instructionFiles: instructionFilesTokens,
|
||||
memoryFiles: memoryFilesTokens,
|
||||
skills: clampedSkills,
|
||||
startupContext: startupContextTokens,
|
||||
messages: conversationTokens,
|
||||
cachedTokens: hasTokenCount ? apiCachedTokens : 0,
|
||||
freeSpace,
|
||||
autocompactBuffer,
|
||||
@@ -841,12 +717,12 @@ export async function collectContextData(
|
||||
totalTokens,
|
||||
contextWindowSize,
|
||||
breakdown,
|
||||
builtinTools: showDetails ? detailBuiltinTools : [],
|
||||
builtinTools: showDetails ? builtinTools : [],
|
||||
mcpTools: showDetails ? detailMcpTools : [],
|
||||
instructionFiles: showDetails ? detailInstructionFiles : [],
|
||||
memoryFiles: showDetails ? detailMemoryFiles : [],
|
||||
instructionFiles: showDetails ? instructionFiles : [],
|
||||
memoryFiles: showDetails ? memoryFiles : [],
|
||||
skills: showDetails
|
||||
? detailSkills.filter(
|
||||
? skills.filter(
|
||||
(skill) =>
|
||||
skill.loaded || enabledSkillNames.has(skill.name.toLowerCase()),
|
||||
)
|
||||
@@ -1014,15 +890,6 @@ export function formatContextUsageText(data: HistoryItemContextUsage): string {
|
||||
lines.push(
|
||||
fmtCategoryRow('Messages', breakdown.messages, contextWindowSize),
|
||||
);
|
||||
if ((breakdown.unattributed ?? 0) > 0) {
|
||||
lines.push(
|
||||
fmtCategoryRow(
|
||||
'Unattributed',
|
||||
breakdown.unattributed!,
|
||||
contextWindowSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (showDetails) {
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('ContextUsage — CompactionThresholds section (review #4168 R1.6)', ()
|
||||
expect(frame(0)).toContain('Estimated pre-conversation overhead');
|
||||
});
|
||||
|
||||
it('renders the startup context, unattributed and cached prefix rows only when nonzero (#12033)', () => {
|
||||
it('renders startup context and cached prefix while ignoring legacy residuals', () => {
|
||||
const present = render(
|
||||
<ContextUsage
|
||||
modelName="qwen3-coder"
|
||||
@@ -205,18 +205,16 @@ describe('ContextUsage — CompactionThresholds section (review #4168 R1.6)', ()
|
||||
);
|
||||
const frame = present.lastFrame() ?? '';
|
||||
expect(frame).toContain('Startup context');
|
||||
expect(frame).toContain('Unattributed');
|
||||
expect(frame).not.toContain('Unattributed');
|
||||
expect(frame).toContain('Cached prefix');
|
||||
present.unmount();
|
||||
|
||||
// Zero-suppression half. `makeBreakdown`'s defaults omit all three fields,
|
||||
// Zero-suppression half. `makeBreakdown`'s defaults omit the optional fields,
|
||||
// which is the shape of a `context_usage` item persisted by an older build
|
||||
// and replayed after `/restore`; `startupContext` is also 0 by
|
||||
// construction on a pre-first-send `/context`. Without the `> 0` guards
|
||||
// these rows render as `undefined tokens (NaN%)`.
|
||||
// `totalTokens` must stay > 0: `Cached prefix` and `Unattributed` are
|
||||
// `hasTokenCount`-gated, so at 0 neither can render with or without its
|
||||
// guard, and only the `Startup context` row would be witnessed.
|
||||
// Keep totalTokens positive so the cached-prefix guard is exercised.
|
||||
const absent = render(
|
||||
<ContextUsage
|
||||
modelName="qwen3-coder"
|
||||
|
||||
@@ -510,15 +510,6 @@ export const ContextUsage: React.FC<ContextUsageProps> = ({
|
||||
symbolColor={theme.text.accent}
|
||||
/>
|
||||
)}
|
||||
{hasTokenCount && (breakdown.unattributed ?? 0) > 0 && (
|
||||
<CategoryRow
|
||||
symbol={FILLED}
|
||||
label={t('Unattributed')}
|
||||
tokens={breakdown.unattributed!}
|
||||
contextWindowSize={contextWindowSize}
|
||||
symbolColor={theme.text.secondary}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Three-tier compaction thresholds — visible even when isEstimated so
|
||||
the user can see the auto-compact landscape before any API call. */}
|
||||
|
||||
@@ -194,14 +194,8 @@ describe('projectSummary', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Pins the producer's arithmetic on a fixture, so editing one row cannot leave
|
||||
* behind a payload no producer can emit (#12235 R2-7). `ui/types.ts` documents
|
||||
* both identities: the category rows — including the `unattributed` residual,
|
||||
* excluding `cachedTokens`, which is an annotation spanning several categories
|
||||
* — account for the request, and `freeSpace` is what is left of the window
|
||||
* after that content and the autocompact buffer. With no provider total the
|
||||
* estimated rows are themselves the content total, so the free-space identity
|
||||
* keys on the rows instead of on `totalTokens`.
|
||||
* Capacity uses the reported total when available and local category estimates
|
||||
* otherwise. Legacy residuals and cached-prefix annotations are not categories.
|
||||
*/
|
||||
function expectCoherentBreakdown(item: {
|
||||
totalTokens: number;
|
||||
@@ -217,12 +211,8 @@ function expectCoherentBreakdown(item: {
|
||||
(breakdown.memoryFiles ?? 0) +
|
||||
(breakdown.skills ?? 0) +
|
||||
(breakdown.startupContext ?? 0) +
|
||||
(breakdown.messages ?? 0) +
|
||||
(breakdown.unattributed ?? 0);
|
||||
(breakdown.messages ?? 0);
|
||||
const hasProviderTotal = item.totalTokens > 0;
|
||||
if (hasProviderTotal) {
|
||||
expect(rows).toBe(item.totalTokens);
|
||||
}
|
||||
// The producer clamps at 0, so an over-full window reports no free space.
|
||||
expect(breakdown.freeSpace).toBe(
|
||||
Math.max(
|
||||
@@ -291,18 +281,13 @@ describe('projectContextUsage', () => {
|
||||
expect(text).toContain('Run /context detail for per-item breakdown.');
|
||||
// MCP tools row is skipped at zero.
|
||||
expect(text).not.toContain('MCP tools');
|
||||
// Parity with views/ContextUsage (#12033): the three optional rows are
|
||||
// absent from this breakdown, so none of them may render. `totalTokens` is
|
||||
// nonzero on purpose — `Cached prefix` and `Unattributed` are total-gated,
|
||||
// so at 0 they cannot render with or without their own guard, and only the
|
||||
// widened `startupContext` skip would be witnessed. This is also the shape
|
||||
// of an older daemon payload; the pre-first-turn view is pinned below.
|
||||
// Optional startup and cached-prefix rows remain absent in legacy payloads.
|
||||
expect(text).not.toContain('Cached prefix');
|
||||
expect(text).not.toContain('Startup context');
|
||||
expect(text).not.toContain('Unattributed');
|
||||
});
|
||||
|
||||
it('prints the cached prefix, startup context and unattributed rows when present (#12033)', () => {
|
||||
it('prints cached prefix and startup context while ignoring legacy residuals', () => {
|
||||
const item = {
|
||||
modelName: 'qwen3-max',
|
||||
totalTokens: 5000,
|
||||
@@ -313,8 +298,7 @@ describe('projectContextUsage', () => {
|
||||
mcpTools: 0,
|
||||
memoryFiles: 200,
|
||||
skills: 0,
|
||||
// Rows sum to totalTokens, freeSpace is window − total − buffer, and
|
||||
// the cached prefix is part of the total (ui/types.ts invariant).
|
||||
// Category estimates do not need to add up to totalTokens.
|
||||
messages: 900,
|
||||
freeSpace: 94000,
|
||||
autocompactBuffer: 1000,
|
||||
@@ -329,7 +313,7 @@ describe('projectContextUsage', () => {
|
||||
expectCoherentBreakdown(item);
|
||||
expect(text).toContain('█ Cached prefix 3.0k tokens (3.0%)');
|
||||
expect(text).toContain('█ Startup context 1.2k tokens (1.2%)');
|
||||
expect(text).toContain('█ Unattributed 900 tokens (0.9%)');
|
||||
expect(text).not.toContain('Unattributed');
|
||||
});
|
||||
|
||||
it('renders the pre-first-turn estimate: startup context shown, no messages yet (#12235)', () => {
|
||||
|
||||
@@ -518,12 +518,6 @@ export function projectContextUsage(item: Record<string, unknown>): string {
|
||||
lines.push(
|
||||
`█ Messages ${fmtTokensShort(messages)} tokens (${pct(messages, windowSize)}%)`,
|
||||
);
|
||||
const unattributed = Number(breakdown['unattributed'] ?? 0);
|
||||
if (unattributed > 0) {
|
||||
lines.push(
|
||||
`█ Unattributed ${fmtTokensShort(unattributed)} tokens (${pct(unattributed, windowSize)}%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Three-tier compaction ladder — ink renders it whenever thresholds +
|
||||
// currentTier are present (even while usage is still estimated).
|
||||
|
||||
@@ -528,6 +528,7 @@ export type ContextTier = 'safe' | 'warn' | 'auto' | 'hard';
|
||||
*/
|
||||
export type ContextThresholds = CompactionThresholds;
|
||||
|
||||
/** Local category estimates are independent of the reported totalTokens. */
|
||||
export interface ContextCategoryBreakdown {
|
||||
systemPrompt: number;
|
||||
builtinTools: number;
|
||||
@@ -540,14 +541,15 @@ export interface ContextCategoryBreakdown {
|
||||
/** Startup prelude outside the skill listing: environment context, MCP server instructions, deferred-tools reminder. */
|
||||
startupContext?: number;
|
||||
/**
|
||||
* Content estimate of the conversation after the startup prelude. With no
|
||||
* Independent content estimate of the conversation after the startup prelude.
|
||||
* It is never derived from or capped by totalTokens. With no
|
||||
* provider total (`totalTokens` 0, e.g. after `/model`, `/restore` or a
|
||||
* resume) it is nonzero, and it is only *part* of the local estimate that
|
||||
* drives the tier: the tier then keys on the request overhead plus this
|
||||
* value, not on this value or on `totalTokens` alone.
|
||||
*/
|
||||
messages: number;
|
||||
/** Provider total not accounted for by any category estimate. Categories plus this sum to `totalTokens`. */
|
||||
/** @deprecated Legacy residual; accepted in old history but no longer emitted or displayed. */
|
||||
unattributed?: number;
|
||||
/** Provider-reported cached prefix. An annotation, not a category: the cached prefix spans several categories. */
|
||||
cachedTokens?: number;
|
||||
|
||||
@@ -3238,6 +3238,7 @@ export type DaemonContinueSessionResult =
|
||||
interruption: 'none' | 'interrupted_prompt' | 'interrupted_turn';
|
||||
};
|
||||
|
||||
/** Local category estimates are independent of the reported totalTokens. */
|
||||
export interface DaemonContextCategoryBreakdown {
|
||||
systemPrompt: number;
|
||||
builtinTools: number;
|
||||
@@ -3249,13 +3250,14 @@ export interface DaemonContextCategoryBreakdown {
|
||||
/** Startup prelude outside the skill listing. Absent from older daemons. */
|
||||
startupContext?: number;
|
||||
/**
|
||||
* Conversation tokens after the startup prelude. When `totalTokens` is 0
|
||||
* Independent conversation estimate after the startup prelude, never derived
|
||||
* from or capped by totalTokens. When `totalTokens` is 0
|
||||
* (no provider count yet: after a model switch, `/restore` or a resume) this
|
||||
* is a local estimate of the history rather than 0, so the rows include the
|
||||
* conversation. Older daemons report 0 there.
|
||||
*/
|
||||
messages: number;
|
||||
/** Provider total not accounted for by any category. Absent from older daemons. */
|
||||
/** @deprecated Legacy residual; accepted from old daemons but no longer emitted or displayed. */
|
||||
unattributed?: number;
|
||||
/** Cached prefix tokens; an annotation that overlaps categories. Absent from older daemons. */
|
||||
cachedTokens?: number;
|
||||
|
||||
@@ -252,9 +252,8 @@ describe('ContextUsageMessage', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('shows the cached prefix, startup context and unattributed rows only when present (#12235)', () => {
|
||||
it('shows cached prefix and startup context while ignoring legacy residuals', () => {
|
||||
const status = makeStatus(60, false);
|
||||
// Rows still sum to the total: 20 + 10 + 5 + 5 + 12 + 8 = 60.
|
||||
Object.assign(status.usage.breakdown, {
|
||||
messages: 0,
|
||||
startupContext: 12,
|
||||
@@ -264,7 +263,7 @@ describe('ContextUsageMessage', () => {
|
||||
const text = render(status).textContent;
|
||||
expect(text).toContain('Cached prefix 30 (30.0%)');
|
||||
expect(text).toContain('Startup context 12 (12.0%)');
|
||||
expect(text).toContain('Unattributed 8 (8.0%)');
|
||||
expect(text).not.toContain('Unattributed');
|
||||
|
||||
const plain = render(makeStatus(60, false)).textContent;
|
||||
expect(plain).not.toContain('Cached prefix');
|
||||
@@ -272,6 +271,23 @@ describe('ContextUsageMessage', () => {
|
||||
expect(plain).not.toContain('Unattributed');
|
||||
});
|
||||
|
||||
it.each(['en', 'zh-CN'] as const)(
|
||||
'preserves category estimates above the total and hides legacy residuals (%s)',
|
||||
(language) => {
|
||||
const status = makeStatus(7, false);
|
||||
Object.assign(status.usage.breakdown, { messages: 193, unattributed: 8 });
|
||||
const container = render(status, false, undefined, language);
|
||||
const text = container.textContent;
|
||||
expect(text).toContain(language === 'en' ? 'Messages 193' : '消息 193');
|
||||
expect(text).not.toContain('Unattributed');
|
||||
expect(text).not.toContain('未归因');
|
||||
expect(text).not.toContain('NaN');
|
||||
expect(
|
||||
parseContextUsageMessage(serializeContextUsageMessage(status)),
|
||||
).toEqual(status);
|
||||
},
|
||||
);
|
||||
|
||||
it('shows an estimated history as messages when the provider total is gone (#12235)', () => {
|
||||
const status = makeStatus(0, true);
|
||||
status.usage.breakdown.messages = 25;
|
||||
|
||||
@@ -544,14 +544,6 @@ export function ContextUsageMessage({
|
||||
tokens={breakdown.messages}
|
||||
/>
|
||||
)}
|
||||
{hasTokenCount && (breakdown.unattributed ?? 0) > 0 && (
|
||||
<CategoryRow
|
||||
{...categoryProps}
|
||||
label={t('contextUsage.unattributed')}
|
||||
tokens={breakdown.unattributed!}
|
||||
symbolClassName={styles.secondary}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
{!hasDetails ? (
|
||||
|
||||
Reference in new issue
Block a user