Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-24 14:42:26 +07:00
19 changed files with 589 additions and 302 deletions
+4
View File
@@ -26,6 +26,8 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory. OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path. OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
@@ -42,6 +44,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. 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. 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.
--- ---
+1 -1
View File
@@ -1 +1 @@
sha256-+rWSD0ZcNJlY5JO3Pb3LslBMu9al3rN1I6i0k5bG1d8= sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=
@@ -12,6 +12,7 @@ const mockState = vi.hoisted(() => {
interface ConstructorEntry { interface ConstructorEntry {
runtimeSettings?: unknown; runtimeSettings?: unknown;
providerParams?: unknown; providerParams?: unknown;
commandsRpcType?: unknown;
} }
return { return {
@@ -214,12 +215,20 @@ vi.mock("./providers/pi/agent.js", () => ({
readonly provider = "pi"; readonly provider = "pi";
readonly runtimeSettings?: unknown; readonly runtimeSettings?: unknown;
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) { constructor(options: {
runtimeSettings?: unknown;
providerParams?: unknown;
commandsRpcType?: unknown;
}) {
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
mockState.constructorArgs.pi.push({ const entry: ConstructorEntry = {
runtimeSettings: options.runtimeSettings, runtimeSettings: options.runtimeSettings,
providerParams: options.providerParams, providerParams: options.providerParams,
}); };
if (options.commandsRpcType !== undefined) {
entry.commandsRpcType = options.commandsRpcType;
}
mockState.constructorArgs.pi.push(entry);
} }
async createSession(): Promise<never> { async createSession(): Promise<never> {
@@ -458,6 +467,7 @@ test("OMP is a disabled built-in backed by the Pi adapter", () => {
providerParams: { providerParams: {
sessionDir: "~/.omp/agent/sessions", sessionDir: "~/.omp/agent/sessions",
}, },
commandsRpcType: "get_available_commands",
}); });
}); });
@@ -488,11 +498,10 @@ test("new provider extending claude appears in registry", () => {
expect(registry.zai.createClient(logger).provider).toBe("zai"); expect(registry.zai.createClient(logger).provider).toBe("zai");
}); });
test("new provider extending pi passes params to the base provider constructor", () => { test("built-in OMP override passes params to the Pi adapter constructor", () => {
const registry = buildProviderRegistry(logger, { const registry = buildProviderRegistry(logger, {
providerOverrides: { providerOverrides: {
omp: { omp: {
extends: "pi",
label: "OMP", label: "OMP",
command: ["omp"], command: ["omp"],
params: { params: {
@@ -515,6 +524,7 @@ test("new provider extending pi passes params to the base provider constructor",
providerParams: { providerParams: {
sessionDir: "~/.omp/agent/sessions", sessionDir: "~/.omp/agent/sessions",
}, },
commandsRpcType: "get_available_commands",
}); });
}); });
@@ -156,6 +156,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
providerParams: options?.providerParams ?? { providerParams: options?.providerParams ?? {
sessionDir: "~/.omp/agent/sessions", sessionDir: "~/.omp/agent/sessions",
}, },
commandsRpcType: "get_available_commands",
}), }),
mock: (logger) => new MockLoadTestAgentClient(logger), mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(), "mock-slow": () => new MockSlowProviderClient(),
@@ -6,8 +6,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import { import {
idleEvent, idleEvent,
TestOpenCodeClient, TestOpenCodeClient,
TestOpenCodeRuntime, TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-runtime.js"; } from "./opencode/test-utils/test-opencode-harness.js";
interface MockOpenCodeClientOptions { interface MockOpenCodeClientOptions {
agents?: unknown[]; agents?: unknown[];
@@ -15,7 +15,7 @@ interface MockOpenCodeClientOptions {
} }
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) { function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = { data: options.agents ?? [] }; openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()]; 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 }); const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]); 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 () => { test("falls back to default OpenCode modes when discovery returns no modes", async () => {
const { runtime } = mockOpenCodeClient({ agents: [] }); 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 }); const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]); 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 () => { test("lists auto accept as a provider feature", async () => {
const { runtime } = mockOpenCodeClient(); 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({ const enabledFeatures = await client.listFeatures({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", 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 () => { test("keeps legacy full-access as an alias for build plus auto accept", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient(); 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({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -251,7 +263,10 @@ describe("OpenCode auto_accept feature", () => {
}); });
const receivedEvents: AgentStreamEvent[] = []; 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({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -279,7 +294,10 @@ describe("OpenCode auto_accept feature", () => {
}); });
const receivedEvents: AgentStreamEvent[] = []; 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({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -322,7 +340,10 @@ describe("OpenCode auto_accept feature", () => {
}); });
const receivedEvents: AgentStreamEvent[] = []; 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({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -4,8 +4,8 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js";
import { import {
TestOpenCodeClient, TestOpenCodeClient,
TestOpenCodeRuntime, TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-runtime.js"; } from "./opencode/test-utils/test-opencode-harness.js";
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
@@ -14,7 +14,7 @@ afterEach(() => {
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListImplementation = () => openCodeClient.providerListImplementation = () =>
new Promise((resolve) => { 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); 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 }); const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000); 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); expect(openCodeClient.calls.providerList).toHaveLength(1);
}); });
test("passes explicit refresh force through server acquisition", async () => { test("uses a new server for explicit catalog refresh", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = { openCodeClient.providerListResponse = {
data: { data: {
@@ -68,17 +71,20 @@ test("passes explicit refresh force through server acquisition", async () => {
}; };
runtime.enqueueClient(openCodeClient); 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 }); 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 () => { test("includes models from api-source providers not in connected", async () => {
// Providers with source "api" are managed by the OpenCode console/subscription. // Providers with source "api" are managed by the OpenCode console/subscription.
// They don't appear in `connected` but are fully usable. // They don't appear in `connected` but are fully usable.
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = { openCodeClient.providerListResponse = {
data: { data: {
@@ -100,7 +106,10 @@ test("includes models from api-source providers not in connected", async () => {
}; };
runtime.enqueueClient(openCodeClient); 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 }); const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
expect(models).toMatchObject([ 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 () => { 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(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = { openCodeClient.providerListResponse = {
data: { data: {
@@ -132,7 +141,10 @@ test("throws when no providers are accessible (neither connected nor api-source)
}; };
runtime.enqueueClient(openCodeClient); 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( await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
"OpenCode has no connected providers", "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 () => { 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(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = { openCodeClient.providerListResponse = {
data: { data: {
@@ -159,7 +171,10 @@ test("does not throw when only api-source providers are present with no connecte
}; };
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await expect( await expect(
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }), client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }),
@@ -5,11 +5,11 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import { import {
idleEvent, idleEvent,
TestOpenCodeClient, TestOpenCodeClient,
TestOpenCodeRuntime, TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-runtime.js"; } from "./opencode/test-utils/test-opencode-harness.js";
function mockOpenCodeClient(events: unknown[]) { function mockOpenCodeClient(events: unknown[]) {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionPromptAsyncEvents = events; openCodeClient.sessionPromptAsyncEvents = events;
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
@@ -35,7 +35,10 @@ function toolPermissionEvent(): unknown {
describe("OpenCode permission actions", () => { describe("OpenCode permission actions", () => {
test("allow always sends OpenCode's always reply", async () => { test("allow always sends OpenCode's always reply", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]); 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({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -63,7 +66,10 @@ describe("OpenCode permission actions", () => {
test("plain allow keeps the backward-compatible once reply", async () => { test("plain allow keeps the backward-compatible once reply", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]); 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({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -5,8 +5,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
import { import {
idleEvent, idleEvent,
TestOpenCodeClient, TestOpenCodeClient,
TestOpenCodeRuntime, TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-runtime.js"; } from "./opencode/test-utils/test-opencode-harness.js";
function createDeferred<T>(): { function createDeferred<T>(): {
promise: Promise<T>; promise: Promise<T>;
@@ -24,11 +24,14 @@ function createDeferred<T>(): {
describe("OpenCodeAgentSession slash command timeout handling", () => { describe("OpenCodeAgentSession slash command timeout handling", () => {
test("lists only OpenCode built-in slash commands Paseo can execute", async () => { test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider(); const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient); 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 session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.listCommands?.()).resolves.toEqual( 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 () => { test("executes compact through the OpenCode summarize endpoint", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider(); const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient); 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 session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.run("/compact")).resolves.toMatchObject({ 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 () => { test("waits for SSE completion when slash commands hit a header timeout", async () => {
const idleEventGate = createDeferred<void>(); const idleEventGate = createDeferred<void>();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider(); const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error"); openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
openCodeClient.commandListResponse = { openCodeClient.commandListResponse = {
@@ -85,7 +91,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
})(); })();
runtime.enqueueClient(openCodeClient); 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 session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help"); 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 () => { test("leaves successful slash command turns open until OpenCode emits idle", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = createOpenCodeClientWithConnectedProvider(); const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandEvents = []; openCodeClient.sessionCommandEvents = [];
openCodeClient.commandListResponse = { openCodeClient.commandListResponse = {
@@ -109,7 +118,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
}; };
runtime.enqueueClient(openCodeClient); 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 session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help"); const runPromise = session.run("/help");
@@ -13,8 +13,8 @@ import {
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
import { import {
TestOpenCodeClient, TestOpenCodeClient,
TestOpenCodeRuntime, TestOpenCodeHarness,
} from "./opencode/test-utils/test-opencode-runtime.js"; } from "./opencode/test-utils/test-opencode-harness.js";
import type { import type {
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
@@ -182,9 +182,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("creates a session with valid id and provider", async () => { test("creates a session with valid id and provider", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient()); 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 session = await client.createSession(buildConfig(cwd));
expect(typeof session.id).toBe("string"); expect(typeof session.id).toBe("string");
@@ -197,11 +200,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("single turn completes with streaming deltas", async () => { test("single turn completes with streaming deltas", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents(); openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
runtime.enqueueClient(openCodeClient); 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 session = await client.createSession(buildConfig(cwd));
const iterator = streamSession(session, "Say hello"); const iterator = streamSession(session, "Say hello");
@@ -230,11 +236,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("manual compact hides the generated summary text", async () => { test("manual compact hides the generated summary text", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.sessionSummarizeEvents = manualCompactEvents(); openCodeClient.sessionSummarizeEvents = manualCompactEvents();
runtime.enqueueClient(openCodeClient); 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({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd, cwd,
@@ -264,7 +273,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 120_000); }, 120_000);
test("fetchCatalog returns models with required fields", async () => { test("fetchCatalog returns models with required fields", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = { openCodeClient.providerListResponse = {
data: { data: {
@@ -296,7 +305,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
], ],
}; };
runtime.enqueueClient(openCodeClient); 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 cwd = os.homedir();
const catalog = await client.fetchCatalog({ cwd, force: false }); const catalog = await client.fetchCatalog({ cwd, force: false });
@@ -331,7 +343,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 60_000); }, 60_000);
test("limits concurrent OpenCode metadata requests across clients", async () => { test("limits concurrent OpenCode metadata requests across clients", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
let activeProviderListCalls = 0; let activeProviderListCalls = 0;
let maxActiveProviderListCalls = 0; let maxActiveProviderListCalls = 0;
const response = { const response = {
@@ -364,7 +376,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
} }
const client = new OpenCodeAgentClient(logger, undefined, { runtime }); const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
await Promise.all( await Promise.all(
Array.from({ length: 12 }, (_, index) => Array.from({ length: 12 }, (_, index) =>
client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }), 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 () => { test("available modes include build and plan", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient()); 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 session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes(); 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 () => { test("custom agents defined in opencode.json appear in available modes", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = { openCodeClient.appAgentsResponse = {
data: [ data: [
@@ -408,7 +426,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}; };
runtime.enqueueClient(openCodeClient); 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 session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes(); 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 () => { test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const planOpenCodeClient = new TestOpenCodeClient(); const planOpenCodeClient = new TestOpenCodeClient();
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" }); planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
const buildOpenCodeClient = new TestOpenCodeClient(); const buildOpenCodeClient = new TestOpenCodeClient();
@@ -468,7 +489,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
]; ];
runtime.enqueueClient(planOpenCodeClient); runtime.enqueueClient(planOpenCodeClient);
runtime.enqueueClient(buildOpenCodeClient); 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({ const planSession = await client.createSession({
...buildConfig(cwd), ...buildConfig(cwd),
@@ -861,11 +885,14 @@ describe("OpenCode adapter context-window normalization", () => {
describe("OpenCode adapter startTurn error handling", () => { describe("OpenCode adapter startTurn error handling", () => {
test("dynamically adds injected MCP servers without config-backed connect", async () => { test("dynamically adds injected MCP servers without config-backed connect", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try { try {
const session = await client.createSession({ 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 () => { 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(); const openCodeClient = new TestOpenCodeClient();
openCodeClient.mcpAddResponse = { openCodeClient.mcpAddResponse = {
data: { data: {
@@ -913,7 +940,10 @@ describe("OpenCode adapter startTurn error handling", () => {
}; };
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try { try {
const session = await client.createSession({ const session = await client.createSession({
@@ -1665,11 +1695,14 @@ describe("OpenCode adapter startTurn error handling", () => {
describe("OpenCodeAgentClient env", () => { describe("OpenCodeAgentClient env", () => {
test("passes launch-context env to env-specific server acquisition", async () => { test("passes launch-context env to env-specific server acquisition", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient); runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
try { try {
const session = await client.createSession( const session = await client.createSession(
@@ -1686,7 +1719,7 @@ describe("OpenCodeAgentClient env", () => {
await session.close(); await session.close();
expect(runtime.acquisitions[0]).toMatchObject({ expect(runtime.acquisitions[0]).toMatchObject({
force: false, kind: "dedicated",
env: { env: {
CHUNK14_PROBE: "expected", CHUNK14_PROBE: "expected",
}, },
@@ -1848,7 +1881,7 @@ describe("OpenCode persisted sessions", () => {
}); });
test("listImportableSessions returns rows without hydrating session messages", async () => { test("listImportableSessions returns rows without hydrating session messages", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
const cwd = "/workspace/repo"; const cwd = "/workspace/repo";
const otherCwd = "/workspace/other"; const otherCwd = "/workspace/other";
@@ -1945,7 +1978,10 @@ describe("OpenCode persisted sessions", () => {
}; };
runtime.enqueueClient(openCodeClient); 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 }); const sessions = await client.listImportableSessions({ cwd, limit: 1 });
expect(sessions).toHaveLength(1); expect(sessions).toHaveLength(1);
@@ -1965,7 +2001,7 @@ describe("OpenCode persisted sessions", () => {
}); });
test("importSession reads only the selected OpenCode session without listing", async () => { test("importSession reads only the selected OpenCode session without listing", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const metadataClient = new TestOpenCodeClient(); const metadataClient = new TestOpenCodeClient();
const resumedClient = new TestOpenCodeClient(); const resumedClient = new TestOpenCodeClient();
const cwd = "/workspace/repo"; const cwd = "/workspace/repo";
@@ -2004,7 +2040,10 @@ describe("OpenCode persisted sessions", () => {
runtime.enqueueClient(metadataClient); runtime.enqueueClient(metadataClient);
runtime.enqueueClient(resumedClient); 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( const imported = await client.importSession(
{ providerHandleId: "ses_selected", cwd }, { providerHandleId: "ses_selected", cwd },
{ {
@@ -2041,7 +2080,7 @@ describe("OpenCode persisted sessions", () => {
}); });
test("listImportableSessions matches Windows cwd paths with forward slashes", async () => { test("listImportableSessions matches Windows cwd paths with forward slashes", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
const requestedCwd = "C:/Users/Administrator/GhostFactory"; const requestedCwd = "C:/Users/Administrator/GhostFactory";
const storedCwd = "C:\\Users\\Administrator\\GhostFactory"; const storedCwd = "C:\\Users\\Administrator\\GhostFactory";
@@ -2064,7 +2103,10 @@ describe("OpenCode persisted sessions", () => {
}; };
runtime.enqueueClient(openCodeClient); 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 }); const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 });
expect(sessions).toHaveLength(1); expect(sessions).toHaveLength(1);
@@ -1,10 +1,12 @@
import { import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage, type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent, type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput, type FilePartInput as OpenCodeFilePartInput,
type GlobalSession as OpenCodeGlobalSession, type GlobalSession as OpenCodeGlobalSession,
type Message as OpenCodeMessage, type Message as OpenCodeMessage,
type OpencodeClient, type OpencodeClient,
type OpencodeClientConfig,
type Part as OpenCodePart, type Part as OpenCodePart,
type Session as OpenCodeSession, type Session as OpenCodeSession,
type TextPartInput as OpenCodeTextPartInput, type TextPartInput as OpenCodeTextPartInput,
@@ -64,7 +66,10 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js"; import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; 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 { import {
formatProviderDiagnostic, formatProviderDiagnostic,
formatProviderDiagnosticError, formatProviderDiagnosticError,
@@ -75,11 +80,6 @@ import {
import { runProviderTurn } from "./provider-runner.js"; import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.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 { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js"; import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js"; import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
@@ -1212,31 +1212,15 @@ export const __openCodeInternals = {
}; };
interface OpenCodeAgentClientDeps { interface OpenCodeAgentClientDeps {
runtime?: OpenCodeRuntime; serverManager?: OpenCodeServerManagerLike;
createClient?: OpenCodeClientFactory;
managedProcesses?: ManagedProcessRegistry; managedProcesses?: ManagedProcessRegistry;
} }
class ProductionOpenCodeRuntime implements OpenCodeRuntime { type OpenCodeClientFactory = (options: { baseUrl: string; directory: string }) => OpencodeClient;
constructor(private readonly serverManager: OpenCodeServerManager) {}
async acquireServer(options: { function createSdkOpenCodeClient(options: { baseUrl: string; directory: string }): OpencodeClient {
force: boolean; return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
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();
}
} }
export class OpenCodeAgentClient implements AgentClient { export class OpenCodeAgentClient implements AgentClient {
@@ -1245,7 +1229,8 @@ export class OpenCodeAgentClient implements AgentClient {
readonly resolveCreateConfig = resolveOpenCodeCreateConfig; readonly resolveCreateConfig = resolveOpenCodeCreateConfig;
readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended; readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended;
private readonly runtime: OpenCodeRuntime; private readonly serverManager: OpenCodeServerManagerLike;
private readonly createOpenCodeClient: OpenCodeClientFactory;
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>(); 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.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings; this.runtimeSettings = runtimeSettings;
this.runtime = this.serverManager =
deps.runtime ?? deps.serverManager ??
new ProductionOpenCodeRuntime( OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, { managedProcesses: deps.managedProcesses,
managedProcesses: deps.managedProcesses, });
}), this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
);
} }
async createSession( async createSession(
@@ -1272,12 +1256,11 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions, options?: AgentCreateSessionOptions,
): Promise<AgentSession> { ): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config); const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ const acquisition = launchContext?.env
force: false, ? await this.serverManager.acquireDedicated(launchContext.env)
env: launchContext?.env, : await this.serverManager.acquireCurrent();
});
const { url } = acquisition.server; const { url } = acquisition.server;
const client = this.runtime.createClient({ const client = this.createOpenCodeClient({
baseUrl: url, baseUrl: url,
directory: openCodeConfig.cwd, directory: openCodeConfig.cwd,
}); });
@@ -1334,9 +1317,9 @@ export class OpenCodeAgentClient implements AgentClient {
cwd, cwd,
}; };
const openCodeConfig = this.assertConfig(config); const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false }); const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server; const { url } = acquisition.server;
const client = this.runtime.createClient({ const client = this.createOpenCodeClient({
baseUrl: url, baseUrl: url,
directory: openCodeConfig.cwd, directory: openCodeConfig.cwd,
}); });
@@ -1361,10 +1344,12 @@ export class OpenCodeAgentClient implements AgentClient {
} }
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { 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 { url } = acquisition.server;
const directory = options.cwd; const directory = options.cwd;
const client = this.runtime.createClient({ baseUrl: url, directory }); const client = this.createOpenCodeClient({ baseUrl: url, directory });
try { try {
const [models, modes] = await Promise.all([ const [models, modes] = await Promise.all([
@@ -1379,9 +1364,9 @@ export class OpenCodeAgentClient implements AgentClient {
async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> { async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
const openCodeConfig = this.assertConfig(config); const openCodeConfig = this.assertConfig(config);
const acquisition = await this.runtime.acquireServer({ force: false }); const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server; const { url } = acquisition.server;
const client = this.runtime.createClient({ const client = this.createOpenCodeClient({
baseUrl: url, baseUrl: url,
directory: openCodeConfig.cwd, directory: openCodeConfig.cwd,
}); });
@@ -1400,9 +1385,9 @@ export class OpenCodeAgentClient implements AgentClient {
async listImportableSessions( async listImportableSessions(
options?: ListImportableSessionsOptions, options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> { ): Promise<ImportableProviderSession[]> {
const acquisition = await this.runtime.acquireServer({ force: false }); const acquisition = await this.serverManager.acquireCurrent();
const { url } = acquisition.server; const { url } = acquisition.server;
const client = this.runtime.createClient({ const client = this.createOpenCodeClient({
baseUrl: url, baseUrl: url,
directory: options?.cwd ?? "", directory: options?.cwd ?? "",
}); });
@@ -1415,9 +1400,9 @@ export class OpenCodeAgentClient implements AgentClient {
} }
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { 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 { url } = acquisition.server;
const client = this.runtime.createClient({ const client = this.createOpenCodeClient({
baseUrl: url, baseUrl: url,
directory: input.cwd, directory: input.cwd,
}); });
@@ -1460,7 +1445,7 @@ export class OpenCodeAgentClient implements AgentClient {
} }
async shutdown(): Promise<void> { async shutdown(): Promise<void> {
await this.runtime.shutdown(); await this.serverManager.shutdown();
} }
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events"; 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 { createTestLogger } from "../../../test-utils/test-logger.js";
import type { import type {
@@ -17,12 +17,16 @@ import {
type OpenCodeServerProcessSpawner, type OpenCodeServerProcessSpawner,
} from "./opencode/server-manager.js"; } from "./opencode/server-manager.js";
afterEach(() => {
vi.useRealTimers();
});
describe("OpenCodeServerManager generations", () => { describe("OpenCodeServerManager generations", () => {
test("rotation creates a new current server without killing a referenced old server", async () => { test("rotation creates a new current server without killing a referenced old server", async () => {
const { manager, runtime } = createTestManager([4101, 4102]); const { manager, runtime } = createTestManager([4101, 4102]);
const oldAcquisition = await manager.acquire({ force: false }); const oldAcquisition = await manager.acquireCurrent();
const newAcquisition = await manager.acquire({ force: true }); const newAcquisition = await manager.acquireNew();
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101"); expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102"); 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 () => { test("new acquisitions after rotation use the new server", async () => {
const { manager, runtime } = createTestManager([4201, 4202]); const { manager, runtime } = createTestManager([4201, 4202]);
const oldAcquisition = await manager.acquire({ force: false }); const oldAcquisition = await manager.acquireCurrent();
const rotatedAcquisition = await manager.acquire({ force: true }); const rotatedAcquisition = await manager.acquireNew();
rotatedAcquisition.release(); 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(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
expect(runtime.terminatedPorts).toEqual([]); expect(runtime.terminatedPorts).toEqual([]);
@@ -50,15 +54,15 @@ describe("OpenCodeServerManager generations", () => {
oldAcquisition.release(); 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 { manager, runtime } = createTestManager([4251, 4252, 4253]);
const initialAcquisition = await manager.acquire({ force: false }); const initialAcquisition = await manager.acquireCurrent();
initialAcquisition.release(); initialAcquisition.release();
const [modelsAcquisition, modesAcquisition] = await Promise.all([ const [modelsAcquisition, modesAcquisition] = await Promise.all([
manager.acquire({ force: true }), manager.acquireNew(),
manager.acquire({ force: true }), manager.acquireNew(),
]); ]);
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252"); expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
@@ -72,8 +76,8 @@ describe("OpenCodeServerManager generations", () => {
test("release is idempotent", async () => { test("release is idempotent", async () => {
const { manager, runtime } = createTestManager([4301, 4302]); const { manager, runtime } = createTestManager([4301, 4302]);
const oldAcquisition = await manager.acquire({ force: false }); const oldAcquisition = await manager.acquireCurrent();
const newAcquisition = await manager.acquire({ force: true }); const newAcquisition = await manager.acquireNew();
newAcquisition.release(); newAcquisition.release();
oldAcquisition.release(); oldAcquisition.release();
@@ -85,8 +89,8 @@ describe("OpenCodeServerManager generations", () => {
test("shutdown kills current and retired servers", async () => { test("shutdown kills current and retired servers", async () => {
const { manager, runtime } = createTestManager([4401, 4402]); const { manager, runtime } = createTestManager([4401, 4402]);
await manager.acquire({ force: false }); await manager.acquireCurrent();
await manager.acquire({ force: true }); await manager.acquireNew();
await manager.shutdown(); 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 () => { test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
const { manager, runtime } = createTestManager([4451]); const { manager, runtime } = createTestManager([4451]);
await manager.acquire({ force: false }); await manager.acquireCurrent();
runtime.processForPort(4451).markKillSignalSent(); runtime.processForPort(4451).markKillSignalSent();
await manager.shutdown(); await manager.shutdown();
@@ -104,13 +108,64 @@ describe("OpenCodeServerManager generations", () => {
expect(runtime.terminatedPorts).toEqual([4451]); 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 () => { test("repeated rotations leave zero unreferenced retired servers", async () => {
const { manager, runtime } = createTestManager([4501, 4502, 4503]); const { manager, runtime } = createTestManager([4501, 4502, 4503]);
const firstAcquisition = await manager.acquire({ force: false }); const firstAcquisition = await manager.acquireCurrent();
const secondAcquisition = await manager.acquire({ force: true }); const secondAcquisition = await manager.acquireNew();
secondAcquisition.release(); secondAcquisition.release();
const thirdAcquisition = await manager.acquire({ force: true }); const thirdAcquisition = await manager.acquireNew();
thirdAcquisition.release(); thirdAcquisition.release();
firstAcquisition.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 () => { test("records helper server starts and removes the record on process exit", async () => {
const { manager, runtime } = createTestManager([4601]); const { manager, runtime } = createTestManager([4601]);
await manager.acquire({ force: false }); await manager.acquireCurrent();
expect(await runtime.managedProcesses.list()).toEqual([ expect(await runtime.managedProcesses.list()).toEqual([
{ {
@@ -146,7 +201,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
test("removes helper server records on shutdown", async () => { test("removes helper server records on shutdown", async () => {
const { manager, runtime } = createTestManager([4602]); const { manager, runtime } = createTestManager([4602]);
await manager.acquire({ force: false }); await manager.acquireCurrent();
await manager.shutdown(); 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; manager: OpenCodeServerManager;
runtime: FakeOpenCodeServerRuntime; runtime: FakeOpenCodeServerRuntime;
} { } {
const runtime = new FakeOpenCodeServerRuntime(ports); const runtime = new FakeOpenCodeServerRuntime(ports, {
autoAnnounce: options.autoAnnounce ?? true,
});
return { return {
manager: new OpenCodeServerManager({ manager: new OpenCodeServerManager({
logger: createTestLogger(), logger: createTestLogger(),
@@ -177,11 +237,13 @@ class FakeOpenCodeServerRuntime {
readonly managedProcesses = new FakeManagedProcesses(); readonly managedProcesses = new FakeManagedProcesses();
readonly terminatedPorts: number[] = []; readonly terminatedPorts: number[] = [];
private readonly ports: number[]; private readonly ports: number[];
private readonly autoAnnounce: boolean;
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>(); private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
private readonly processesByPort = new Map<number, FakeOpenCodeProcess>(); private readonly processesByPort = new Map<number, FakeOpenCodeProcess>();
constructor(ports: number[]) { constructor(ports: number[], options: { autoAnnounce: boolean }) {
this.ports = [...ports]; this.ports = [...ports];
this.autoAnnounce = options.autoAnnounce;
} }
get launchedPorts(): number[] { get launchedPorts(): number[] {
@@ -206,7 +268,9 @@ class FakeOpenCodeServerRuntime {
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port }); const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
this.processesByChild.set(process.child, process); this.processesByChild.set(process.child, process);
this.processesByPort.set(port, process); this.processesByPort.set(port, process);
queueMicrotask(() => process.announceListening()); if (this.autoAnnounce) {
queueMicrotask(() => process.announceListening());
}
return process.child; 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 { export interface OpenCodeServerManagerLike {
ensureRunning(): Promise<{ port: number; url: string }>; ensureRunning(): Promise<{ port: number; url: string }>;
acquire(options: { acquireCurrent(): Promise<OpenCodeServerAcquisition>;
force: boolean; acquireNew(): Promise<OpenCodeServerAcquisition>;
env?: Record<string, string>; acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
}): Promise<OpenCodeServerAcquisition>; shutdown(): Promise<void>;
} }
export interface OpenCodeServerGeneration { export interface OpenCodeServerGeneration {
@@ -35,7 +35,9 @@ export interface OpenCodeServerGeneration {
url: string; url: string;
refCount: number; refCount: number;
retired: boolean; retired: boolean;
ready: Promise<void>;
managedProcessId?: string; managedProcessId?: string;
managedProcessRecord?: Promise<{ id: string } | null>;
} }
export type OpenCodePortAllocator = () => Promise<number>; export type OpenCodePortAllocator = () => Promise<number>;
@@ -62,7 +64,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
private currentServer: OpenCodeServerGeneration | null = null; private currentServer: OpenCodeServerGeneration | null = null;
private retiredServers = new Set<OpenCodeServerGeneration>(); private retiredServers = new Set<OpenCodeServerGeneration>();
private startPromise: Promise<OpenCodeServerGeneration> | null = null; private startPromise: Promise<OpenCodeServerGeneration> | null = null;
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null; private newServerPromise: Promise<OpenCodeServerGeneration> | null = null;
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly runtimeSettingsKey: string; private readonly runtimeSettingsKey: string;
@@ -127,26 +129,35 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
} }
async ensureRunning(): Promise<{ port: number; url: string }> { async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquire({ force: false }); const acquisition = await this.acquireCurrent();
acquisition.release(); acquisition.release();
return acquisition.server; return acquisition.server;
} }
async acquire(options: { async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
force: boolean; const server = await this.getCurrentServer();
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();
return this.acquireServer(server); 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 { private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
server.refCount += 1; server.refCount += 1;
let released = false; let released = false;
@@ -163,41 +174,57 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}; };
} }
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> { private async getNewServer(): Promise<OpenCodeServerGeneration> {
if (this.forcedRefreshPromise) { if (this.newServerPromise) {
return this.forcedRefreshPromise; return this.newServerPromise;
} }
this.forcedRefreshPromise = Promise.resolve() this.newServerPromise = Promise.resolve()
.then(async () => { .then(async () => {
await this.rotateCurrentServer(); await this.rotateCurrentServer();
return this.getCurrentServer(); const server = await this.startServer();
if (!server.retired) {
this.currentServer = server;
}
await server.ready;
return server;
}) })
.finally(() => { .finally(() => {
this.forcedRefreshPromise = null; this.newServerPromise = null;
}); });
return this.forcedRefreshPromise; return this.newServerPromise;
} }
private async getCurrentServer(): Promise<OpenCodeServerGeneration> { private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
if (this.newServerPromise) {
return this.newServerPromise;
}
if (this.startPromise) { if (this.startPromise) {
return this.startPromise; const server = await this.startPromise;
await server.ready;
return server;
} }
if (this.currentServer && !this.currentServer.process.killed) { if (this.currentServer && !this.currentServer.process.killed) {
await this.currentServer.ready;
return this.currentServer; return this.currentServer;
} }
this.startPromise = this.startServer(); this.startPromise = this.startServer().then((server) => {
try { if (!server.retired) {
const result = await this.startPromise; this.currentServer = server;
if (!result.retired) {
this.currentServer = result;
} }
return result; return server;
} finally { });
this.startPromise = null; 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> { 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> { private async startServer(launchEnv?: Record<string, string>): Promise<OpenCodeServerGeneration> {
const port = await this.portAllocator(); const port = await this.portAllocator();
const url = `http://127.0.0.1:${port}`; 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 serverArgs = [...launchPrefix.args, "serve", "--port", String(port)];
const serverCwd = os.homedir(); const serverCwd = os.homedir();
return new Promise((resolve, reject) => { const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, { cwd: serverCwd,
cwd: serverCwd, detached: process.platform !== "win32",
detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe"], ...createProviderEnvSpec({
...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings,
runtimeSettings: this.runtimeSettings, overlays: [launchEnv],
overlays: [launchEnv], }),
}), });
}); const managedProcessRecord = this.recordManagedServerProcess({
const managedProcessRecord = this.recordManagedServerProcess({ process: serverProcess,
process: serverProcess, command: launchPrefix.command,
command: launchPrefix.command, args: serverArgs,
args: serverArgs, port,
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 started = false;
let stderrBuffer = ""; let settled = false;
let stdoutBuffer = ""; let stderrBuffer = "";
const STARTUP_BUFFER_CAP = 8192; let stdoutBuffer = "";
const appendCapped = (current: string, chunk: string): string => { const STARTUP_BUFFER_CAP = 8192;
if (current.length >= STARTUP_BUFFER_CAP) { const appendCapped = (current: string, chunk: string): string => {
return current; 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; settled = true;
return current + chunk.slice(0, remaining); clearTimeout(timeout);
reject(error);
}; };
const buildStartupErrorMessage = (headline: string): string => { timeout = setTimeout(() => {
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(() => {
if (!started) { if (!started) {
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout"))); failStartup(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
} }
}, 30_000); }, 30_000);
serverProcess.stdout?.on("data", (data: Buffer) => { serverProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString(); const output = data.toString();
stdoutBuffer = appendCapped(stdoutBuffer, output); stdoutBuffer = appendCapped(stdoutBuffer, output);
if (output.includes("listening on") && !started) { if (output.includes("listening on") && !settled) {
started = true; started = true;
settled = true;
clearTimeout(timeout); clearTimeout(timeout);
void (async () => { resolve();
const record = await managedProcessRecord;
resolve({
process: serverProcess,
port,
url,
refCount: 0,
retired: false,
...(record ? { managedProcessId: record.id } : {}),
});
})();
} }
}); });
@@ -306,17 +341,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}); });
serverProcess.on("error", (error) => { serverProcess.on("error", (error) => {
clearTimeout(timeout);
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
const headline = error instanceof Error ? error.message : String(error); const headline = error instanceof Error ? error.message : String(error);
reject(new Error(buildStartupErrorMessage(headline))); failStartup(new Error(buildStartupErrorMessage(headline)));
}); });
serverProcess.on("exit", (code) => { serverProcess.on("exit", (code) => {
this.removeManagedProcessRecordWhenResolved(managedProcessRecord); this.removeManagedServerRecord(server);
if (!started) { if (!started) {
clearTimeout(timeout); failStartup(
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`))); new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)),
);
} }
if (this.currentServer?.process === serverProcess) { if (this.currentServer?.process === serverProcess) {
this.currentServer = null; 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> { async shutdown(): Promise<void> {
@@ -375,6 +420,9 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
if (server.managedProcessId) { if (server.managedProcessId) {
await this.removeManagedProcessId(server.managedProcessId); await this.removeManagedProcessId(server.managedProcessId);
server.managedProcessId = undefined; 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> { private async removeManagedProcessId(id: string): Promise<void> {
try { try {
await this.managedProcesses?.remove(id); await this.managedProcesses?.remove(id);
@@ -1,7 +1,7 @@
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js"; import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
export interface TestOpenCodeServerAcquisition { export interface TestOpenCodeServerAcquisition {
force: boolean; kind: "current" | "new" | "dedicated";
env?: Record<string, string>; env?: Record<string, string>;
released: boolean; released: boolean;
} }
@@ -16,14 +16,26 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
return this.server; return this.server;
} }
async acquire(options: { async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
force: boolean; 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>; env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> { }): OpenCodeServerAcquisition {
const acquisition: TestOpenCodeServerAcquisition = { const acquisition: TestOpenCodeServerAcquisition = {
force: options.force, kind: input.kind,
env: options.env,
released: false, released: false,
...(input.env ? { env: input.env } : {}),
}; };
this.acquisitions.push(acquisition); this.acquisitions.push(acquisition);
return { return {
@@ -33,6 +45,8 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
}, },
}; };
} }
async shutdown(): Promise<void> {}
} }
export function createTestOpenCodeServerManager(): TestOpenCodeServerManager { export function createTestOpenCodeServerManager(): TestOpenCodeServerManager {
@@ -1,15 +1,15 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; 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 { interface OpenCodeResponse {
data?: unknown; data?: unknown;
error?: unknown; error?: unknown;
} }
export class TestOpenCodeRuntime implements OpenCodeRuntime { export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
readonly acquisitions: Array<{ readonly acquisitions: Array<{
force: boolean; kind: "current" | "new" | "dedicated";
env?: Record<string, string>; env?: Record<string, string>;
releaseCount: number; releaseCount: number;
}> = []; }> = [];
@@ -22,11 +22,27 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
this.clients.push(client); this.clients.push(client);
} }
async acquireServer(options: { async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
force: boolean; 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>; env?: Record<string, string>;
}): Promise<OpenCodeServerAcquisition> { }): OpenCodeServerAcquisition {
const acquisition = { force: options.force, env: options.env, releaseCount: 0 }; const acquisition = {
kind: input.kind,
releaseCount: 0,
...(input.env ? { env: input.env } : {}),
};
this.acquisitions.push(acquisition); this.acquisitions.push(acquisition);
return { return {
server: this.server, 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; return this.server;
} }
createClient(options: { baseUrl: string; directory: string }): OpencodeClient { readonly createClient = (options: { baseUrl: string; directory: string }): OpencodeClient => {
this.clientCreations.push(options); this.clientCreations.push(options);
const client = this.clients.shift() ?? new TestOpenCodeClient(); const client = this.clients.shift() ?? new TestOpenCodeClient();
return client.asSdkClient(); return client.asSdkClient();
} };
async shutdown(): Promise<void> {} async shutdown(): Promise<void> {}
} }
@@ -62,6 +62,7 @@ import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
import type { import type {
PiAgentSessionEvent, PiAgentSessionEvent,
PiAgentMessage, PiAgentMessage,
PiCommandsRpcType,
PiImageContent, PiImageContent,
PiModel, PiModel,
PiRpcSlashCommand, PiRpcSlashCommand,
@@ -154,6 +155,7 @@ interface PiRpcAgentClientOptions {
logger: Logger; logger: Logger;
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
providerParams?: unknown; providerParams?: unknown;
commandsRpcType?: PiCommandsRpcType;
runtime?: PiRuntime; runtime?: PiRuntime;
} }
@@ -967,8 +969,12 @@ function mapPiModel(model: PiModel): AgentModelDefinition {
}; };
} }
function createRuntime(logger: Logger, runtimeSettings?: ProviderRuntimeSettings): PiRuntime { function createRuntime(
return new PiCliRuntime({ logger, runtimeSettings }); logger: Logger,
runtimeSettings?: ProviderRuntimeSettings,
commandsRpcType?: PiCommandsRpcType,
): PiRuntime {
return new PiCliRuntime({ logger, runtimeSettings, commandsRpcType });
} }
export class PiRpcAgentSession implements AgentSession { export class PiRpcAgentSession implements AgentSession {
@@ -1866,7 +1872,9 @@ export class PiRpcAgentClient implements AgentClient {
this.logger = options.logger; this.logger = options.logger;
this.runtimeSettings = options.runtimeSettings; this.runtimeSettings = options.runtimeSettings;
this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {}); this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {});
this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings); this.runtime =
options.runtime ??
createRuntime(options.logger, options.runtimeSettings, options.commandsRpcType);
} }
async createSession( async createSession(
@@ -5,6 +5,7 @@ import pino from "pino";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js"; import { PiCliRuntime } from "./cli-runtime.js";
import type { PiCommandsRpcType } from "./rpc-types.js";
import type { PiRuntimeLaunch } from "./runtime.js"; import type { PiRuntimeLaunch } from "./runtime.js";
type PiChild = ChildProcessWithoutNullStreams & { type PiChild = ChildProcessWithoutNullStreams & {
@@ -31,10 +32,15 @@ function createPiChild(): PiChild {
return child; return child;
} }
function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime { function createRuntime(
child: PiChild,
launches: PiRuntimeLaunch[] = [],
options?: { commandsRpcType?: PiCommandsRpcType },
): PiCliRuntime {
return new PiCliRuntime({ return new PiCliRuntime({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
command: ["pi"], command: ["pi"],
commandsRpcType: options?.commandsRpcType,
spawnProcess: (launch) => { spawnProcess: (launch) => {
launches.push(launch); launches.push(launch);
return child; return child;
@@ -184,6 +190,42 @@ describe("PiCliRuntime", () => {
expect(events).toEqual([{ type: "turn_start" }]); expect(events).toEqual([{ type: "turn_start" }]);
}); });
test("lists commands through the default Pi get_commands RPC", async () => {
const child = createPiChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [{ name: "review", description: "Review changes", source: "extension" }],
};
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{ name: "review", description: "Review changes", source: "extension" },
]);
expect(commandTypes).toEqual(["get_commands"]);
});
test("lists commands through the OMP-compatible get_available_commands RPC", async () => {
const child = createPiChild();
const commandTypes: string[] = [];
replyToCommands(child, (command) => {
commandTypes.push(String(command.type));
return {
commands: [{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" }],
};
});
const session = await createRuntime(child, [], {
commandsRpcType: "get_available_commands",
}).startSession({ cwd: "/workspace/project" });
await expect(session.getCommands()).resolves.toEqual([
{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" },
]);
expect(commandTypes).toEqual(["get_available_commands"]);
});
test("keeps unicode line separators inside one JSONL record", async () => { test("keeps unicode line separators inside one JSONL record", async () => {
const child = createPiChild(); const child = createPiChild();
replyToCommands(child, () => ({})); replyToCommands(child, () => ({}));
@@ -13,6 +13,7 @@ import {
} from "./runtime.js"; } from "./runtime.js";
import type { import type {
PiAgentMessage, PiAgentMessage,
PiCommandsRpcType,
PiModel, PiModel,
PiRpcCommand, PiRpcCommand,
PiRpcResponse, PiRpcResponse,
@@ -26,6 +27,7 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi", process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
]; ];
const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_COMMANDS_RPC_TYPE: PiCommandsRpcType = "get_commands";
const STDERR_BUFFER_LIMIT = 8192; const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -48,15 +50,18 @@ export interface PiCliRuntimeOptions {
logger: Logger; logger: Logger;
runtimeSettings?: ProviderRuntimeSettings; runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]]; command?: [string, ...string[]];
commandsRpcType?: PiCommandsRpcType;
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams; spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
} }
export class PiCliRuntime implements PiRuntime { export class PiCliRuntime implements PiRuntime {
private readonly command: [string, ...string[]]; private readonly command: [string, ...string[]];
private readonly commandsRpcType: PiCommandsRpcType;
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams; private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: PiCliRuntimeOptions) { constructor(private readonly options: PiCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_PI_COMMAND; this.command = options.command ?? DEFAULT_PI_COMMAND;
this.commandsRpcType = options.commandsRpcType ?? DEFAULT_COMMANDS_RPC_TYPE;
this.spawnProcess = this.spawnProcess =
options.spawnProcess ?? options.spawnProcess ??
((launch) => { ((launch) => {
@@ -77,7 +82,12 @@ export class PiCliRuntime implements PiRuntime {
runtimeSettings: this.options.runtimeSettings, runtimeSettings: this.options.runtimeSettings,
session: input, session: input,
}); });
return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger); return new PiCliRuntimeSession(
launch,
this.spawnProcess(launch),
this.options.logger,
this.commandsRpcType,
);
} }
} }
@@ -93,6 +103,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
_launch: PiRuntimeLaunch, _launch: PiRuntimeLaunch,
private readonly child: ChildProcessWithoutNullStreams, private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger, private readonly logger: Logger,
private readonly commandsRpcType: PiCommandsRpcType,
) { ) {
child.stdout.on("data", (chunk) => { child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString()); this.handleStdoutChunk(chunk.toString());
@@ -173,7 +184,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
} }
async getCommands(): Promise<PiRpcSlashCommand[]> { async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: "get_commands" })) as { const data = (await this.request({ type: this.commandsRpcType })) as {
commands?: PiRpcSlashCommand[]; commands?: PiRpcSlashCommand[];
}; };
return data.commands ?? []; return data.commands ?? [];
@@ -110,6 +110,8 @@ export interface PiRpcSlashCommand {
sourceInfo?: Record<string, unknown>; sourceInfo?: Record<string, unknown>;
} }
export type PiCommandsRpcType = "get_commands" | "get_available_commands";
export type PiRpcCommand = export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] } | { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "compact"; customInstructions?: string } | { id?: string; type: "compact"; customInstructions?: string }
@@ -121,7 +123,7 @@ export type PiRpcCommand =
| { id?: string; type: "set_model"; provider: string; modelId: string } | { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel } | { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
| { id?: string; type: "get_session_stats" } | { id?: string; type: "get_session_stats" }
| { id?: string; type: "get_commands" }; | { id?: string; type: PiCommandsRpcType };
export interface PiRpcResponse { export interface PiRpcResponse {
id?: string; id?: string;