Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
7 changes: 6 additions & 1 deletion src/local-agent-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "./local-agent-runtime.js";
import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js";
import { CodexLocalAgentDriver } from "./local-agent-codex.js";
import { OpencodeLocalAgentDriver } from "./local-agent-opencode.js";

export interface LocalAgentAdapter {
readonly provider: LocalAgentProvider;
Expand Down Expand Up @@ -55,7 +56,11 @@ export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgen

export function createLocalAgentDrivers(): LocalAgentDriver[] {
return LOCAL_AGENT_PROVIDERS.map((provider) =>
provider === "codex" ? new CodexLocalAgentDriver() : new LegacyLocalAgentDriver(provider));
provider === "codex"
? new CodexLocalAgentDriver()
: provider === "opencode"
? new OpencodeLocalAgentDriver()
: new LegacyLocalAgentDriver(provider));
}

class LegacyLocalAgentDriver implements LocalAgentDriver {
Expand Down
218 changes: 218 additions & 0 deletions src/local-agent-opencode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import {
opencodeAgentConfig,
OpencodeLocalAgentDriver,
opencodeAgentFor,
opencodePermissionFor,
type OpencodeClientLike,
type OpencodeFactory,
} from "./local-agent-opencode.js";
import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js";

let sessionNumber = 0;
const createInputs: unknown[] = [];
const promptInputs: unknown[] = [];
const switchInputs: unknown[] = [];
const agentInputs: unknown[] = [];
let healthAvailable = true;
const client = {
v2: {
session: {
async create(input: unknown) {
createInputs.push(input);
sessionNumber += 1;
return { data: { data: { id: `session_${sessionNumber}` } } };
},
async prompt(input: unknown) {
promptInputs.push(input);
return input;
},
async wait() {},
async messages(input: unknown) {
const sessionId = (input as { sessionID: string }).sessionID;
return {
data: { data: [{
info: { role: "assistant" },
parts: [{ type: "text", text: `response:${sessionId}` }],
}] },
};
},
async get() {
return { data: { data: { model: { providerID: "anthropic", id: "sonnet" } } } };
},
async switchAgent(input: unknown) { agentInputs.push(input); },
async switchModel(input: unknown) { switchInputs.push(input); },
},
health: { async get() {
if (!healthAvailable) throw new Error("server unavailable");
return { data: { healthy: true } };
} },
},
} as unknown as OpencodeClientLike;
let factoryCalls = 0;
let closeCalls = 0;
const factory: OpencodeFactory = async () => {
factoryCalls += 1;
return {
client,
server: { close: () => { closeCalls += 1; } },
};
};
const driver = new OpencodeLocalAgentDriver(factory);
const pool = new LocalAgentRuntimePool();

const first = await pool.run(driver, {
agentId: "agt_one",
provider: "opencode",
workspace: "/tmp/project",
}, {
prompt: "first",
workspace: "/tmp/project",
model: "anthropic/sonnet",
thinking: "high",
});
const second = await pool.run(driver, {
agentId: "agt_two",
provider: "opencode",
workspace: "/tmp/project",
}, {
prompt: "second",
workspace: "/tmp/project",
});

assert.equal(factoryCalls, 1, "OpenCode agents share one server runtime");
assert.equal(first.providerSessionId, "session_1");
assert.equal(second.providerSessionId, "session_2");
assert.equal(second.finalResponse, "response:session_2");
assert.deepEqual(createInputs[0], {
location: { directory: "/tmp/project" },
agent: "devspace_allowed",
model: { providerID: "anthropic", id: "sonnet", variant: "high" },
});
assert.deepEqual(promptInputs[0], {
sessionID: "session_1",
prompt: { text: "first" },
});

let callbackSessionId: string | undefined;
await pool.run(driver, {
agentId: "agt_one",
provider: "opencode",
workspace: "/tmp/project",
}, {
prompt: "thinking override",
workspace: "/tmp/project",
providerSessionId: first.providerSessionId ?? undefined,
thinking: "low",
}, {
onSessionId: (id) => { callbackSessionId = id; },
});
assert.equal(callbackSessionId, first.providerSessionId);
assert.deepEqual(switchInputs[0], {
sessionID: "session_1",
model: { providerID: "anthropic", id: "sonnet", variant: "low" },
});
assert.deepEqual(agentInputs[0], { sessionID: "session_1", agent: "devspace_allowed" });
assert.equal(opencodeAgentFor("read_only"), "devspace_read_only");
assert.equal(opencodeAgentFor("full_access"), "devspace_full_access");
assert.deepEqual(opencodePermissionFor("allowed"), {
read: "allow",
edit: "allow",
glob: "allow",
grep: "allow",
list: "allow",
bash: "allow",
task: "deny",
external_directory: "deny",
});
const readOnlyPermissions = opencodePermissionFor("read_only");
assert.equal(typeof readOnlyPermissions === "object" ? readOnlyPermissions.bash : undefined, "deny");
for (const writeMode of ["read_only", "allowed", "full_access"] as const) {
const config = opencodeAgentConfig(writeMode);
assert.equal(config.mode, "primary");
assert.equal(typeof config.permission === "object" ? config.permission.task : undefined, "deny");
}

let promptFailureCount = 0;
const applicationErrorClient = {
v2: {
session: {
async create() { return { data: { data: { id: "session_app_error" } } }; },
async switchAgent() {},
async prompt() {
promptFailureCount += 1;
if (promptFailureCount === 1) throw new Error("server rejected invalid input");
return {};
},
async wait() {},
async messages() {
return { data: { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "ok" }] }] } };
},
},
health: { async get() { return { data: { healthy: true } }; } },
},
} as unknown as OpencodeClientLike;
const applicationErrorPool = new LocalAgentRuntimePool();
const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({
client: applicationErrorClient,
server: { close: () => undefined },
}));
await assert.rejects(
applicationErrorPool.run(applicationErrorDriver, {
agentId: "agt_app_error",
provider: "opencode",
workspace: "/tmp/project",
}, { prompt: "bad input", workspace: "/tmp/project" }),
/server rejected invalid input/,
);
assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime");
const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, {
agentId: "agt_app_error",
provider: "opencode",
workspace: "/tmp/project",
}, { prompt: "valid input", workspace: "/tmp/project" });
assert.equal(recoveredApplicationTurn.finalResponse, "ok");
await applicationErrorPool.close();

let recoveringFactoryCalls = 0;
const recoveringDriver = new OpencodeLocalAgentDriver(async () => {
recoveringFactoryCalls += 1;
healthAvailable = true;
return { client, server: { close: () => undefined } };
});
const recoveringPool = new LocalAgentRuntimePool();
await recoveringPool.run(recoveringDriver, {
agentId: "agt_dead",
provider: "opencode",
workspace: "/tmp/project",
}, {
prompt: "initial",
workspace: "/tmp/project",
});
healthAvailable = false;
await assert.rejects(
recoveringPool.run(recoveringDriver, {
agentId: "agt_dead",
provider: "opencode",
workspace: "/tmp/project",
}, {
prompt: "dead runtime",
workspace: "/tmp/project",
}),
/health check failed/,
);
assert.equal(recoveringPool.size, 0, "a failed health check removes the dead runtime immediately");
await recoveringPool.run(recoveringDriver, {
agentId: "agt_dead",
provider: "opencode",
workspace: "/tmp/project",
}, {
prompt: "recreated",
workspace: "/tmp/project",
});
assert.equal(recoveringFactoryCalls, 2, "the next turn creates a fresh OpenCode server");
await recoveringPool.close();

await pool.close();
await pool.close();
assert.equal(closeCalls, 1, "shared OpenCode server closes once");
Loading
Loading