mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(server): inject opencode runtime (#868)
This commit is contained in:
@@ -13,7 +13,6 @@ const mockState = vi.hoisted(() => {
|
||||
claude: [] as ConstructorEntry[],
|
||||
codex: [] as ConstructorEntry[],
|
||||
copilot: [] as ConstructorEntry[],
|
||||
opencode: [] as ConstructorEntry[],
|
||||
pi: [] as ConstructorEntry[],
|
||||
genericAcp: [] as Array<{
|
||||
command: string[];
|
||||
@@ -26,7 +25,6 @@ const mockState = vi.hoisted(() => {
|
||||
this.constructorArgs.claude = [];
|
||||
this.constructorArgs.codex = [];
|
||||
this.constructorArgs.copilot = [];
|
||||
this.constructorArgs.opencode = [];
|
||||
this.constructorArgs.pi = [];
|
||||
this.constructorArgs.genericAcp = [];
|
||||
this.isCommandAvailable.mockReset();
|
||||
@@ -188,54 +186,6 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/opencode-agent.js", () => ({
|
||||
OpenCodeAgentClient: class OpenCodeAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "opencode";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(_logger: unknown, runtimeSettings?: unknown) {
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
mockState.constructorArgs.opencode.push({ runtimeSettings });
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/opencode/server-manager.js", () => ({
|
||||
OpenCodeServerManager: {
|
||||
getInstance: vi.fn(() => ({
|
||||
shutdown: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/pi-direct-agent.js", () => ({
|
||||
PiDirectAgentClient: class PiDirectAgentClient {
|
||||
readonly capabilities = {
|
||||
|
||||
@@ -1,73 +1,28 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
|
||||
import {
|
||||
createEventStream,
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
|
||||
interface MockOpenCodeClientOptions {
|
||||
agents?: unknown[];
|
||||
events?: unknown[];
|
||||
}
|
||||
|
||||
function createEventStream(events: unknown[]): AsyncGenerator<never> {
|
||||
return (async function* () {
|
||||
for (const event of events) {
|
||||
yield event as never;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
|
||||
const promptAsync = vi.fn().mockResolvedValue({});
|
||||
const permissionReply = vi.fn().mockResolvedValue({});
|
||||
const questionReply = vi.fn().mockResolvedValue({});
|
||||
const questionReject = vi.fn().mockResolvedValue({});
|
||||
const appAgents = vi.fn().mockResolvedValue({ data: options.agents ?? [] });
|
||||
const events = options.events ?? [idleEvent()];
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
|
||||
openCodeClient.eventStream = createEventStream(options.events ?? [idleEvent()]);
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
promptAsync,
|
||||
abort: vi.fn().mockResolvedValue({}),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({ data: { connected: [], all: [] } }),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({ stream: createEventStream(events) }),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: appAgents,
|
||||
},
|
||||
permission: {
|
||||
reply: permissionReply,
|
||||
},
|
||||
question: {
|
||||
reply: questionReply,
|
||||
reject: questionReject,
|
||||
},
|
||||
} as never);
|
||||
|
||||
return { appAgents, permissionReply, promptAsync, questionReject, questionReply };
|
||||
}
|
||||
|
||||
function idleEvent(): unknown {
|
||||
return {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
return { openCodeClient, runtime };
|
||||
}
|
||||
|
||||
function toolPermissionEvent(): unknown {
|
||||
@@ -108,22 +63,15 @@ function questionEvent(): unknown {
|
||||
}
|
||||
|
||||
describe("OpenCode full-access mode", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
|
||||
const serverManager = createTestOpenCodeServerManager();
|
||||
mockOpenCodeClient({
|
||||
const { runtime } = mockOpenCodeClient({
|
||||
agents: [
|
||||
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
|
||||
{ name: "paseo-custom", mode: "primary", hidden: false, description: "Custom agent" },
|
||||
],
|
||||
});
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager,
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]);
|
||||
@@ -134,12 +82,9 @@ describe("OpenCode full-access mode", () => {
|
||||
});
|
||||
|
||||
test("reports full-access but sends prompts through OpenCode build agent", async () => {
|
||||
const serverManager = createTestOpenCodeServerManager();
|
||||
const { promptAsync } = mockOpenCodeClient();
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager,
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -150,22 +95,21 @@ describe("OpenCode full-access mode", () => {
|
||||
|
||||
await session.run("Implement the change");
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ agent: "build" }));
|
||||
expect(openCodeClient.calls.sessionPromptAsync).toHaveLength(1);
|
||||
expect(openCodeClient.calls.sessionPromptAsync[0]).toEqual(
|
||||
expect.objectContaining({ agent: "build" }),
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("auto-approves tool permissions in full-access without surfacing them", async () => {
|
||||
const serverManager = createTestOpenCodeServerManager();
|
||||
const { permissionReply } = mockOpenCodeClient({
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||
events: [toolPermissionEvent(), idleEvent()],
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager,
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -175,8 +119,8 @@ describe("OpenCode full-access mode", () => {
|
||||
|
||||
await session.run("Run verification");
|
||||
|
||||
expect(permissionReply).toHaveBeenCalledTimes(1);
|
||||
expect(permissionReply).toHaveBeenCalledWith({
|
||||
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
|
||||
expect(openCodeClient.calls.permissionReply[0]).toEqual({
|
||||
requestID: "permission-1",
|
||||
directory: "/tmp/project",
|
||||
reply: "once",
|
||||
@@ -188,15 +132,12 @@ describe("OpenCode full-access mode", () => {
|
||||
});
|
||||
|
||||
test("keeps questions separate from full-access tool auto-approval", async () => {
|
||||
const serverManager = createTestOpenCodeServerManager();
|
||||
const { permissionReply, questionReply } = mockOpenCodeClient({
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||
events: [questionEvent(), idleEvent()],
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager,
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -221,13 +162,13 @@ describe("OpenCode full-access mode", () => {
|
||||
updatedInput: { answers: { Decision: "Proceed" } },
|
||||
});
|
||||
|
||||
expect(questionReply).toHaveBeenCalledTimes(1);
|
||||
expect(questionReply).toHaveBeenCalledWith({
|
||||
expect(openCodeClient.calls.questionReply).toHaveLength(1);
|
||||
expect(openCodeClient.calls.questionReply[0]).toEqual({
|
||||
requestID: "question-1",
|
||||
directory: "/tmp/project",
|
||||
answers: [["Proceed"]],
|
||||
});
|
||||
expect(permissionReply).not.toHaveBeenCalled();
|
||||
expect(openCodeClient.calls.permissionReply).toEqual([]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
|
||||
await session.close();
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
async function providerList(): Promise<unknown> {
|
||||
return new Promise((resolve) => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListImplementation = () =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: {
|
||||
@@ -40,18 +38,9 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
}
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: providerList,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const serverManager = createTestOpenCodeServerManager();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager,
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
@@ -63,26 +52,23 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
label: "GLM 5.1",
|
||||
},
|
||||
]);
|
||||
expect(openCodeClient.calls.providerList).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("passes explicit refresh force through server acquisition", async () => {
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: async () => ({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
} as never);
|
||||
const serverManager = createTestOpenCodeServerManager();
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager,
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
|
||||
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
||||
|
||||
expect(serverManager.acquisitions).toEqual([{ force: true, released: true }]);
|
||||
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -25,34 +22,12 @@ function createDeferred<T>(): {
|
||||
}
|
||||
|
||||
describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager: createTestOpenCodeServerManager(),
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.listCommands?.()).resolves.toEqual(
|
||||
@@ -68,44 +43,11 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("executes compact through the OpenCode summarize endpoint", async () => {
|
||||
const command = vi.fn();
|
||||
const summarize = vi.fn().mockResolvedValue({ data: {} });
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
command,
|
||||
summarize,
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager: createTestOpenCodeServerManager(),
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||
@@ -114,50 +56,30 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
timeline: [],
|
||||
usage: undefined,
|
||||
});
|
||||
expect(summarize).toHaveBeenCalledWith({ sessionID: "session-1", directory: "/tmp" });
|
||||
expect(command).not.toHaveBeenCalled();
|
||||
expect(openCodeClient.calls.sessionSummarize).toEqual([
|
||||
{ sessionID: "session-1", directory: "/tmp" },
|
||||
]);
|
||||
expect(openCodeClient.calls.sessionCommand).toEqual([]);
|
||||
});
|
||||
|
||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||
const idleEventGate = createDeferred<void>();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
|
||||
openCodeClient.commandListResponse = {
|
||||
data: [{ name: "help", description: "Show help", hints: [] }],
|
||||
};
|
||||
openCodeClient.eventStream = (async function* () {
|
||||
await idleEventGate.promise;
|
||||
yield {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
})();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
session: {
|
||||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||
command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
|
||||
},
|
||||
provider: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
event: {
|
||||
subscribe: vi.fn().mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
await idleEventGate.promise;
|
||||
yield {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
})(),
|
||||
}),
|
||||
},
|
||||
command: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: [{ name: "help", description: "Show help", hints: [] }],
|
||||
}),
|
||||
},
|
||||
app: {
|
||||
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
|
||||
serverManager: createTestOpenCodeServerManager(),
|
||||
});
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
@@ -172,3 +94,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createOpenCodeClientWithConnectedProvider(): TestOpenCodeClient {
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
connected: ["openai"],
|
||||
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||
},
|
||||
};
|
||||
return openCodeClient;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { readdir, readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
type Event as OpenCodeEvent,
|
||||
type FilePartInput as OpenCodeFilePartInput,
|
||||
@@ -47,10 +46,7 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import {
|
||||
OpenCodeServerManager,
|
||||
type OpenCodeServerManagerLike,
|
||||
} from "./opencode/server-manager.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
@@ -60,6 +56,11 @@ import {
|
||||
} from "./diagnostic-utils.js";
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import {
|
||||
createSdkOpenCodeClient,
|
||||
type OpenCodeRuntime,
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
|
||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -898,14 +899,34 @@ export const __openCodeInternals = {
|
||||
};
|
||||
|
||||
interface OpenCodeAgentClientDeps {
|
||||
serverManager?: OpenCodeServerManagerLike;
|
||||
runtime?: OpenCodeRuntime;
|
||||
}
|
||||
|
||||
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
|
||||
constructor(private readonly serverManager: OpenCodeServerManager) {}
|
||||
|
||||
async acquireServer(options: { force: boolean }): 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 {
|
||||
readonly provider = "opencode" as const;
|
||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||
|
||||
private readonly serverManager: OpenCodeServerManagerLike;
|
||||
private readonly runtime: OpenCodeRuntime;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
@@ -920,8 +941,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
|
||||
this.serverManager =
|
||||
deps.serverManager ?? OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new ProductionOpenCodeRuntime(
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings),
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -930,9 +954,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.serverManager.acquire({ force: false });
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const { url } = acquisition.server;
|
||||
const client = createOpencodeClient({
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -986,9 +1010,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
...overrides,
|
||||
};
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.serverManager.acquire({ force: false });
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const { url } = acquisition.server;
|
||||
const client = createOpencodeClient({
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1011,9 +1035,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
const { url } = acquisition.server;
|
||||
const client = createOpencodeClient({
|
||||
const client = this.runtime.createClient({
|
||||
baseUrl: url,
|
||||
directory: options.cwd,
|
||||
});
|
||||
@@ -1074,10 +1098,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
const { url } = acquisition.server;
|
||||
const directory = options.cwd;
|
||||
const client = createOpencodeClient({ baseUrl: url, directory });
|
||||
const client = this.runtime.createClient({ baseUrl: url, directory });
|
||||
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
@@ -1128,7 +1152,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
try {
|
||||
const { url } = await this.serverManager.ensureRunning();
|
||||
const { url } = await this.runtime.ensureServerRunning();
|
||||
serverStatus = `Running (${url})`;
|
||||
} catch (error) {
|
||||
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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 }): 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 });
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
|
||||
|
||||
interface OpenCodeResponse {
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = [];
|
||||
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
|
||||
private readonly clients: TestOpenCodeClient[] = [];
|
||||
|
||||
server = { port: 1234, url: "http://127.0.0.1:1234" };
|
||||
|
||||
enqueueClient(client: TestOpenCodeClient): void {
|
||||
this.clients.push(client);
|
||||
}
|
||||
|
||||
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition = { force: options.force, releaseCount: 0 };
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
release: () => {
|
||||
acquisition.releaseCount += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
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> {}
|
||||
}
|
||||
|
||||
export class TestOpenCodeClient {
|
||||
readonly calls = {
|
||||
appAgents: [] as unknown[],
|
||||
commandList: [] as unknown[],
|
||||
eventSubscribe: [] as unknown[],
|
||||
permissionReply: [] as unknown[],
|
||||
providerList: [] as unknown[],
|
||||
questionReject: [] as unknown[],
|
||||
questionReply: [] as unknown[],
|
||||
sessionAbort: [] as unknown[],
|
||||
sessionCommand: [] as unknown[],
|
||||
sessionCreate: [] as unknown[],
|
||||
sessionDelete: [] as unknown[],
|
||||
sessionMessages: [] as unknown[],
|
||||
sessionPromptAsync: [] as unknown[],
|
||||
sessionSummarize: [] as unknown[],
|
||||
sessionUpdate: [] as unknown[],
|
||||
};
|
||||
|
||||
appAgentsResponse: OpenCodeResponse = { data: [] };
|
||||
commandListResponse: OpenCodeResponse = { data: [] };
|
||||
eventStream: AsyncIterable<unknown> = createEventStream([idleEvent()]);
|
||||
permissionReplyResponse: OpenCodeResponse = {};
|
||||
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
|
||||
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
|
||||
questionRejectResponse: OpenCodeResponse = {};
|
||||
questionReplyResponse: OpenCodeResponse = {};
|
||||
sessionAbortResponse: OpenCodeResponse = {};
|
||||
sessionCommandError: unknown = null;
|
||||
sessionCommandResponse: OpenCodeResponse = {};
|
||||
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
|
||||
sessionDeleteResponse: OpenCodeResponse = {};
|
||||
sessionMessagesResponse: OpenCodeResponse = { data: [] };
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
|
||||
sessionUpdateResponse: OpenCodeResponse = {};
|
||||
|
||||
asSdkClient(): OpencodeClient {
|
||||
return {
|
||||
app: {
|
||||
agents: async (parameters: unknown) => {
|
||||
this.calls.appAgents.push(parameters);
|
||||
return this.appAgentsResponse;
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list: async (parameters: unknown) => {
|
||||
this.calls.commandList.push(parameters);
|
||||
return this.commandListResponse;
|
||||
},
|
||||
},
|
||||
event: {
|
||||
subscribe: async (parameters: unknown, options: unknown) => {
|
||||
this.calls.eventSubscribe.push({ parameters, options });
|
||||
return { stream: this.eventStream };
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
add: async () => ({}),
|
||||
connect: async () => ({}),
|
||||
},
|
||||
permission: {
|
||||
reply: async (parameters: unknown) => {
|
||||
this.calls.permissionReply.push(parameters);
|
||||
return this.permissionReplyResponse;
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list: async (parameters: unknown) => {
|
||||
this.calls.providerList.push(parameters);
|
||||
return this.providerListImplementation
|
||||
? await this.providerListImplementation()
|
||||
: this.providerListResponse;
|
||||
},
|
||||
},
|
||||
question: {
|
||||
reject: async (parameters: unknown) => {
|
||||
this.calls.questionReject.push(parameters);
|
||||
return this.questionRejectResponse;
|
||||
},
|
||||
reply: async (parameters: unknown) => {
|
||||
this.calls.questionReply.push(parameters);
|
||||
return this.questionReplyResponse;
|
||||
},
|
||||
},
|
||||
session: {
|
||||
abort: async (parameters: unknown) => {
|
||||
this.calls.sessionAbort.push(parameters);
|
||||
return this.sessionAbortResponse;
|
||||
},
|
||||
command: async (parameters: unknown) => {
|
||||
this.calls.sessionCommand.push(parameters);
|
||||
if (this.sessionCommandError) {
|
||||
throw this.sessionCommandError;
|
||||
}
|
||||
return this.sessionCommandResponse;
|
||||
},
|
||||
create: async (parameters: unknown) => {
|
||||
this.calls.sessionCreate.push(parameters);
|
||||
return this.sessionCreateResponse;
|
||||
},
|
||||
delete: async (parameters: unknown) => {
|
||||
this.calls.sessionDelete.push(parameters);
|
||||
return this.sessionDeleteResponse;
|
||||
},
|
||||
messages: async (parameters: unknown) => {
|
||||
this.calls.sessionMessages.push(parameters);
|
||||
return this.sessionMessagesResponse;
|
||||
},
|
||||
promptAsync: async (parameters: unknown) => {
|
||||
this.calls.sessionPromptAsync.push(parameters);
|
||||
return this.sessionPromptAsyncResponse;
|
||||
},
|
||||
summarize: async (parameters: unknown) => {
|
||||
this.calls.sessionSummarize.push(parameters);
|
||||
return this.sessionSummarizeResponse;
|
||||
},
|
||||
update: async (parameters: unknown) => {
|
||||
this.calls.sessionUpdate.push(parameters);
|
||||
return this.sessionUpdateResponse;
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
}
|
||||
}
|
||||
|
||||
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
|
||||
return (async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function idleEvent(): unknown {
|
||||
return {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user