From 80380ce71d46e7d7f13eb4299c681ae941ea9565 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Mon, 17 Aug 2026 21:46:06 +0800 Subject: [PATCH 1/2] 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/2] 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({