fix(server): extend OpenCode event stream readiness timeout to match server startup budget

Closes #3571.
This commit is contained in:
BrianAguilarWasco
2026-08-20 22:56:18 +02:00
committed by GitHub
parent 3f7188bc1c
commit 28a963795b
3 changed files with 68 additions and 4 deletions
@@ -26,6 +26,11 @@ import type {
AgentTimelineItem,
} from "../agent-sdk-types.js";
// Deliberately an independent literal rather than the production constant these tests
// guard: deriving the boundary from OPENCODE_SERVER_STARTUP_TIMEOUT_MS would keep the
// readiness tests green if that constant were shortened below the required budget.
const EXPECTED_STARTUP_BUDGET_MS = 30_000;
function tmpCwd(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
try {
@@ -3299,7 +3304,7 @@ describe("OpenCode adapter startTurn error handling", () => {
await session.close();
});
test("bounds dispatch readiness at ten seconds without sending a prompt", async () => {
test("bounds dispatch readiness at the server startup budget without sending a prompt", async () => {
vi.useFakeTimers();
const openCode = new TestOpenCodeClient();
const session = new __openCodeInternals.OpenCodeAgentSession(
@@ -3316,10 +3321,21 @@ describe("OpenCode adapter startTurn error handling", () => {
try {
const dispatch = session.startTurn("wait for transport");
const rejection = expect(dispatch).rejects.toThrow("OpenCode event stream first record");
await vi.advanceTimersByTimeAsync(9_999);
let settled = false;
const markSettled = () => {
settled = true;
};
void dispatch.then(markSettled, markSettled);
await vi.advanceTimersByTimeAsync(EXPECTED_STARTUP_BUDGET_MS - 1);
// Still waiting one tick before the budget: a shorter readiness timeout would have
// already failed the dispatch here.
expect(settled).toBe(false);
expect(openCode.calls.sessionPromptAsync).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(settled).toBe(true);
expect(openCode.calls.sessionPromptAsync).toHaveLength(0);
} finally {
vi.useRealTimers();
@@ -3327,6 +3343,40 @@ describe("OpenCode adapter startTurn error handling", () => {
}
});
test("dispatches a prompt when the event stream needs more than ten seconds", async () => {
vi.useFakeTimers();
const openCode = new TestOpenCodeClient();
openCode.sessionPromptAsyncEvents = [];
const streamReady = createTestDeferred<void>();
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/workspace/repo" },
openCode.asSdkClient(),
"ses_readiness_slow_stream",
createTestLogger(),
new Map(),
{
ready: () => streamReady.promise,
subscribe: () => () => undefined,
},
);
try {
const dispatch = session.startTurn("wait for a slow transport");
// OpenCode installs with plugins have been observed taking well past ten seconds
// to emit their first SSE record.
await vi.advanceTimersByTimeAsync(20_000);
expect(openCode.calls.sessionPromptAsync).toHaveLength(0);
streamReady.resolve();
await dispatch;
await vi.advanceTimersByTimeAsync(0);
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
} finally {
vi.useRealTimers();
streamReady.resolve();
await session.close();
}
});
test("close aborts every in-flight reconciliation request class", async () => {
const cases = [
"child discovery",
@@ -75,6 +75,7 @@ import { execCommand } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
OpenCodeServerManager,
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
type OpenCodeServerAcquisition,
type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js";
@@ -3531,7 +3532,14 @@ class OpenCodeAgentSession implements AgentSession {
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
try {
await withTimeout(this.events.ready(), 10_000, "OpenCode event stream first record");
// The stream cannot deliver its first record before OpenCode finished booting, so
// this wait gets the same budget as server startup instead of a shorter one that
// fails turns on slow (plugin-heavy or cold) starts.
await withTimeout(
this.events.ready(),
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
"OpenCode event stream first record",
);
} catch (error) {
if (this.abortController === turnAbortController) {
this.abortController = null;
@@ -21,6 +21,12 @@ import {
type OpenCodeEventSource,
} from "./event-consumer.js";
/**
* Budget for an OpenCode server to become usable after spawn. Plugin-heavy installs
* routinely need well over ten seconds on a cold start, so every wait that depends on
* the server finishing its boot shares this budget.
*/
export const OPENCODE_SERVER_STARTUP_TIMEOUT_MS = 30_000;
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -388,7 +394,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
if (!started) {
failStartup(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
}
}, 30_000);
}, OPENCODE_SERVER_STARTUP_TIMEOUT_MS);
serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString();