diff --git a/src/harness/opencode.ts b/src/harness/opencode.ts index a6ad6eb..95d7f29 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 9038883..1cd0e50 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", ])); });