diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 60ce52a0f..a8968dabe 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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(); + 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", diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ad3c88729..d0b845ad3 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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; diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index f19ba54d5..e98c8fbd7 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -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();