From afe08cdf7c6770171314981030559ec9fb2cefbb Mon Sep 17 00:00:00 2001 From: Tessa Moore Date: Wed, 22 Jul 2026 20:46:08 -0400 Subject: [PATCH 1/3] fix(opencode): report persisted session cost --- src/harness/opencode.ts | 49 +++++++++++++++++++++++++--------- tests/opencode-harness.test.ts | 24 +++++++++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/harness/opencode.ts b/src/harness/opencode.ts index a6ad6eb5..95d7f29c 100644 --- a/src/harness/opencode.ts +++ b/src/harness/opencode.ts @@ -117,6 +117,12 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +function sessionCostUsd(session: OpenCodeSession | undefined): number { + return typeof session?.cost === "number" && Number.isFinite(session.cost) && session.cost >= 0 + ? session.cost + : 0; +} + function extractPromptText(message: unknown): string { if (typeof message === "string") return message; if (!isRecord(message)) return String(message); @@ -792,11 +798,12 @@ export class OpenCodeHarness implements AgentHarness { success: boolean, outcome: "completed" | "failed" | "interrupted", result?: string, + totalCostUsd = 0, ): boolean => emitRunCompleted({ success, outcome, duration_ms: 0, - total_cost_usd: 0, + total_cost_usd: totalCostUsd, num_turns: runCounter, result, session_id: sessionId ?? "", @@ -894,7 +901,7 @@ export class OpenCodeHarness implements AgentHarness { : `${event.type} failed`; if (turnInProgress && !turnWaitCompleted) { activeWaitController?.abort(); - finishTurn(false, "failed", reason); + await completeTurn(false, reason); } return; } @@ -957,10 +964,10 @@ export class OpenCodeHarness implements AgentHarness { if (streamStarted || !client) return; streamStarted = true; void client.streamEvents(handleEvent, streamController.signal) - .catch((error) => { + .catch(async (error) => { if (!streamController.signal.aborted && turnInProgress && !turnWaitCompleted) { activeWaitController?.abort(); - finishTurn(false, "failed", errorMessage(error)); + await completeTurn(false, errorMessage(error)); } }) .finally(() => { @@ -972,6 +979,10 @@ export class OpenCodeHarness implements AgentHarness { return await http.request("GET", `/session/${encodeURIComponent(id)}/message`); }; + const fetchSession = async (http: OpenCodeClient, id: string): Promise => { + return await http.request("GET", `/session/${encodeURIComponent(id)}`); + }; + const sendPrompt = async (http: OpenCodeClient, id: string, text: string, promptSystemPrompt: string | undefined, signal: AbortSignal): Promise => { await http.request("POST", `/session/${encodeURIComponent(id)}/prompt_async`, classicPromptBody(text, options.model, promptSystemPrompt), { signal }); }; @@ -1064,12 +1075,18 @@ export class OpenCodeHarness implements AgentHarness { const completeTurn = async (success: boolean, result?: string, outcome: "completed" | "failed" | "interrupted" = success ? "completed" : "failed"): Promise => { let finalResult = result; - if (success && client && sessionId) { - const messages = await fetchSessionMessages(client, sessionId) - .catch((): undefined => undefined); + let totalCostUsd = 0; + if (client && sessionId) { + const [messages, session] = await Promise.all([ + success + ? fetchSessionMessages(client, sessionId).catch((): undefined => undefined) + : Promise.resolve(undefined), + fetchSession(client, sessionId).catch((): undefined => undefined), + ]); finalResult = finalResult ?? extractAssistantResult(messages); + totalCostUsd = sessionCostUsd(session); } - finishTurn(success, outcome, finalResult); + finishTurn(success, outcome, finalResult, totalCostUsd); }; const runTurn = async (text: string): Promise => { @@ -1097,7 +1114,11 @@ export class OpenCodeHarness implements AgentHarness { await completeTurn(true); } catch (error) { activeWaitController = undefined; - await completeTurn(false, errorMessage(error)); + if (sessionInterrupted) { + await completeTurn(false, undefined, "interrupted"); + } else { + await completeTurn(false, errorMessage(error)); + } } finally { turnInProgress = false; turnWaitCompleted = false; @@ -1163,7 +1184,7 @@ export class OpenCodeHarness implements AgentHarness { } catch (error) { if (!sessionInterrupted) { if (!turnInProgress) turnCompletionEmitted = false; - finishTurn(false, "failed", errorMessage(error)); + await completeTurn(false, errorMessage(error)); } } finally { streamController.abort(); @@ -1221,14 +1242,18 @@ export class OpenCodeHarness implements AgentHarness { if (!turnInProgress) { turnCompletionEmitted = false; } - finishTurn(false, "interrupted"); if (!client) { + finishTurn(false, "interrupted"); await server?.close().catch((): undefined => undefined); return; } - if (!sessionId) return; + if (!sessionId) { + finishTurn(false, "interrupted"); + return; + } const abortRequest = client.request("POST", `/session/${encodeURIComponent(sessionId)}/abort`).catch((): undefined => undefined); await abortRequest; + await completeTurn(false, undefined, "interrupted"); }, }; } diff --git a/tests/opencode-harness.test.ts b/tests/opencode-harness.test.ts index 90388836..1cd0e505 100644 --- a/tests/opencode-harness.test.ts +++ b/tests/opencode-harness.test.ts @@ -15,6 +15,7 @@ type RequestRecord = { class MockOpenCodeServer { requests: RequestRecord[] = []; closed = false; + sessionCost = 0; waitMode: "immediate" | "defer" = "immediate"; statusMode: "idle" | "busy-then-idle" | "always-busy" | "timeout" = "idle"; busyStatusResponses = 0; @@ -54,6 +55,9 @@ class MockOpenCodeServer { if (path === "/api/health") return json({ healthy: true, version: "1.16.2" }); if (method === "POST" && path === "/session") return json({ id: "ses_test" }); if (method === "POST" && path === "/session/ses_existing/fork") return json({ id: "ses_forked" }); + if (method === "GET" && /^\/session\/ses_[^/]+$/.test(path)) { + return json({ id: path.slice("/session/".length), cost: this.sessionCost }); + } if (method === "GET" && path === "/session/status") { this.statusRequests += 1; if (this.statusMode === "timeout") { @@ -288,6 +292,25 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => { assert.equal(mock.closed, true); }); + it("reports OpenCode's persisted cumulative session cost", async () => { + const mock = new MockOpenCodeServer(); + mock.sessionCost = 41.5661305; + const harness = new OpenCodeHarness({ + createServer: async () => mock.handle(), + fetch: mock.fetch, + }); + + const messages = await collectMessages(harness.launch({ + prompt: "ship it", + cwd: "/repo", + })); + + const result = messages.find((message) => message.type === "run_completed") as Extract | undefined; + assert.equal(result?.data.success, true); + assert.equal(result?.data.total_cost_usd, 41.5661305); + assert.equal(mock.requests.some((request) => request.method === "GET" && request.path === "/session/ses_test"), true); + }); + it("uses the real OpenCode classic JSON lifecycle endpoints", async () => { const mock = new MockOpenCodeServer(); const harness = new OpenCodeHarness({ @@ -310,6 +333,7 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => { "/session", "/session/ses_test/message", "/session/ses_test/prompt_async", + "/session/ses_test", "/session/status", ])); }); From 96e2d9fd96eb0b79d487e2e23c5c6088bf50b34e Mon Sep 17 00:00:00 2001 From: Tessa Moore Date: Wed, 22 Jul 2026 21:47:57 -0400 Subject: [PATCH 2/3] fix(opencode): bound session cost completion --- src/harness/opencode.ts | 44 ++++++++++++++++---------- tests/opencode-harness.test.ts | 57 +++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/harness/opencode.ts b/src/harness/opencode.ts index 95d7f29c..eac08f32 100644 --- a/src/harness/opencode.ts +++ b/src/harness/opencode.ts @@ -62,6 +62,7 @@ type OpenCodePendingInput = { const OPENCODE_COMMAND_ENV = "OPENCLAW_OPENCODE_COMMAND"; const STARTUP_TIMEOUT_MS = 15_000; const REQUEST_TIMEOUT_MS = 60_000; +const SESSION_COST_TIMEOUT_MS = 250; const TURN_TIMEOUT_MS = 15 * 60_000; const MUTATION_PERMISSIONS = [ "edit", @@ -783,6 +784,7 @@ export class OpenCodeHarness implements AgentHarness { let turnInProgress = false; let turnWaitCompleted = false; let turnCompletionEmitted = false; + let turnCompletionPromise: Promise | undefined; let sessionInterrupted = false; let turnSawSseIdle = false; let lastBackendRefConversationId: string | undefined; @@ -980,7 +982,9 @@ export class OpenCodeHarness implements AgentHarness { }; const fetchSession = async (http: OpenCodeClient, id: string): Promise => { - return await http.request("GET", `/session/${encodeURIComponent(id)}`); + return await http.request("GET", `/session/${encodeURIComponent(id)}`, undefined, { + timeoutMs: Math.min(this.deps.requestTimeoutMs ?? REQUEST_TIMEOUT_MS, SESSION_COST_TIMEOUT_MS), + }); }; const sendPrompt = async (http: OpenCodeClient, id: string, text: string, promptSystemPrompt: string | undefined, signal: AbortSignal): Promise => { @@ -1073,26 +1077,30 @@ export class OpenCodeHarness implements AgentHarness { return sessionId; }; - const completeTurn = async (success: boolean, result?: string, outcome: "completed" | "failed" | "interrupted" = success ? "completed" : "failed"): Promise => { - let finalResult = result; - let totalCostUsd = 0; - if (client && sessionId) { - const [messages, session] = await Promise.all([ - success - ? fetchSessionMessages(client, sessionId).catch((): undefined => undefined) - : Promise.resolve(undefined), - fetchSession(client, sessionId).catch((): undefined => undefined), - ]); - finalResult = finalResult ?? extractAssistantResult(messages); - totalCostUsd = sessionCostUsd(session); - } - finishTurn(success, outcome, finalResult, totalCostUsd); + const completeTurn = (success: boolean, result?: string, outcome: "completed" | "failed" | "interrupted" = success ? "completed" : "failed"): Promise => { + turnCompletionPromise ??= (async () => { + let finalResult = result; + let totalCostUsd = 0; + if (client && sessionId) { + const [messages, session] = await Promise.all([ + success + ? fetchSessionMessages(client, sessionId).catch((): undefined => undefined) + : Promise.resolve(undefined), + fetchSession(client, sessionId).catch((): undefined => undefined), + ]); + finalResult = finalResult ?? extractAssistantResult(messages); + totalCostUsd = sessionCostUsd(session); + } + finishTurn(success, outcome, finalResult, totalCostUsd); + })(); + return turnCompletionPromise; }; const runTurn = async (text: string): Promise => { turnInProgress = true; turnWaitCompleted = false; turnCompletionEmitted = false; + turnCompletionPromise = undefined; turnSawSseIdle = false; try { const http = await ensureClient(); @@ -1183,7 +1191,10 @@ export class OpenCodeHarness implements AgentHarness { } } catch (error) { if (!sessionInterrupted) { - if (!turnInProgress) turnCompletionEmitted = false; + if (!turnInProgress) { + turnCompletionEmitted = false; + turnCompletionPromise = undefined; + } await completeTurn(false, errorMessage(error)); } } finally { @@ -1241,6 +1252,7 @@ export class OpenCodeHarness implements AgentHarness { activeWaitController?.abort(); if (!turnInProgress) { turnCompletionEmitted = false; + turnCompletionPromise = undefined; } if (!client) { finishTurn(false, "interrupted"); diff --git a/tests/opencode-harness.test.ts b/tests/opencode-harness.test.ts index 1cd0e505..36aed7cb 100644 --- a/tests/opencode-harness.test.ts +++ b/tests/opencode-harness.test.ts @@ -311,6 +311,41 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => { assert.equal(mock.requests.some((request) => request.method === "GET" && request.path === "/session/ses_test"), true); }); + it("falls back promptly when the optional session cost endpoint is unavailable", async () => { + const mock = new MockOpenCodeServer(); + const originalFetch = mock.fetch; + let costRequestAborted = false; + mock.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input.url); + const method = init?.method ?? "GET"; + if (method === "GET" && url.pathname === "/session/ses_test") { + await originalFetch(input, init); + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + costRequestAborted = true; + reject(new Error("session cost unavailable")); + }, { once: true }); + }); + } + return await originalFetch(input, init); + }; + const harness = new OpenCodeHarness({ + createServer: async () => mock.handle(), + fetch: mock.fetch, + requestTimeoutMs: 5_000, + }); + + const startedAt = Date.now(); + const messages = await collectMessages(harness.launch({ prompt: "ship it", cwd: "/repo" })); + const elapsedMs = Date.now() - startedAt; + + const result = messages.find((message) => message.type === "run_completed") as Extract | undefined; + assert.equal(result?.data.success, true); + assert.equal(result?.data.total_cost_usd, 0); + assert.equal(costRequestAborted, true); + assert.ok(elapsedMs < 1_000, `completion took ${elapsedMs}ms`); + }); + it("uses the real OpenCode classic JSON lifecycle endpoints", async () => { const mock = new MockOpenCodeServer(); const harness = new OpenCodeHarness({ @@ -1322,6 +1357,19 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => { it("does not emit a failed completion after interrupt aborts an active wait", async () => { const mock = new MockOpenCodeServer(); mock.waitMode = "defer"; + mock.sessionCost = 12.75; + const releaseCostRequest = Promise.withResolvers(); + let costRequests = 0; + const originalFetch = mock.fetch; + mock.fetch = async (input, init) => { + const response = await originalFetch(input, init); + const url = new URL(typeof input === "string" ? input : input.url); + if ((init?.method ?? "GET") === "GET" && url.pathname === "/session/ses_test") { + costRequests += 1; + await releaseCostRequest.promise; + } + return response; + }; const harness = new OpenCodeHarness({ createServer: async () => mock.handle(), fetch: mock.fetch, @@ -1329,13 +1377,20 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => { const session = harness.launch({ prompt: "stop", cwd: "/repo" }); await waitForRequest(mock, "/session/ses_test/prompt_async"); - await session.interrupt?.(); + const interruption = session.interrupt?.(); + await waitForRequest(mock, "/session/ses_test"); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(costRequests, 1); + releaseCostRequest.resolve(); + await interruption; const messages = await collectAllMessages(session); const completions = messages.filter((message) => message.type === "run_completed") as Extract[]; assert.equal(completions.length, 1); assert.equal(completions[0]?.data.success, false); assert.equal(completions[0]?.data.outcome, "interrupted"); + assert.equal(completions[0]?.data.total_cost_usd, 12.75); + assert.equal(costRequests, 1); assert.equal(mock.requests.some((request) => request.method === "POST" && request.path === "/session/ses_test/abort"), true); }); From c4ea4f552b717e4b9fb343be043c00fe83809147 Mon Sep 17 00:00:00 2001 From: Mark Goldenstein Date: Wed, 22 Jul 2026 19:28:32 -0700 Subject: [PATCH 3/3] fix(opencode): preserve interrupted outcome --- src/harness/opencode.ts | 6 ++++- tests/opencode-harness.test.ts | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/harness/opencode.ts b/src/harness/opencode.ts index eac08f32..e519766f 100644 --- a/src/harness/opencode.ts +++ b/src/harness/opencode.ts @@ -1091,7 +1091,11 @@ export class OpenCodeHarness implements AgentHarness { finalResult = finalResult ?? extractAssistantResult(messages); totalCostUsd = sessionCostUsd(session); } - finishTurn(success, outcome, finalResult, totalCostUsd); + if (sessionInterrupted) { + finishTurn(false, "interrupted", undefined, totalCostUsd); + } else { + finishTurn(success, outcome, finalResult, totalCostUsd); + } })(); return turnCompletionPromise; }; diff --git a/tests/opencode-harness.test.ts b/tests/opencode-harness.test.ts index 36aed7cb..e5f1bc69 100644 --- a/tests/opencode-harness.test.ts +++ b/tests/opencode-harness.test.ts @@ -1292,6 +1292,51 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => { assert.equal(completions[0]?.data.result, "tool failed"); }); + it("keeps interruption authoritative when it races with an SSE failure completion", async () => { + const mock = new MockOpenCodeServer(); + mock.waitMode = "defer"; + mock.sessionCost = 7.25; + const costRequested = Promise.withResolvers(); + const releaseCostRequest = Promise.withResolvers(); + const originalFetch = mock.fetch; + mock.fetch = async (input, init) => { + const response = await originalFetch(input, init); + const url = new URL(typeof input === "string" ? input : input.url); + if ((init?.method ?? "GET") === "GET" && url.pathname === "/session/ses_test") { + costRequested.resolve(); + await releaseCostRequest.promise; + } + return response; + }; + const harness = new OpenCodeHarness({ + createServer: async () => mock.handle(), + fetch: mock.fetch, + }); + + const session = harness.launch({ prompt: "stop during failure", cwd: "/repo" }); + await waitForRequest(mock, "/session/ses_test/prompt_async"); + mock.emit({ + type: "session.next.step.failed", + properties: { + sessionID: "ses_test", + error: { message: "tool failed during interruption" }, + }, + }); + await costRequested.promise; + const interruption = session.interrupt?.(); + releaseCostRequest.resolve(); + await interruption; + + const messages = await collectAllMessages(session); + const completions = messages.filter((message) => message.type === "run_completed") as Extract[]; + assert.equal(completions.length, 1); + assert.equal(completions[0]?.data.success, false); + assert.equal(completions[0]?.data.outcome, "interrupted"); + assert.equal(completions[0]?.data.result, undefined); + assert.equal(completions[0]?.data.total_cost_usd, 7.25); + assert.equal(mock.requests.filter((request) => request.method === "GET" && request.path === "/session/ses_test").length, 1); + }); + it("keeps wait success when an SSE failure arrives during context fetch", async () => { const mock = new MockOpenCodeServer(); const contextRequested = Promise.withResolvers();