fix(opencode): fill the context meter during v2 turns (#5710)

OpenCode v2 sessions never reported context used or the model's context
limit, so the composer meter was an empty ring during a turn and vanished
when it ended. Each finished step now reports its context against the
selected model's limit.

Closes #5701

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Matt Cowger <matt@cowger.us>
This commit is contained in:
paseo-bot[bot]
2026-09-29 22:10:27 +02:00
committed by GitHub
co-authored by Matt Cowger
parent 60aff2346c
commit d3c902e9fa
3 changed files with 175 additions and 0 deletions
@@ -35,6 +35,7 @@ import { features } from "./configuration.js";
import { commands } from "./commands.js";
import { messages } from "./history.js";
import { SessionPermissions } from "./permissions.js";
import { SessionUsage } from "./usage.js";
export class OpenCodeV2Session implements AgentSession {
readonly provider = "opencode";
@@ -45,6 +46,7 @@ export class OpenCodeV2Session implements AgentSession {
private readonly permissions: SessionPermissions;
private readonly turns: SessionTurns;
private readonly children: SessionChildren;
private readonly usage: SessionUsage;
private stream: Promise<void> | null = null;
private streamAbort = new AbortController();
private exited = false;
@@ -101,6 +103,17 @@ export class OpenCodeV2Session implements AgentSession {
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
},
});
this.usage = new SessionUsage({
client: () => this.client,
cwd: config.cwd,
info: () => this.info,
emit: (event) => this.emit(event),
reportError: (error) =>
this.logger.warn(
{ error: toDiagnosticErrorMessage(error) },
"OpenCode context usage update failed",
),
});
}
get id() {
return this.info.id;
@@ -465,6 +478,7 @@ export class OpenCodeV2Session implements AgentSession {
return;
}
this.permissions.observe(event);
this.usage.observe(event);
this.turns.observe(event);
this.scheduleReconcile();
}
@@ -0,0 +1,100 @@
import type { ModelInfo, TokenUsageInfo } from "@opencode/client";
import { describe, expect, test } from "vitest";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { AgentUsage } from "../../../agent-sdk-types.js";
function model(context: number): ModelInfo {
return {
id: "model",
modelID: "model",
providerID: "test",
name: "Model",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context, output: 10_000 },
};
}
function stepEnded(harness: V2Harness, id: string, tokens: TokenUsageInfo) {
harness.push({
id,
created: 2,
type: "session.step.ended",
durable: { aggregateID: "session", seq: 1, version: 1 },
data: {
sessionID: "session",
assistantMessageID: "answer",
finish: "tool-calls",
cost: 0.1,
tokens,
},
});
}
describe("OpenCode v2 context usage", () => {
test("reports each step's context against the model limit, in step order", async () => {
const harness = new V2Harness();
harness.info.model = { providerID: "test", id: "model" };
harness.info.tokens = {
input: 900,
output: 90,
reasoning: 0,
cache: { read: 40, write: 0 },
};
harness.info.cost = 0.5;
// Each lookup answers faster than the one before it, so a lookup per step would
// deliver step 1's usage after step 2's.
const lookupDelays = [50, 0];
harness.api.model.list = async (input) => {
await new Promise((resolve) => setTimeout(resolve, lookupDelays.shift() ?? 0));
return {
location: { directory: input.location.directory },
data: [model(200_000)],
};
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
});
const usage: AgentUsage[] = [];
session.subscribe((event) => {
if (event.type === "usage_updated") usage.push(event.usage);
});
try {
stepEnded(harness, "step-1", {
input: 100,
output: 10,
reasoning: 5,
cache: { read: 1_000, write: 20 },
});
stepEnded(harness, "step-2", {
input: 300,
output: 10,
reasoning: 5,
cache: { read: 1_000, write: 20 },
});
await expect.poll(() => usage.length).toBe(2);
expect(usage.map((entry) => entry.contextWindowUsedTokens)).toEqual([1_135, 1_335]);
expect(usage[1]).toEqual({
inputTokens: 900,
outputTokens: 90,
cachedInputTokens: 40,
totalCostUsd: 0.5,
contextWindowUsedTokens: 1_335,
contextWindowMaxTokens: 200_000,
});
} finally {
await session.close();
}
});
});
@@ -0,0 +1,61 @@
import type { ModelRef, OpenCodeEvent, SessionInfo, TokenUsageInfo } from "@opencode/client";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { V2Api } from "./api.js";
import { usageFromV2 } from "./mapping.js";
interface UsageOptions {
client(): V2Api;
cwd: string;
info(): SessionInfo;
emit(event: AgentStreamEvent): void;
reportError(error: unknown): void;
}
// A step is one model call, so its tokens are the context that call used.
function contextWindowUsedTokens(tokens: TokenUsageInfo) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
}
export class SessionUsage {
private readonly limits = new Map<string, Promise<number | undefined>>();
private reports: Promise<void> = Promise.resolve();
constructor(private readonly options: UsageOptions) {}
observe(event: OpenCodeEvent) {
if (event.type !== "session.step.ended") return;
const { tokens } = event.data;
// Reports stay in step order while a model's context limit is still being looked up.
this.reports = this.reports
.then(() => this.report(tokens))
.catch((error: unknown) => this.options.reportError(error));
}
private async report(tokens: TokenUsageInfo) {
const model = this.options.info().model;
const contextWindowMaxTokens = model ? await this.contextLimit(model) : undefined;
this.options.emit({
type: "usage_updated",
provider: "opencode",
usage: {
...usageFromV2(this.options.info()),
contextWindowUsedTokens: contextWindowUsedTokens(tokens),
...(contextWindowMaxTokens === undefined ? {} : { contextWindowMaxTokens }),
},
});
}
private contextLimit(model: ModelRef) {
const key = `${model.providerID}/${model.id}`;
const cached = this.limits.get(key);
if (cached) return cached;
const limit = this.options
.client()
.model.list({ location: { directory: this.options.cwd } })
.then(
(catalog) =>
catalog.data.find(
(entry) => entry.providerID === model.providerID && entry.id === model.id,
)?.limit.context,
);
this.limits.set(key, limit);
limit.catch(() => this.limits.delete(key));
return limit;
}
}