From 80380ce71d46e7d7f13eb4299c681ae941ea9565 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Mon, 17 Aug 2026 21:46:06 +0800 Subject: [PATCH 1/4] fix: read the CLI 1.1.21 context-usage response shape qodercli 1.1.21 changed the get_context_usage control response from flat token counts (totalTokens/maxTokens/rawMaxTokens/percentage) to a percentage-based shape (contextWindow.usedPercentage, categories, tokenCountsAvailable). The turn tracker kept reading the old fields, so every lookup came back undefined and the context usage meter was stuck at its "appears after the first response" placeholder. Parse the new shape: percentage drives the meter directly, absolute token counts are used when the CLI reports them (tokenCountsAvailable), and window size falls back to the previous turn or the model catalog. Router tests now mock the new wire shape. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 4 ++ src/qoder/runtime/qoder-turn-tracker.ts | 71 ++++++++++++++----- .../qoder/runtime/qoder-chat-runtime.test.ts | 38 ++++------ 3 files changed, 71 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b182cfe..41f56e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. count and logs per-stage details to the developer console, including the underlying file error (such as a permission denial) for each session history file that fails to load. +- The context usage meter updates again after each response: Qoder CLI + 1.1.21 changed its context-usage report to a percentage-based shape + without absolute token counts, which the meter could not read, so it + stayed stuck at its "appears after the first response" placeholder. ## [1.0.4] - 2026-08-12 diff --git a/src/qoder/runtime/qoder-turn-tracker.ts b/src/qoder/runtime/qoder-turn-tracker.ts index 4d7155f..0776b52 100644 --- a/src/qoder/runtime/qoder-turn-tracker.ts +++ b/src/qoder/runtime/qoder-turn-tracker.ts @@ -19,6 +19,24 @@ interface ContextUsageRequest { sessionId: string | null; } +/** + * Wire shape returned by the qodercli `get_context_usage` control API + * (1.1.21+). Percentages are reported in percent units (5.4 === 5.4%); + * absolute token counts only appear when the CLI can provide them. + * The SDK's declared response type still mirrors an older shape, so the + * tracker reads the payload through this interface. + */ +interface CliContextUsagePayload { + model?: string; + tokenCountsAvailable?: boolean; + contextWindow?: { + usedPercentage?: number; + usedTokens?: number; + maxTokens?: number; + }; + categories?: Array<{ type?: string; tokens?: number; percentage?: number }>; +} + /** Owns metadata and usage state that lives for exactly one Qoder turn. */ export class QoderTurnTracker { private metadata: ChatTurnMetadata = {}; @@ -92,29 +110,49 @@ export class QoderTurnTracker { } try { - const response = await activeQuery.getContextUsage(); + const payload = await activeQuery.getContextUsage() as unknown as CliContextUsagePayload; if (!request.isCurrentQuery(activeQuery)) { return null; } const previousUsage = this.bufferedUsageChunk?.usage; const model = toQoderRuntimeModelId( - response.model || previousUsage?.model || request.configuredModel, + payload.model || previousUsage?.model || request.configuredModel, ); - const reportedContextWindow = [response.rawMaxTokens, response.maxTokens] - .find(value => Number.isFinite(value) && value > 0); + const rawMaxTokens = payload.contextWindow?.maxTokens; + const reportedMaxTokens = typeof rawMaxTokens === 'number' + && Number.isFinite(rawMaxTokens) && rawMaxTokens > 0 + ? rawMaxTokens + : undefined; + const hasReportedWindow = reportedMaxTokens !== undefined; const previousContextWindow = previousUsage?.model === model && previousUsage.contextWindow > 0 ? previousUsage.contextWindow : undefined; - const contextWindow = reportedContextWindow + const contextWindow = reportedMaxTokens ?? previousContextWindow ?? getContextWindowSize(model); - const ratio = Number.isFinite(response.percentage) - ? Math.min(1, Math.max(0, response.percentage)) + + const rawUsedPercentage = payload.contextWindow?.usedPercentage; + const hasReportedRatio = typeof rawUsedPercentage === 'number' + && Number.isFinite(rawUsedPercentage); + const ratio = hasReportedRatio ? Math.min(1, Math.max(0, rawUsedPercentage / 100)) : 0; + + // Absolute counts are only meaningful when the CLI can provide them. + const categoryTokens = payload.tokenCountsAvailable === true + ? (payload.categories ?? []).reduce( + (sum, category) => sum + (typeof category.tokens === 'number' + && Number.isFinite(category.tokens) && category.tokens > 0 + ? category.tokens + : 0), + 0, + ) : 0; - const reportedTotalTokens = Number.isFinite(response.totalTokens) && response.totalTokens > 0 - ? response.totalTokens + const rawUsedTokens = payload.contextWindow?.usedTokens; + const usedTokens = typeof rawUsedTokens === 'number' + && Number.isFinite(rawUsedTokens) && rawUsedTokens > 0 + ? rawUsedTokens : 0; + const reportedTotalTokens = [usedTokens, categoryTokens].find(value => value > 0) ?? 0; const estimatedContextTokens = ratio > 0 ? Math.max(1, Math.round(contextWindow * ratio)) : 0; @@ -122,23 +160,18 @@ export class QoderTurnTracker { || estimatedContextTokens || previousUsage?.contextTokens || 0; - const apiUsage = response.apiUsage; return { type: 'usage', usage: { model, - inputTokens: apiUsage?.input_tokens || previousUsage?.inputTokens || 0, - cacheCreationInputTokens: apiUsage?.cache_creation_input_tokens - || previousUsage?.cacheCreationInputTokens - || 0, - cacheReadInputTokens: apiUsage?.cache_read_input_tokens - || previousUsage?.cacheReadInputTokens - || 0, + inputTokens: previousUsage?.inputTokens || 0, + cacheCreationInputTokens: previousUsage?.cacheCreationInputTokens || 0, + cacheReadInputTokens: previousUsage?.cacheReadInputTokens || 0, contextWindow, - contextWindowIsAuthoritative: reportedContextWindow !== undefined, + contextWindowIsAuthoritative: hasReportedWindow, contextTokens, - percentage: Number.isFinite(response.percentage) + percentage: hasReportedRatio ? Math.round(ratio * 100) : Math.min(100, Math.max(0, Math.round((contextTokens / contextWindow) * 100))), }, diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index dd7dabb..dba1ec5 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -1305,17 +1305,13 @@ describe('QoderChatRuntime', () => { it('should use getContextUsage percentage when Qoder CLI masks token counts', async () => { (service as any).persistentQuery = { getContextUsage: jest.fn().mockResolvedValue({ - totalTokens: 0, - maxTokens: 0, - rawMaxTokens: 0, - percentage: 0.125, model: 'performance', - apiUsage: { - input_tokens: 0, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, + tokenCountsAvailable: false, + contextWindow: { usedPercentage: 12.5 }, + categories: [ + { type: 'system_prompt', percentage: 2.5 }, + { type: 'messages', percentage: 10 }, + ], }), }; @@ -1345,17 +1341,13 @@ describe('QoderChatRuntime', () => { it('should prefer public context token counts when Qoder CLI returns them', async () => { (service as any).persistentQuery = { getContextUsage: jest.fn().mockResolvedValue({ - totalTokens: 12_000, - maxTokens: 280_000, - rawMaxTokens: 300_000, - percentage: 0.04, model: 'ultimate', - apiUsage: { - input_tokens: 10_000, - output_tokens: 500, - cache_creation_input_tokens: 1_000, - cache_read_input_tokens: 1_000, - }, + tokenCountsAvailable: true, + contextWindow: { usedPercentage: 4, usedTokens: 12_000, maxTokens: 300_000 }, + categories: [ + { type: 'system_prompt', tokens: 2_000, percentage: 0.7 }, + { type: 'messages', tokens: 10_000, percentage: 3.3 }, + ], }), }; @@ -1369,9 +1361,9 @@ describe('QoderChatRuntime', () => { type: 'usage', usage: { model: 'ultimate', - inputTokens: 10_000, - cacheCreationInputTokens: 1_000, - cacheReadInputTokens: 1_000, + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, contextWindow: 300_000, contextWindowIsAuthoritative: true, contextTokens: 12_000, From 72875c0136f1f08f87f25d602dcfb44529c6f72e Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Mon, 17 Aug 2026 22:18:06 +0800 Subject: [PATCH 2/4] fix: keep the chosen context-window tier in the usage meter The post-response context-usage refresh fell back to the model catalog default window whenever the CLI omitted maxTokens, so a tier chosen in the per-model editor (such as 400K) reverted to the default (200K) after the first message. Route the effective per-model context window into the turn tracker and prefer it over the catalog fallback when the CLI reports no absolute window. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 4 ++ src/qoder/runtime/qoder-chat-runtime.ts | 5 +++ src/qoder/runtime/qoder-response-router.ts | 3 ++ src/qoder/runtime/qoder-turn-tracker.ts | 9 ++++ .../qoder/runtime/qoder-chat-runtime.test.ts | 43 +++++++++++++++++++ 5 files changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f56e6..129e0ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. 1.1.21 changed its context-usage report to a percentage-based shape without absolute token counts, which the meter could not read, so it stayed stuck at its "appears after the first response" placeholder. +- The context usage meter keeps the context-window tier chosen in the + per-model editor after a response; previously the post-response + refresh silently fell back to the model catalog default (such as + 200K), so a 400K selection reverted to 200K once a message was sent. ## [1.0.4] - 2026-08-12 diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index 5da9b1c..ceb9ea4 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -59,6 +59,7 @@ import { getActiveQoderCliEdition, getQoderCliBinaryBaseName } from '../config/c import { loadSubagentFinalResult, loadSubagentToolCalls } from '../history/qoder-history-store'; import type { McpServerManager } from '../mcp/mcp-server-manager'; import { toQoderRuntimeModelId } from '../models/model-selection'; +import { qoderModelConfig } from '../models/qoder-model-config'; import { stripCurrentNoteContext } from '../prompt/context/prompt-context'; import { encodeQoderTurn } from '../prompt/qoder-turn-encoder'; import type { QoderHostContext } from '../qoder-host-context'; @@ -183,6 +184,10 @@ export class QoderChatRuntime implements ChatRuntime { getCurrentQuery: () => this.persistentQuery, getMessageChannel: () => this.messageChannel, getConfiguredModel: () => this.getScopedSettings().model, + getConfiguredContextWindow: () => { + const settings = this.getScopedSettings(); + return qoderModelConfig.getEffectiveContextWindowSize(settings.model, settings); + }, getSessionId: () => this.sessionManager.getSessionId(), onSessionInit: event => { const wasFork = this.pendingForkSession; diff --git a/src/qoder/runtime/qoder-response-router.ts b/src/qoder/runtime/qoder-response-router.ts index 897aa78..1ef89df 100644 --- a/src/qoder/runtime/qoder-response-router.ts +++ b/src/qoder/runtime/qoder-response-router.ts @@ -16,6 +16,8 @@ interface QoderResponseRouterDeps { getCurrentQuery: () => Query | null; getMessageChannel: () => QoderMessageChannel | null; getConfiguredModel: () => string; + /** Effective context window from the per-model override, if any. */ + getConfiguredContextWindow: () => number | undefined; getSessionId: () => string | null; onSessionInit: (event: SessionInitEvent) => void; onPlanModeEntered: () => void; @@ -137,6 +139,7 @@ export class QoderResponseRouter { query: this.deps.getCurrentQuery(), isCurrentQuery: query => this.deps.getCurrentQuery() === query, configuredModel: this.deps.getConfiguredModel(), + configuredContextWindow: this.deps.getConfiguredContextWindow(), sessionId: this.deps.getSessionId(), }); if (contextUsageChunk) { diff --git a/src/qoder/runtime/qoder-turn-tracker.ts b/src/qoder/runtime/qoder-turn-tracker.ts index 0776b52..0af7561 100644 --- a/src/qoder/runtime/qoder-turn-tracker.ts +++ b/src/qoder/runtime/qoder-turn-tracker.ts @@ -16,6 +16,8 @@ interface ContextUsageRequest { query: Query | null; isCurrentQuery: (query: Query) => boolean; configuredModel: string; + /** Effective context window from the per-model editor override, if any. */ + configuredContextWindow?: number; sessionId: string | null; } @@ -128,7 +130,14 @@ export class QoderTurnTracker { const previousContextWindow = previousUsage?.model === model && previousUsage.contextWindow > 0 ? previousUsage.contextWindow : undefined; + // Without a CLI-reported window the configured tier is the source of + // truth; buffered chunks only carry catalog fallbacks. + const configuredContextWindow = Number.isFinite(request.configuredContextWindow) + && (request.configuredContextWindow as number) > 0 + ? request.configuredContextWindow + : undefined; const contextWindow = reportedMaxTokens + ?? configuredContextWindow ?? previousContextWindow ?? getContextWindowSize(model); diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index dba1ec5..5279686 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -1338,6 +1338,49 @@ describe('QoderChatRuntime', () => { expect(onDone).toHaveBeenCalled(); }); + it('should honor the configured context-window tier when the CLI omits the window', async () => { + (mockPlugin as any).settings.model = 'performance'; + (mockPlugin as any).settings.qoder = { + discoveredModels: [{ + value: 'performance', + contextTiers: [ + { label: '200K', tokenCount: 200_000, isDefault: true }, + { label: '400K', tokenCount: 400_000, isDefault: false }, + ], + }], + modelOverrides: { performance: { contextWindow: 400_000 } }, + }; + (service as any).persistentQuery = { + getContextUsage: jest.fn().mockResolvedValue({ + model: 'performance', + tokenCountsAvailable: false, + contextWindow: { usedPercentage: 10 }, + categories: [], + }), + }; + + await (service as any).responseRouter.route({ + type: 'result', + subtype: 'success', + result: 'completed', + }); + + expect(onChunk).toHaveBeenCalledWith({ + type: 'usage', + usage: { + model: 'performance', + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextWindow: 400_000, + contextWindowIsAuthoritative: false, + contextTokens: 40_000, + percentage: 10, + }, + sessionId: null, + }); + }); + it('should prefer public context token counts when Qoder CLI returns them', async () => { (service as any).persistentQuery = { getContextUsage: jest.fn().mockResolvedValue({ From 1d04ad4c075ca4143b135defd92267fbb5c7873b Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 18 Aug 2026 00:20:09 +0800 Subject: [PATCH 3/4] chore: upgrade @qoder-ai/qoder-agent-sdk to 1.0.23 The new SDK ships the updated get_context_usage response type (contextWindow.usedPercentage plus percentage-only categories), so the turn tracker now consumes the SDK type directly instead of a local stand-in: drop the cast, the dead token-count branches, and the CLI-reported window path that the API no longer provides. Token counts shown in the meter tooltip are derived from the reported percentage against the effective context window (configured tier first). Smoke-tested against a real CLI; the one affected router test now asserts the percentage-derived shape. Co-authored-by: QoderAI (Qwen 3.8 Max) --- package-lock.json | 8 +-- package.json | 2 +- src/qoder/runtime/qoder-turn-tracker.ts | 56 +++---------------- .../qoder/runtime/qoder-chat-runtime.test.ts | 24 +++++--- 4 files changed, 29 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index c163357..80151a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.38.6", "@modelcontextprotocol/sdk": "~1.30.0", - "@qoder-ai/qoder-agent-sdk": "1.0.16", + "@qoder-ai/qoder-agent-sdk": "^1.0.23", "tslib": "^2.8.1" }, "devDependencies": { @@ -2130,9 +2130,9 @@ } }, "node_modules/@qoder-ai/qoder-agent-sdk": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.16.tgz", - "integrity": "sha512-hcKmA6jjt1TJizoZ1ezhyYfO5sJ3LgkqHjz0T5TtmhfYVBv8rgiq1VdijEF4PYT8v9347/tXcRzHqKfFmMn7Xw==", + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@qoder-ai/qoder-agent-sdk/-/qoder-agent-sdk-1.0.23.tgz", + "integrity": "sha512-r3ogzHwVQo5oarY4GjlJb8v7THl2ECo9VyFnSDaM3FUrYUlOoRDfsZsUrKfZIxW5k0R4pFmQ9kKmRAPMdF03dw==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { diff --git a/package.json b/package.json index 50da00c..3e95a1c 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.38.6", "@modelcontextprotocol/sdk": "~1.30.0", - "@qoder-ai/qoder-agent-sdk": "1.0.16", + "@qoder-ai/qoder-agent-sdk": "^1.0.23", "tslib": "^2.8.1" }, "overrides": { diff --git a/src/qoder/runtime/qoder-turn-tracker.ts b/src/qoder/runtime/qoder-turn-tracker.ts index 0af7561..cd63627 100644 --- a/src/qoder/runtime/qoder-turn-tracker.ts +++ b/src/qoder/runtime/qoder-turn-tracker.ts @@ -21,24 +21,6 @@ interface ContextUsageRequest { sessionId: string | null; } -/** - * Wire shape returned by the qodercli `get_context_usage` control API - * (1.1.21+). Percentages are reported in percent units (5.4 === 5.4%); - * absolute token counts only appear when the CLI can provide them. - * The SDK's declared response type still mirrors an older shape, so the - * tracker reads the payload through this interface. - */ -interface CliContextUsagePayload { - model?: string; - tokenCountsAvailable?: boolean; - contextWindow?: { - usedPercentage?: number; - usedTokens?: number; - maxTokens?: number; - }; - categories?: Array<{ type?: string; tokens?: number; percentage?: number }>; -} - /** Owns metadata and usage state that lives for exactly one Qoder turn. */ export class QoderTurnTracker { private metadata: ChatTurnMetadata = {}; @@ -112,7 +94,9 @@ export class QoderTurnTracker { } try { - const payload = await activeQuery.getContextUsage() as unknown as CliContextUsagePayload; + // The CLI reports occupancy as a percentage only; absolute token + // counts are derived against the effective context window below. + const payload = await activeQuery.getContextUsage(); if (!request.isCurrentQuery(activeQuery)) { return null; } @@ -121,23 +105,16 @@ export class QoderTurnTracker { const model = toQoderRuntimeModelId( payload.model || previousUsage?.model || request.configuredModel, ); - const rawMaxTokens = payload.contextWindow?.maxTokens; - const reportedMaxTokens = typeof rawMaxTokens === 'number' - && Number.isFinite(rawMaxTokens) && rawMaxTokens > 0 - ? rawMaxTokens - : undefined; - const hasReportedWindow = reportedMaxTokens !== undefined; + // The CLI reports occupancy only; the window comes from the + // configured tier, the previous turn, or the model catalog. const previousContextWindow = previousUsage?.model === model && previousUsage.contextWindow > 0 ? previousUsage.contextWindow : undefined; - // Without a CLI-reported window the configured tier is the source of - // truth; buffered chunks only carry catalog fallbacks. const configuredContextWindow = Number.isFinite(request.configuredContextWindow) && (request.configuredContextWindow as number) > 0 ? request.configuredContextWindow : undefined; - const contextWindow = reportedMaxTokens - ?? configuredContextWindow + const contextWindow = configuredContextWindow ?? previousContextWindow ?? getContextWindowSize(model); @@ -146,27 +123,10 @@ export class QoderTurnTracker { && Number.isFinite(rawUsedPercentage); const ratio = hasReportedRatio ? Math.min(1, Math.max(0, rawUsedPercentage / 100)) : 0; - // Absolute counts are only meaningful when the CLI can provide them. - const categoryTokens = payload.tokenCountsAvailable === true - ? (payload.categories ?? []).reduce( - (sum, category) => sum + (typeof category.tokens === 'number' - && Number.isFinite(category.tokens) && category.tokens > 0 - ? category.tokens - : 0), - 0, - ) - : 0; - const rawUsedTokens = payload.contextWindow?.usedTokens; - const usedTokens = typeof rawUsedTokens === 'number' - && Number.isFinite(rawUsedTokens) && rawUsedTokens > 0 - ? rawUsedTokens - : 0; - const reportedTotalTokens = [usedTokens, categoryTokens].find(value => value > 0) ?? 0; const estimatedContextTokens = ratio > 0 ? Math.max(1, Math.round(contextWindow * ratio)) : 0; - const contextTokens = reportedTotalTokens - || estimatedContextTokens + const contextTokens = estimatedContextTokens || previousUsage?.contextTokens || 0; @@ -178,7 +138,7 @@ export class QoderTurnTracker { cacheCreationInputTokens: previousUsage?.cacheCreationInputTokens || 0, cacheReadInputTokens: previousUsage?.cacheReadInputTokens || 0, contextWindow, - contextWindowIsAuthoritative: hasReportedWindow, + contextWindowIsAuthoritative: false, contextTokens, percentage: hasReportedRatio ? Math.round(ratio * 100) diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 5279686..0698dba 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -1381,16 +1381,24 @@ describe('QoderChatRuntime', () => { }); }); - it('should prefer public context token counts when Qoder CLI returns them', async () => { + it('should derive token counts from the percentage against the catalog window', async () => { (service as any).persistentQuery = { getContextUsage: jest.fn().mockResolvedValue({ model: 'ultimate', - tokenCountsAvailable: true, - contextWindow: { usedPercentage: 4, usedTokens: 12_000, maxTokens: 300_000 }, + contextWindow: { usedPercentage: 4 }, categories: [ - { type: 'system_prompt', tokens: 2_000, percentage: 0.7 }, - { type: 'messages', tokens: 10_000, percentage: 3.3 }, + { type: 'system_prompt', percentage: 0.7 }, + { type: 'messages', percentage: 3.3 }, ], + autoCompact: { enabled: true, thresholdPercentage: 92 }, + skills: { count: 0, percentageOfContext: 0, items: [] }, + duplicateFileReads: [], + session: { + messageCount: 2, + promptCount: 1, + toolCalls: { total: 0, succeeded: 0, failed: 0 }, + linesChanged: { added: 0, removed: 0 }, + }, }), }; @@ -1407,9 +1415,9 @@ describe('QoderChatRuntime', () => { inputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, - contextWindow: 300_000, - contextWindowIsAuthoritative: true, - contextTokens: 12_000, + contextWindow: 200_000, + contextWindowIsAuthoritative: false, + contextTokens: 8_000, percentage: 4, }, sessionId: null, From da87d79b816c1062ce1eedc520011ef62251b8ff Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Tue, 18 Aug 2026 01:39:08 +0800 Subject: [PATCH 4/4] fix: keep the context meter stable while a response streams Mid-turn usage chunks were built against the model catalog window and could carry zeroed token counts, so the meter flashed to the catalog default (200K) or to its 0% placeholder during a response and only recovered when the post-response refresh ran. Transform options now carry the effective per-model context window, the router drops zeroed usage snapshots while a real reading is buffered, and the buffered reading survives across turns (it is the fallback chain's previous-turn source) instead of being cleared at turn boundaries. Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 4 ++ src/qoder/runtime/qoder-response-router.ts | 10 ++++ src/qoder/runtime/qoder-turn-tracker.ts | 14 ++++-- src/qoder/stream/transform-qoder-message.ts | 6 ++- .../qoder/runtime/qoder-chat-runtime.test.ts | 49 +++++++++++++++++++ 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129e0ce..6edab1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. per-model editor after a response; previously the post-response refresh silently fell back to the model catalog default (such as 200K), so a 400K selection reverted to 200K once a message was sent. +- The context usage meter no longer flickers to 0% or to the catalog + default window while a response is streaming: mid-turn usage + snapshots now carry the configured context-window tier, and zeroed + snapshots can no longer overwrite an existing reading. ## [1.0.4] - 2026-08-12 diff --git a/src/qoder/runtime/qoder-response-router.ts b/src/qoder/runtime/qoder-response-router.ts index 1ef89df..4f531ff 100644 --- a/src/qoder/runtime/qoder-response-router.ts +++ b/src/qoder/runtime/qoder-response-router.ts @@ -65,6 +65,7 @@ export class QoderResponseRouter { const autoTurnBufferStartLength = this.autoTurnBuffer.length; const transformOptions = this.deps.turnTracker.getTransformOptions( this.deps.getConfiguredModel(), + this.deps.getConfiguredContextWindow(), ); for (const event of transformSDKMessage(message, transformOptions)) { @@ -92,6 +93,15 @@ export class QoderResponseRouter { if (!isStreamChunk(event)) continue; + // Streaming can emit zeroed usage snapshots (the CLI masks counts + // mid-turn); dropping them keeps the meter on its last real reading + // instead of flashing back to the placeholder. + if (event.type === 'usage' + && event.usage.contextTokens <= 0 + && this.deps.turnTracker.hasBufferedUsage()) { + continue; + } + if ( message.type === 'assistant' && event.type === 'text' diff --git a/src/qoder/runtime/qoder-turn-tracker.ts b/src/qoder/runtime/qoder-turn-tracker.ts index cd63627..43811a0 100644 --- a/src/qoder/runtime/qoder-turn-tracker.ts +++ b/src/qoder/runtime/qoder-turn-tracker.ts @@ -31,13 +31,11 @@ export class QoderTurnTracker { consumeMetadata(): ChatTurnMetadata { const metadata = { ...this.metadata }; this.metadata = {}; - this.bufferedUsageChunk = null; return metadata; } reset(): void { this.metadata = {}; - this.bufferedUsageChunk = null; this.clearTransformState(); } @@ -55,6 +53,15 @@ export class QoderTurnTracker { return chunk; } + /** + * Whether a non-zero usage reading is buffered. The reading survives + * across turns so mid-turn zeroed snapshots cannot flash the meter + * back to its placeholder; only a fresh runtime starts empty. + */ + hasBufferedUsage(): boolean { + return (this.bufferedUsageChunk?.usage.contextTokens ?? 0) > 0; + } + updateContextWindow(contextWindow: number): UsageChunk | null { if (!this.bufferedUsageChunk || contextWindow <= 0) { return null; @@ -78,9 +85,10 @@ export class QoderTurnTracker { return nextChunk; } - getTransformOptions(model: string) { + getTransformOptions(model: string, contextWindow?: number) { return { intendedModel: toQoderRuntimeModelId(model), + contextWindow, streamState: this.streamState, usageState: this.usageState, }; diff --git a/src/qoder/stream/transform-qoder-message.ts b/src/qoder/stream/transform-qoder-message.ts index faa9255..4ab86f8 100644 --- a/src/qoder/stream/transform-qoder-message.ts +++ b/src/qoder/stream/transform-qoder-message.ts @@ -80,6 +80,8 @@ function transformTaskNotification(message: SDKMessage): StreamChunk | null { export interface TransformOptions { /** The intended model from settings/query (used for context window size). */ intendedModel?: string; + /** Effective context window from the per-model editor override, if any. */ + contextWindow?: number; /** Tracks active streamed tool blocks so input_json_delta can be normalized. */ streamState?: TransformStreamState; /** Tracks prompt-token usage across SDK-compatible stream events. */ @@ -322,7 +324,9 @@ function samePromptUsage(a: PromptUsageSnapshot, b: PromptUsageSnapshot): boolea function buildUsageInfo(promptUsage: PromptUsageSnapshot, options?: TransformOptions): UsageInfo { const model = options?.intendedModel ?? 'sonnet'; - const contextWindow = getContextWindowSize(model); + const contextWindow = typeof options?.contextWindow === 'number' && options.contextWindow > 0 + ? options.contextWindow + : getContextWindowSize(model); const percentage = Math.min(100, Math.max(0, Math.round((promptUsage.contextTokens / contextWindow) * 100))); return { diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 0698dba..1ca32d8 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -1424,6 +1424,55 @@ describe('QoderChatRuntime', () => { }); }); + it('should size streamed usage chunks against the configured context-window tier', async () => { + (mockPlugin as any).settings.model = 'performance'; + (mockPlugin as any).settings.qoder = { + discoveredModels: [{ + value: 'performance', + contextTiers: [ + { label: '200K', tokenCount: 200_000, isDefault: true }, + { label: '400K', tokenCount: 400_000, isDefault: false }, + ], + }], + modelOverrides: { performance: { contextWindow: 400_000 } }, + }; + + await (service as any).responseRouter.route({ + type: 'assistant', + message: { + content: [{ type: 'text', text: 'ok' }], + usage: { input_tokens: 20_000 }, + }, + }); + + expect(onChunk).toHaveBeenCalledWith(expect.objectContaining({ + type: 'usage', + usage: expect.objectContaining({ + contextWindow: 400_000, + contextTokens: 20_000, + percentage: 5, + }), + })); + }); + + it('should not flash the meter to zero when streaming emits a zeroed usage snapshot', async () => { + await (service as any).responseRouter.route({ + type: 'assistant', + message: { content: [], usage: { input_tokens: 10_000 } }, + }); + onChunk.mockClear(); + + await (service as any).responseRouter.route({ + type: 'assistant', + message: { content: [], usage: { input_tokens: 0 } }, + }); + + const zeroChunks = onChunk.mock.calls.filter( + ([chunk]: any) => chunk.type === 'usage' && chunk.usage.contextTokens <= 0, + ); + expect(zeroChunks).toHaveLength(0); + }); + it('should still finish the turn when getContextUsage is unavailable', async () => { (service as any).persistentQuery = { getContextUsage: jest.fn().mockRejectedValue(new Error('unsupported')),