From d3c902e9fab97e451dc52903333a46299eb13fad Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:10:27 +0200 Subject: [PATCH] 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 --- .../agent/providers/opencode/v2/session.ts | 14 +++ .../agent/providers/opencode/v2/usage.test.ts | 100 ++++++++++++++++++ .../agent/providers/opencode/v2/usage.ts | 61 +++++++++++ 3 files changed, 175 insertions(+) create mode 100644 packages/server/src/server/agent/providers/opencode/v2/usage.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/usage.ts diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index f7a19006d9..cb71ff7212 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -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 | 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(); } diff --git a/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts b/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts new file mode 100644 index 0000000000..4fa64079c2 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts @@ -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(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/usage.ts b/packages/server/src/server/agent/providers/opencode/v2/usage.ts new file mode 100644 index 0000000000..17a4885cbc --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/usage.ts @@ -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>(); + private reports: Promise = 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; + } +}