diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a47ad4..523deca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,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/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-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 0af7561..43811a0 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 = {}; @@ -49,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(); } @@ -73,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; @@ -96,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, }; @@ -112,7 +102,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 +113,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 +131,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 +146,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/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 5279686..1ca32d8 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,15 +1415,64 @@ 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, }); }); + 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')),