Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions src/qoder/runtime/qoder-response-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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'
Expand Down
70 changes: 19 additions & 51 deletions src/qoder/runtime/qoder-turn-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -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();
}

Expand All @@ -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;
Expand All @@ -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,
};
Expand All @@ -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;
}
Expand All @@ -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);

Expand All @@ -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;

Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/qoder/stream/transform-qoder-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 65 additions & 8 deletions tests/unit/qoder/runtime/qoder-chat-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
}),
};

Expand All @@ -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')),
Expand Down
Loading