Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions src/harness/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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);
Expand Down Expand Up @@ -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 ?? "",
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(() => {
Expand All @@ -972,6 +979,10 @@ export class OpenCodeHarness implements AgentHarness {
return await http.request<unknown>("GET", `/session/${encodeURIComponent(id)}/message`);
};

const fetchSession = async (http: OpenCodeClient, id: string): Promise<OpenCodeSession> => {
return await http.request<OpenCodeSession>("GET", `/session/${encodeURIComponent(id)}`);
};

const sendPrompt = async (http: OpenCodeClient, id: string, text: string, promptSystemPrompt: string | undefined, signal: AbortSignal): Promise<void> => {
await http.request("POST", `/session/${encodeURIComponent(id)}/prompt_async`, classicPromptBody(text, options.model, promptSystemPrompt), { signal });
};
Expand Down Expand Up @@ -1064,12 +1075,18 @@ export class OpenCodeHarness implements AgentHarness {

const completeTurn = async (success: boolean, result?: string, outcome: "completed" | "failed" | "interrupted" = success ? "completed" : "failed"): Promise<void> => {
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<void> => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
},
};
}
Expand Down
24 changes: 24 additions & 0 deletions tests/opencode-harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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<HarnessMessage, { type: "run_completed" }> | 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({
Expand All @@ -310,6 +333,7 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => {
"/session",
"/session/ses_test/message",
"/session/ses_test/prompt_async",
"/session/ses_test",
"/session/status",
]));
});
Expand Down