mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Stop OpenCode helper servers from leaking (#1697)
* Fix OpenCode helper server ownership Own OpenCode helper generations as soon as spawn returns so startup timeout and shutdown clean up through the manager/reaper path. Collapse the OpenCode runtime wrapper so the provider talks to the server manager directly. * Protect dedicated OpenCode server startup
This commit is contained in:
@@ -42,6 +42,8 @@ Provider session import has its own contract. The picker calls `listImportableSe
|
||||
|
||||
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
|
||||
|
||||
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
|
||||
|
||||
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
|
||||
|
||||
---
|
||||
|
||||
@@ -6,8 +6,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
interface MockOpenCodeClientOptions {
|
||||
agents?: unknown[];
|
||||
@@ -15,7 +15,7 @@ interface MockOpenCodeClientOptions {
|
||||
}
|
||||
|
||||
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
|
||||
openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()];
|
||||
@@ -77,7 +77,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]);
|
||||
@@ -86,7 +89,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("falls back to default OpenCode modes when discovery returns no modes", async () => {
|
||||
const { runtime } = mockOpenCodeClient({ agents: [] });
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]);
|
||||
@@ -95,7 +101,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("lists auto accept as a provider feature", async () => {
|
||||
const { runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const enabledFeatures = await client.listFeatures({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -121,7 +130,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -251,7 +263,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -279,7 +294,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -322,7 +340,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -14,7 +14,7 @@ afterEach(() => {
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListImplementation = () =>
|
||||
new Promise((resolve) => {
|
||||
@@ -40,7 +40,10 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
});
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
@@ -57,8 +60,8 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
expect(openCodeClient.calls.providerList).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("passes explicit refresh force through server acquisition", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
test("uses a new server for explicit catalog refresh", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -68,17 +71,20 @@ test("passes explicit refresh force through server acquisition", async () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true });
|
||||
|
||||
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
|
||||
expect(runtime.acquisitions).toEqual([{ kind: "new", releaseCount: 1 }]);
|
||||
});
|
||||
|
||||
test("includes models from api-source providers not in connected", async () => {
|
||||
// Providers with source "api" are managed by the OpenCode console/subscription.
|
||||
// They don't appear in `connected` but are fully usable.
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -100,7 +106,10 @@ test("includes models from api-source providers not in connected", async () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
expect(models).toMatchObject([
|
||||
@@ -113,7 +122,7 @@ test("includes models from api-source providers not in connected", async () => {
|
||||
});
|
||||
|
||||
test("throws when no providers are accessible (neither connected nor api-source)", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -132,7 +141,10 @@ test("throws when no providers are accessible (neither connected nor api-source)
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
|
||||
"OpenCode has no connected providers",
|
||||
@@ -140,7 +152,7 @@ test("throws when no providers are accessible (neither connected nor api-source)
|
||||
});
|
||||
|
||||
test("does not throw when only api-source providers are present with no connected providers", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -159,7 +171,10 @@ test("does not throw when only api-source providers are present with no connecte
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }),
|
||||
|
||||
@@ -5,11 +5,11 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
function mockOpenCodeClient(events: unknown[]) {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionPromptAsyncEvents = events;
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
@@ -35,7 +35,10 @@ function toolPermissionEvent(): unknown {
|
||||
describe("OpenCode permission actions", () => {
|
||||
test("allow always sends OpenCode's always reply", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -63,7 +66,10 @@ describe("OpenCode permission actions", () => {
|
||||
|
||||
test("plain allow keeps the backward-compatible once reply", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -24,11 +24,14 @@ function createDeferred<T>(): {
|
||||
|
||||
describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.listCommands?.()).resolves.toEqual(
|
||||
@@ -49,11 +52,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("executes compact through the OpenCode summarize endpoint", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||
@@ -70,7 +76,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
|
||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||
const idleEventGate = createDeferred<void>();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
|
||||
openCodeClient.commandListResponse = {
|
||||
@@ -85,7 +91,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
})();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
@@ -101,7 +110,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("leaves successful slash command turns open until OpenCode emits idle", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandEvents = [];
|
||||
openCodeClient.commandListResponse = {
|
||||
@@ -109,7 +118,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
@@ -182,9 +182,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("creates a session with valid id and provider", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
runtime.enqueueClient(new TestOpenCodeClient());
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
expect(typeof session.id).toBe("string");
|
||||
@@ -197,11 +200,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("single turn completes with streaming deltas", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const iterator = streamSession(session, "Say hello");
|
||||
@@ -230,11 +236,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("manual compact hides the generated summary text", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
@@ -264,7 +273,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
}, 120_000);
|
||||
|
||||
test("fetchCatalog returns models with required fields", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -296,7 +305,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
],
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const cwd = os.homedir();
|
||||
const catalog = await client.fetchCatalog({ cwd, force: false });
|
||||
|
||||
@@ -331,7 +343,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
}, 60_000);
|
||||
|
||||
test("limits concurrent OpenCode metadata requests across clients", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
let activeProviderListCalls = 0;
|
||||
let maxActiveProviderListCalls = 0;
|
||||
const response = {
|
||||
@@ -364,7 +376,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
}
|
||||
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 12 }, (_, index) =>
|
||||
client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
|
||||
@@ -376,9 +391,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("available modes include build and plan", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
runtime.enqueueClient(new TestOpenCodeClient());
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
@@ -392,7 +410,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("custom agents defined in opencode.json appear in available modes", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = {
|
||||
data: [
|
||||
@@ -408,7 +426,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
@@ -431,7 +452,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const planOpenCodeClient = new TestOpenCodeClient();
|
||||
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
|
||||
const buildOpenCodeClient = new TestOpenCodeClient();
|
||||
@@ -468,7 +489,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
];
|
||||
runtime.enqueueClient(planOpenCodeClient);
|
||||
runtime.enqueueClient(buildOpenCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
const planSession = await client.createSession({
|
||||
...buildConfig(cwd),
|
||||
@@ -861,11 +885,14 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
|
||||
describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("dynamically adds injected MCP servers without config-backed connect", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
@@ -901,7 +928,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
|
||||
test("fails the turn when OpenCode reports MCP add failure in data payload", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.mcpAddResponse = {
|
||||
data: {
|
||||
@@ -913,7 +940,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
@@ -1665,11 +1695,14 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
|
||||
describe("OpenCodeAgentClient env", () => {
|
||||
test("passes launch-context env to env-specific server acquisition", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession(
|
||||
@@ -1686,7 +1719,7 @@ describe("OpenCodeAgentClient env", () => {
|
||||
await session.close();
|
||||
|
||||
expect(runtime.acquisitions[0]).toMatchObject({
|
||||
force: false,
|
||||
kind: "dedicated",
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
@@ -1848,7 +1881,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("listImportableSessions returns rows without hydrating session messages", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
const cwd = "/workspace/repo";
|
||||
const otherCwd = "/workspace/other";
|
||||
@@ -1945,7 +1978,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const sessions = await client.listImportableSessions({ cwd, limit: 1 });
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
@@ -1965,7 +2001,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("importSession reads only the selected OpenCode session without listing", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const metadataClient = new TestOpenCodeClient();
|
||||
const resumedClient = new TestOpenCodeClient();
|
||||
const cwd = "/workspace/repo";
|
||||
@@ -2004,7 +2040,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
runtime.enqueueClient(metadataClient);
|
||||
runtime.enqueueClient(resumedClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const imported = await client.importSession(
|
||||
{ providerHandleId: "ses_selected", cwd },
|
||||
{
|
||||
@@ -2041,7 +2080,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("listImportableSessions matches Windows cwd paths with forward slashes", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
const requestedCwd = "C:/Users/Administrator/GhostFactory";
|
||||
const storedCwd = "C:\\Users\\Administrator\\GhostFactory";
|
||||
@@ -2064,7 +2103,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 });
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
type Event as OpenCodeEvent,
|
||||
type FilePartInput as OpenCodeFilePartInput,
|
||||
type GlobalSession as OpenCodeGlobalSession,
|
||||
type Message as OpenCodeMessage,
|
||||
type OpencodeClient,
|
||||
type OpencodeClientConfig,
|
||||
type Part as OpenCodePart,
|
||||
type Session as OpenCodeSession,
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
@@ -64,7 +66,10 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
import {
|
||||
OpenCodeServerManager,
|
||||
type OpenCodeServerManagerLike,
|
||||
} from "./opencode/server-manager.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -75,11 +80,6 @@ import {
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import {
|
||||
createSdkOpenCodeClient,
|
||||
type OpenCodeRuntime,
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
|
||||
@@ -1212,31 +1212,15 @@ export const __openCodeInternals = {
|
||||
};
|
||||
|
||||
interface OpenCodeAgentClientDeps {
|
||||
runtime?: OpenCodeRuntime;
|
||||
serverManager?: OpenCodeServerManagerLike;
|
||||
createClient?: OpenCodeClientFactory;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
}
|
||||
|
||||
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
|
||||
constructor(private readonly serverManager: OpenCodeServerManager) {}
|
||||
type OpenCodeClientFactory = (options: { baseUrl: string; directory: string }) => OpencodeClient;
|
||||
|
||||
async acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
return this.serverManager.acquire(options);
|
||||
}
|
||||
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.serverManager.ensureRunning();
|
||||
}
|
||||
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
return createSdkOpenCodeClient(options);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.serverManager.shutdown();
|
||||
}
|
||||
function createSdkOpenCodeClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
|
||||
}
|
||||
|
||||
export class OpenCodeAgentClient implements AgentClient {
|
||||
@@ -1245,7 +1229,8 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
readonly resolveCreateConfig = resolveOpenCodeCreateConfig;
|
||||
readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended;
|
||||
|
||||
private readonly runtime: OpenCodeRuntime;
|
||||
private readonly serverManager: OpenCodeServerManagerLike;
|
||||
private readonly createOpenCodeClient: OpenCodeClientFactory;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
@@ -1257,13 +1242,12 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new ProductionOpenCodeRuntime(
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
|
||||
managedProcesses: deps.managedProcesses,
|
||||
}),
|
||||
);
|
||||
this.serverManager =
|
||||
deps.serverManager ??
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
|
||||
managedProcesses: deps.managedProcesses,
|
||||
});
|
||||
this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -1272,12 +1256,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: launchContext?.env,
|
||||
});
|
||||
const acquisition = launchContext?.env
|
||||
? await this.serverManager.acquireDedicated(launchContext.env)
|
||||
: await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1334,9 +1317,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
cwd,
|
||||
};
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1361,10 +1344,12 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
const acquisition = options.force
|
||||
? await this.serverManager.acquireNew()
|
||||
: await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const directory = options.cwd;
|
||||
const client = this.runtime.createClient({ baseUrl: url, directory });
|
||||
const client = this.createOpenCodeClient({ baseUrl: url, directory });
|
||||
|
||||
try {
|
||||
const [models, modes] = await Promise.all([
|
||||
@@ -1379,9 +1364,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1400,9 +1385,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: options?.cwd ?? "",
|
||||
});
|
||||
@@ -1415,9 +1400,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: input.cwd,
|
||||
});
|
||||
@@ -1460,7 +1445,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.runtime.shutdown();
|
||||
await this.serverManager.shutdown();
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type {
|
||||
@@ -17,12 +17,16 @@ import {
|
||||
type OpenCodeServerProcessSpawner,
|
||||
} from "./opencode/server-manager.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("OpenCodeServerManager generations", () => {
|
||||
test("rotation creates a new current server without killing a referenced old server", async () => {
|
||||
const { manager, runtime } = createTestManager([4101, 4102]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const newAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const newAcquisition = await manager.acquireNew();
|
||||
|
||||
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
|
||||
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
|
||||
@@ -37,11 +41,11 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("new acquisitions after rotation use the new server", async () => {
|
||||
const { manager, runtime } = createTestManager([4201, 4202]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const rotatedAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const rotatedAcquisition = await manager.acquireNew();
|
||||
rotatedAcquisition.release();
|
||||
|
||||
const nextAcquisition = await manager.acquire({ force: false });
|
||||
const nextAcquisition = await manager.acquireCurrent();
|
||||
|
||||
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
@@ -50,15 +54,15 @@ describe("OpenCodeServerManager generations", () => {
|
||||
oldAcquisition.release();
|
||||
});
|
||||
|
||||
test("concurrent forced acquisitions share one fresh generation", async () => {
|
||||
test("concurrent new-server acquisitions share one fresh generation", async () => {
|
||||
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
|
||||
|
||||
const initialAcquisition = await manager.acquire({ force: false });
|
||||
const initialAcquisition = await manager.acquireCurrent();
|
||||
initialAcquisition.release();
|
||||
|
||||
const [modelsAcquisition, modesAcquisition] = await Promise.all([
|
||||
manager.acquire({ force: true }),
|
||||
manager.acquire({ force: true }),
|
||||
manager.acquireNew(),
|
||||
manager.acquireNew(),
|
||||
]);
|
||||
|
||||
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
|
||||
@@ -72,8 +76,8 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("release is idempotent", async () => {
|
||||
const { manager, runtime } = createTestManager([4301, 4302]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const newAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const newAcquisition = await manager.acquireNew();
|
||||
newAcquisition.release();
|
||||
|
||||
oldAcquisition.release();
|
||||
@@ -85,8 +89,8 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("shutdown kills current and retired servers", async () => {
|
||||
const { manager, runtime } = createTestManager([4401, 4402]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquire({ force: true });
|
||||
await manager.acquireCurrent();
|
||||
await manager.acquireNew();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
@@ -96,7 +100,7 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
|
||||
const { manager, runtime } = createTestManager([4451]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
runtime.processForPort(4451).markKillSignalSent();
|
||||
|
||||
await manager.shutdown();
|
||||
@@ -104,13 +108,64 @@ describe("OpenCodeServerManager generations", () => {
|
||||
expect(runtime.terminatedPorts).toEqual([4451]);
|
||||
});
|
||||
|
||||
test("startup timeout kills the spawned server and removes its managed-process record", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { manager, runtime } = createTestManager([4471], { autoAnnounce: false });
|
||||
|
||||
const acquisition = manager.acquireCurrent();
|
||||
const failure = expect(acquisition).rejects.toThrow("OpenCode server startup timeout");
|
||||
await runtime.settle();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await failure;
|
||||
expect(runtime.terminatedPorts).toEqual([4471]);
|
||||
expect(await runtime.managedProcesses.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("shutdown kills a server that is still starting", async () => {
|
||||
const { manager, runtime } = createTestManager([4472], { autoAnnounce: false });
|
||||
|
||||
const acquisition = manager.acquireCurrent();
|
||||
await runtime.settle();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
await expect(acquisition).rejects.toThrow("OpenCode server exited with code null");
|
||||
expect(runtime.terminatedPorts).toEqual([4472]);
|
||||
expect(await runtime.managedProcesses.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("dedicated server startup is protected from retired cleanup", async () => {
|
||||
const { manager, runtime } = createTestManager([4473, 4474], { autoAnnounce: false });
|
||||
|
||||
const currentStart = manager.acquireCurrent();
|
||||
await runtime.settle();
|
||||
runtime.processForPort(4473).announceListening();
|
||||
const currentAcquisition = await currentStart;
|
||||
|
||||
const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" });
|
||||
await runtime.settle();
|
||||
|
||||
currentAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
|
||||
runtime.processForPort(4474).announceListening();
|
||||
const dedicatedAcquisition = await dedicatedStart;
|
||||
|
||||
expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474");
|
||||
|
||||
dedicatedAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4474]);
|
||||
});
|
||||
|
||||
test("repeated rotations leave zero unreferenced retired servers", async () => {
|
||||
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
|
||||
|
||||
const firstAcquisition = await manager.acquire({ force: false });
|
||||
const secondAcquisition = await manager.acquire({ force: true });
|
||||
const firstAcquisition = await manager.acquireCurrent();
|
||||
const secondAcquisition = await manager.acquireNew();
|
||||
secondAcquisition.release();
|
||||
const thirdAcquisition = await manager.acquire({ force: true });
|
||||
const thirdAcquisition = await manager.acquireNew();
|
||||
thirdAcquisition.release();
|
||||
firstAcquisition.release();
|
||||
|
||||
@@ -122,7 +177,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
test("records helper server starts and removes the record on process exit", async () => {
|
||||
const { manager, runtime } = createTestManager([4601]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
|
||||
expect(await runtime.managedProcesses.list()).toEqual([
|
||||
{
|
||||
@@ -146,7 +201,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
test("removes helper server records on shutdown", async () => {
|
||||
const { manager, runtime } = createTestManager([4602]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
@@ -155,11 +210,16 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createTestManager(ports: number[]): {
|
||||
function createTestManager(
|
||||
ports: number[],
|
||||
options: { autoAnnounce?: boolean } = {},
|
||||
): {
|
||||
manager: OpenCodeServerManager;
|
||||
runtime: FakeOpenCodeServerRuntime;
|
||||
} {
|
||||
const runtime = new FakeOpenCodeServerRuntime(ports);
|
||||
const runtime = new FakeOpenCodeServerRuntime(ports, {
|
||||
autoAnnounce: options.autoAnnounce ?? true,
|
||||
});
|
||||
return {
|
||||
manager: new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
@@ -177,11 +237,13 @@ class FakeOpenCodeServerRuntime {
|
||||
readonly managedProcesses = new FakeManagedProcesses();
|
||||
readonly terminatedPorts: number[] = [];
|
||||
private readonly ports: number[];
|
||||
private readonly autoAnnounce: boolean;
|
||||
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
|
||||
private readonly processesByPort = new Map<number, FakeOpenCodeProcess>();
|
||||
|
||||
constructor(ports: number[]) {
|
||||
constructor(ports: number[], options: { autoAnnounce: boolean }) {
|
||||
this.ports = [...ports];
|
||||
this.autoAnnounce = options.autoAnnounce;
|
||||
}
|
||||
|
||||
get launchedPorts(): number[] {
|
||||
@@ -206,7 +268,9 @@ class FakeOpenCodeServerRuntime {
|
||||
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
|
||||
this.processesByChild.set(process.child, process);
|
||||
this.processesByPort.set(port, process);
|
||||
queueMicrotask(() => process.announceListening());
|
||||
if (this.autoAnnounce) {
|
||||
queueMicrotask(() => process.announceListening());
|
||||
}
|
||||
return process.child;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
type OpencodeClientConfig,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
export interface OpenCodeServerAcquisition {
|
||||
server: { port: number; url: string };
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntime {
|
||||
acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition>;
|
||||
ensureServerRunning(): Promise<{ port: number; url: string }>;
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSdkOpenCodeClient(options: {
|
||||
baseUrl: string;
|
||||
directory: string;
|
||||
}): OpencodeClient {
|
||||
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
|
||||
}
|
||||
@@ -23,10 +23,10 @@ export interface OpenCodeServerAcquisition {
|
||||
|
||||
export interface OpenCodeServerManagerLike {
|
||||
ensureRunning(): Promise<{ port: number; url: string }>;
|
||||
acquire(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition>;
|
||||
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
|
||||
acquireNew(): Promise<OpenCodeServerAcquisition>;
|
||||
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OpenCodeServerGeneration {
|
||||
@@ -35,7 +35,9 @@ export interface OpenCodeServerGeneration {
|
||||
url: string;
|
||||
refCount: number;
|
||||
retired: boolean;
|
||||
ready: Promise<void>;
|
||||
managedProcessId?: string;
|
||||
managedProcessRecord?: Promise<{ id: string } | null>;
|
||||
}
|
||||
|
||||
export type OpenCodePortAllocator = () => Promise<number>;
|
||||
@@ -62,7 +64,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
private currentServer: OpenCodeServerGeneration | null = null;
|
||||
private retiredServers = new Set<OpenCodeServerGeneration>();
|
||||
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private newServerPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly runtimeSettingsKey: string;
|
||||
@@ -127,26 +129,35 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
const acquisition = await this.acquire({ force: false });
|
||||
const acquisition = await this.acquireCurrent();
|
||||
acquisition.release();
|
||||
return acquisition.server;
|
||||
}
|
||||
|
||||
async acquire(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
if (options.env) {
|
||||
const server = await this.startDedicatedServer(options.env);
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
const server = options.force
|
||||
? await this.getForcedRefreshServer()
|
||||
: await this.getCurrentServer();
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
const server = await this.getCurrentServer();
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
async acquireNew(): Promise<OpenCodeServerAcquisition> {
|
||||
const server = await this.getNewServer();
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
|
||||
const server = await this.startServer(env);
|
||||
server.retired = true;
|
||||
this.retiredServers.add(server);
|
||||
const acquisition = this.acquireServer(server);
|
||||
try {
|
||||
await server.ready;
|
||||
return acquisition;
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
|
||||
server.refCount += 1;
|
||||
let released = false;
|
||||
@@ -163,41 +174,57 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
};
|
||||
}
|
||||
|
||||
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.forcedRefreshPromise) {
|
||||
return this.forcedRefreshPromise;
|
||||
private async getNewServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.newServerPromise) {
|
||||
return this.newServerPromise;
|
||||
}
|
||||
|
||||
this.forcedRefreshPromise = Promise.resolve()
|
||||
this.newServerPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
await this.rotateCurrentServer();
|
||||
return this.getCurrentServer();
|
||||
const server = await this.startServer();
|
||||
if (!server.retired) {
|
||||
this.currentServer = server;
|
||||
}
|
||||
await server.ready;
|
||||
return server;
|
||||
})
|
||||
.finally(() => {
|
||||
this.forcedRefreshPromise = null;
|
||||
this.newServerPromise = null;
|
||||
});
|
||||
return this.forcedRefreshPromise;
|
||||
return this.newServerPromise;
|
||||
}
|
||||
|
||||
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.newServerPromise) {
|
||||
return this.newServerPromise;
|
||||
}
|
||||
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
const server = await this.startPromise;
|
||||
await server.ready;
|
||||
return server;
|
||||
}
|
||||
|
||||
if (this.currentServer && !this.currentServer.process.killed) {
|
||||
await this.currentServer.ready;
|
||||
return this.currentServer;
|
||||
}
|
||||
|
||||
this.startPromise = this.startServer();
|
||||
try {
|
||||
const result = await this.startPromise;
|
||||
if (!result.retired) {
|
||||
this.currentServer = result;
|
||||
this.startPromise = this.startServer().then((server) => {
|
||||
if (!server.retired) {
|
||||
this.currentServer = server;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
return server;
|
||||
});
|
||||
const currentStart = this.startPromise;
|
||||
const result = await currentStart.finally(() => {
|
||||
if (this.startPromise === currentStart) {
|
||||
this.startPromise = null;
|
||||
}
|
||||
});
|
||||
await result.ready;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async rotateCurrentServer(): Promise<void> {
|
||||
@@ -217,15 +244,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
}
|
||||
}
|
||||
|
||||
private async startDedicatedServer(
|
||||
env: Record<string, string>,
|
||||
): Promise<OpenCodeServerGeneration> {
|
||||
const server = await this.startServer(env);
|
||||
server.retired = true;
|
||||
this.retiredServers.add(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
private async startServer(launchEnv?: Record<string, string>): Promise<OpenCodeServerGeneration> {
|
||||
const port = await this.portAllocator();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
@@ -233,69 +251,86 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
const serverArgs = [...launchPrefix.args, "serve", "--port", String(port)];
|
||||
const serverCwd = os.homedir();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
|
||||
cwd: serverCwd,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
});
|
||||
const managedProcessRecord = this.recordManagedServerProcess({
|
||||
process: serverProcess,
|
||||
command: launchPrefix.command,
|
||||
args: serverArgs,
|
||||
port,
|
||||
});
|
||||
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
|
||||
cwd: serverCwd,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
});
|
||||
const managedProcessRecord = this.recordManagedServerProcess({
|
||||
process: serverProcess,
|
||||
command: launchPrefix.command,
|
||||
args: serverArgs,
|
||||
port,
|
||||
});
|
||||
const server: OpenCodeServerGeneration = {
|
||||
process: serverProcess,
|
||||
port,
|
||||
url,
|
||||
refCount: 0,
|
||||
retired: false,
|
||||
ready: Promise.resolve(),
|
||||
managedProcessRecord,
|
||||
};
|
||||
void managedProcessRecord.then((record) => {
|
||||
if (record && server.managedProcessRecord === managedProcessRecord) {
|
||||
server.managedProcessId = record.id;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
let started = false;
|
||||
let stderrBuffer = "";
|
||||
let stdoutBuffer = "";
|
||||
const STARTUP_BUFFER_CAP = 8192;
|
||||
const appendCapped = (current: string, chunk: string): string => {
|
||||
if (current.length >= STARTUP_BUFFER_CAP) {
|
||||
return current;
|
||||
let started = false;
|
||||
let settled = false;
|
||||
let stderrBuffer = "";
|
||||
let stdoutBuffer = "";
|
||||
const STARTUP_BUFFER_CAP = 8192;
|
||||
const appendCapped = (current: string, chunk: string): string => {
|
||||
if (current.length >= STARTUP_BUFFER_CAP) {
|
||||
return current;
|
||||
}
|
||||
const remaining = STARTUP_BUFFER_CAP - current.length;
|
||||
return current + chunk.slice(0, remaining);
|
||||
};
|
||||
const buildStartupErrorMessage = (headline: string): string => {
|
||||
const sections = [headline];
|
||||
const stderrTrimmed = stderrBuffer.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
sections.push(`stderr: ${stderrTrimmed}`);
|
||||
}
|
||||
const stdoutTrimmed = stdoutBuffer.trim();
|
||||
if (stdoutTrimmed.length > 0) {
|
||||
sections.push(`stdout: ${stdoutTrimmed}`);
|
||||
}
|
||||
return sections.join("\n");
|
||||
};
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const failStartup = (error: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const remaining = STARTUP_BUFFER_CAP - current.length;
|
||||
return current + chunk.slice(0, remaining);
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
const buildStartupErrorMessage = (headline: string): string => {
|
||||
const sections = [headline];
|
||||
const stderrTrimmed = stderrBuffer.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
sections.push(`stderr: ${stderrTrimmed}`);
|
||||
}
|
||||
const stdoutTrimmed = stdoutBuffer.trim();
|
||||
if (stdoutTrimmed.length > 0) {
|
||||
sections.push(`stdout: ${stdoutTrimmed}`);
|
||||
}
|
||||
return sections.join("\n");
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
timeout = setTimeout(() => {
|
||||
if (!started) {
|
||||
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
|
||||
failStartup(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutBuffer = appendCapped(stdoutBuffer, output);
|
||||
if (output.includes("listening on") && !started) {
|
||||
if (output.includes("listening on") && !settled) {
|
||||
started = true;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
void (async () => {
|
||||
const record = await managedProcessRecord;
|
||||
resolve({
|
||||
process: serverProcess,
|
||||
port,
|
||||
url,
|
||||
refCount: 0,
|
||||
retired: false,
|
||||
...(record ? { managedProcessId: record.id } : {}),
|
||||
});
|
||||
})();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -306,17 +341,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
});
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
|
||||
const headline = error instanceof Error ? error.message : String(error);
|
||||
reject(new Error(buildStartupErrorMessage(headline)));
|
||||
failStartup(new Error(buildStartupErrorMessage(headline)));
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
|
||||
this.removeManagedServerRecord(server);
|
||||
if (!started) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
|
||||
failStartup(
|
||||
new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)),
|
||||
);
|
||||
}
|
||||
if (this.currentServer?.process === serverProcess) {
|
||||
this.currentServer = null;
|
||||
@@ -328,6 +362,17 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.ready = ready.catch(async (error) => {
|
||||
await this.killServer(server);
|
||||
if (this.currentServer === server) {
|
||||
this.currentServer = null;
|
||||
}
|
||||
this.retiredServers.delete(server);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
@@ -375,6 +420,9 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
if (server.managedProcessId) {
|
||||
await this.removeManagedProcessId(server.managedProcessId);
|
||||
server.managedProcessId = undefined;
|
||||
server.managedProcessRecord = undefined;
|
||||
} else {
|
||||
this.removeManagedServerRecord(server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +463,19 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
});
|
||||
}
|
||||
|
||||
private removeManagedServerRecord(server: OpenCodeServerGeneration): void {
|
||||
const record = server.managedProcessRecord;
|
||||
server.managedProcessRecord = undefined;
|
||||
if (server.managedProcessId) {
|
||||
void this.removeManagedProcessId(server.managedProcessId);
|
||||
server.managedProcessId = undefined;
|
||||
return;
|
||||
}
|
||||
if (record) {
|
||||
this.removeManagedProcessRecordWhenResolved(record);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeManagedProcessId(id: string): Promise<void> {
|
||||
try {
|
||||
await this.managedProcesses?.remove(id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
|
||||
|
||||
export interface TestOpenCodeServerAcquisition {
|
||||
force: boolean;
|
||||
kind: "current" | "new" | "dedicated";
|
||||
env?: Record<string, string>;
|
||||
released: boolean;
|
||||
}
|
||||
@@ -16,14 +16,26 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
async acquire(options: {
|
||||
force: boolean;
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "current" });
|
||||
}
|
||||
|
||||
async acquireNew(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "new" });
|
||||
}
|
||||
|
||||
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "dedicated", env });
|
||||
}
|
||||
|
||||
private recordAcquisition(input: {
|
||||
kind: TestOpenCodeServerAcquisition["kind"];
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
}): OpenCodeServerAcquisition {
|
||||
const acquisition: TestOpenCodeServerAcquisition = {
|
||||
force: options.force,
|
||||
env: options.env,
|
||||
kind: input.kind,
|
||||
released: false,
|
||||
...(input.env ? { env: input.env } : {}),
|
||||
};
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
@@ -33,6 +45,8 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
|
||||
export function createTestOpenCodeServerManager(): TestOpenCodeServerManager {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
|
||||
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "../server-manager.js";
|
||||
|
||||
interface OpenCodeResponse {
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
||||
readonly acquisitions: Array<{
|
||||
force: boolean;
|
||||
kind: "current" | "new" | "dedicated";
|
||||
env?: Record<string, string>;
|
||||
releaseCount: number;
|
||||
}> = [];
|
||||
@@ -22,11 +22,27 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
this.clients.push(client);
|
||||
}
|
||||
|
||||
async acquireServer(options: {
|
||||
force: boolean;
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "current" });
|
||||
}
|
||||
|
||||
async acquireNew(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "new" });
|
||||
}
|
||||
|
||||
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "dedicated", env });
|
||||
}
|
||||
|
||||
private recordAcquisition(input: {
|
||||
kind: "current" | "new" | "dedicated";
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition = { force: options.force, env: options.env, releaseCount: 0 };
|
||||
}): OpenCodeServerAcquisition {
|
||||
const acquisition = {
|
||||
kind: input.kind,
|
||||
releaseCount: 0,
|
||||
...(input.env ? { env: input.env } : {}),
|
||||
};
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
@@ -36,15 +52,15 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
readonly createClient = (options: { baseUrl: string; directory: string }): OpencodeClient => {
|
||||
this.clientCreations.push(options);
|
||||
const client = this.clients.shift() ?? new TestOpenCodeClient();
|
||||
return client.asSdkClient();
|
||||
}
|
||||
};
|
||||
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
Reference in New Issue
Block a user