diff --git a/package-lock.json b/package-lock.json index c396338f9b..6ee13ac3f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39240,6 +39240,7 @@ "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "typescript": "^5.9.3", + "undici": "^7.30.0", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", @@ -39970,6 +39971,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "packages/server/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "packages/server/node_modules/vitest": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index 047c7b72a1..fad88186d6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -141,6 +141,7 @@ "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", "typescript": "^5.9.3", + "undici": "^7.30.0", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", diff --git a/packages/server/src/server/agent/providers/opencode/v2/runtime.test.ts b/packages/server/src/server/agent/providers/opencode/v2/runtime.test.ts new file mode 100644 index 0000000000..2cd7d20721 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/runtime.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import { V2Runtime } from "./runtime.js"; + +// Answers every request at once except session.wait, which it holds for WAIT_MS before answering, +// the way OpenCode holds the wait response until the session's agent loop is idle. +const FAKE_HELPER = ` +import http from "node:http"; +const server = http.createServer((request, response) => { + if (request.url.endsWith("/wait")) { + setTimeout(() => { response.statusCode = 204; response.end(); }, Number(process.env.WAIT_MS)); + return; + } + response.setHeader("content-type", "application/json"); + response.end("{}"); +}); +server.listen(0, "127.0.0.1", () => { + console.log("server listening on http://127.0.0.1:" + server.address().port); +}); +`; + +let root: string; +const defaultDispatcher = getGlobalDispatcher(); + +beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-runtime-")); + vi.stubEnv("PASEO_HOME", path.join(root, "home")); +}); + +afterEach(async () => { + setGlobalDispatcher(defaultDispatcher); + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); +}); + +test("session.wait outlasts the fetch header deadline until the helper reports the session idle", async () => { + // Stands in for undici's five-minute default, which ended every longer turn. + setGlobalDispatcher(new Agent({ headersTimeout: 200 })); + const helper = path.join(root, "fake-opencode.mjs"); + await writeFile(helper, FAKE_HELPER); + const runtime = new V2Runtime({ + logger: createTestLogger(), + settings: { + command: { mode: "replace", argv: [process.execPath, helper] }, + env: { WAIT_MS: "2500" }, + }, + decorateEnv: async (env) => env, + }); + try { + const connection = await runtime.acquire(); + await expect(connection.client.session.wait({ sessionID: "ses_long" })).resolves.toBe( + undefined, + ); + } finally { + await runtime.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts index cab5544582..a245e353c0 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts @@ -5,6 +5,7 @@ import type { V2Api } from "./api.js"; import { randomBytes } from "node:crypto"; import { mkdir } from "node:fs/promises"; import type { Logger } from "pino"; +import { Agent } from "undici"; import { spawnProcess } from "../../../../../utils/spawn.js"; import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js"; import { @@ -174,6 +175,9 @@ export class V2Runtime { }), }); const processAbort = new AbortController(); + // session.wait answers only once the session is idle, so a header deadline would fail any + // turn that outlasts it. Requests end when the helper exits or the caller aborts instead. + const dispatcher = new Agent({ headersTimeout: 0 }); const exited = new Promise((resolve) => process.once("exit", (code) => { const error = new Error(`OpenCode helper server exited (${code})`); @@ -199,6 +203,7 @@ export class V2Runtime { const stop = () => { stopped ??= (async () => { await terminateWithTreeKill(process, { gracefulTimeoutMs: 5_000, forceTimeoutMs: 1_000 }); + await dispatcher.destroy(); const entry = await record; if (entry) await managedProcesses?.remove(entry.id); })(); @@ -243,7 +248,9 @@ export class V2Runtime { const signal = init?.signal ? AbortSignal.any([init.signal, processAbort.signal]) : processAbort.signal; - const response = await fetch(request, { ...init, signal }); + // Node's fetch accepts an undici dispatcher, which the DOM RequestInit type omits. + const requestInit: RequestInit & { dispatcher: Agent } = { ...init, signal, dispatcher }; + const response = await fetch(request, requestInit); const html = response.headers.get("content-type")?.includes("text/html") ?? false; if (!response.ok || html) { const requestUrl = request instanceof Request ? request.url : String(request);