diff --git a/src/harness/opencode.ts b/src/harness/opencode.ts index a6ad6eb..e519766 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", @@ -117,6 +118,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); @@ -777,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; @@ -792,11 +800,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 +903,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 +966,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 +981,12 @@ 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)}`, 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 => { await http.request("POST", `/session/${encodeURIComponent(id)}/prompt_async`, classicPromptBody(text, options.model, promptSystemPrompt), { signal }); }; @@ -1062,20 +1077,34 @@ 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; - if (success && client && sessionId) { - const messages = await fetchSessionMessages(client, sessionId) - .catch((): undefined => undefined); - finalResult = finalResult ?? extractAssistantResult(messages); - } - finishTurn(success, outcome, finalResult); + 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); + } + if (sessionInterrupted) { + finishTurn(false, "interrupted", undefined, totalCostUsd); + } else { + 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(); @@ -1097,7 +1126,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; @@ -1162,8 +1195,11 @@ export class OpenCodeHarness implements AgentHarness { } } catch (error) { if (!sessionInterrupted) { - if (!turnInProgress) turnCompletionEmitted = false; - finishTurn(false, "failed", errorMessage(error)); + if (!turnInProgress) { + turnCompletionEmitted = false; + turnCompletionPromise = undefined; + } + await completeTurn(false, errorMessage(error)); } } finally { streamController.abort(); @@ -1220,15 +1256,20 @@ export class OpenCodeHarness implements AgentHarness { activeWaitController?.abort(); if (!turnInProgress) { turnCompletionEmitted = false; + turnCompletionPromise = undefined; } - 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..e5f1bc6 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,60 @@ 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("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({ @@ -310,6 +368,7 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => { "/session", "/session/ses_test/message", "/session/ses_test/prompt_async", + "/session/ses_test", "/session/status", ])); }); @@ -1233,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(); @@ -1298,6 +1402,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, @@ -1305,13 +1422,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); });