Skip to content
Merged
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
75 changes: 58 additions & 17 deletions src/harness/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -117,6 +118,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 @@ -777,6 +784,7 @@ export class OpenCodeHarness implements AgentHarness {
let turnInProgress = false;
let turnWaitCompleted = false;
let turnCompletionEmitted = false;
let turnCompletionPromise: Promise<void> | undefined;
let sessionInterrupted = false;
let turnSawSseIdle = false;
let lastBackendRefConversationId: string | undefined;
Expand All @@ -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 ?? "",
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(() => {
Expand All @@ -972,6 +981,12 @@ 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)}`, 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<void> => {
await http.request("POST", `/session/${encodeURIComponent(id)}/prompt_async`, classicPromptBody(text, options.model, promptSystemPrompt), { signal });
};
Expand Down Expand Up @@ -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<void> => {
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<void> => {
turnCompletionPromise ??= (async () => {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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<void> => {
turnInProgress = true;
turnWaitCompleted = false;
turnCompletionEmitted = false;
turnCompletionPromise = undefined;
turnSawSseIdle = false;
try {
const http = await ensureClient();
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
},
};
}
Expand Down
126 changes: 125 additions & 1 deletion 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,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<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("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<Response>((_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<HarnessMessage, { type: "run_completed" }> | 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({
Expand All @@ -310,6 +368,7 @@ describe("OpenCodeHarness HTTP/SSE mapping", () => {
"/session",
"/session/ses_test/message",
"/session/ses_test/prompt_async",
"/session/ses_test",
"/session/status",
]));
});
Expand Down Expand Up @@ -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<void>();
const releaseCostRequest = Promise.withResolvers<void>();
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<HarnessMessage, { type: "run_completed" }>[];
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<void>();
Expand Down Expand Up @@ -1298,20 +1402,40 @@ 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<void>();
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,
});

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<HarnessMessage, { type: "run_completed" }>[];
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);
});

Expand Down