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