Add OpenCode build and plan modes

This commit is contained in:
Mohamed Boudra
2026-03-15 11:17:23 +07:00
parent aa6c521394
commit 6f34699e4b
3 changed files with 148 additions and 12 deletions
@@ -59,9 +59,14 @@ const CODEX_MODES: AgentMode[] = [
const OPENCODE_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard permission rules",
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
},
];
@@ -97,11 +102,11 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
label: "OpenCode",
description:
"Open-source coding assistant with multi-provider model support",
defaultModeId: "default",
defaultModeId: "build",
modes: OPENCODE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModeId: "build",
},
},
];
@@ -1,5 +1,12 @@
import { beforeAll, describe, expect, test, vi } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
@@ -202,4 +209,72 @@ const hasOpenCode = isBinaryInstalled("opencode");
60_000
);
test(
"available modes include build and plan",
async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession(buildConfig(cwd));
const modes = await session.getAvailableModes();
expect(modes.some((mode) => mode.id === "build")).toBe(true);
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
await session.close();
rmSync(cwd, { recursive: true, force: true });
},
60_000
);
test(
"plan mode blocks edits while build mode can write files",
async () => {
const cwd = tmpCwd();
const planFile = path.join(cwd, "plan-mode-output.txt");
const buildFile = path.join(cwd, "build-mode-output.txt");
const client = new OpenCodeAgentClient(logger);
const planSession = await client.createSession({
...buildConfig(cwd),
modeId: "plan",
});
const planTurn = await collectTurnEvents(
planSession.stream(
"Create a file named plan-mode-output.txt in the current directory containing exactly hello."
)
);
expect(planTurn.turnCompleted).toBe(true);
expect(planTurn.turnFailed).toBe(false);
expect(existsSync(planFile)).toBe(false);
const planResponse = planTurn.assistantMessages.map((message) => message.text).join("");
expect(planResponse.toLowerCase()).toContain("plan mode");
await planSession.close();
const buildSession = await client.createSession({
...buildConfig(cwd),
modeId: "build",
});
const buildTurn = await collectTurnEvents(
buildSession.stream(
"Create a file named build-mode-output.txt in the current directory containing exactly hello."
)
);
expect(buildTurn.turnCompleted).toBe(true);
expect(buildTurn.turnFailed).toBe(false);
expect(existsSync(buildFile)).toBe(true);
expect(readFileSync(buildFile, "utf8")).toContain("hello");
await buildSession.close();
rmSync(cwd, { recursive: true, force: true });
},
180_000
);
});
@@ -39,7 +39,7 @@ import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
@@ -47,12 +47,19 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const DEFAULT_MODES: AgentMode[] = [
{
id: "default",
label: "Default",
description: "Standard permission rules",
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
},
];
const OPENCODE_MODE_IDS = new Set(DEFAULT_MODES.map((mode) => mode.id));
type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
type OpenCodeMessageRole = "user" | "assistant";
@@ -227,6 +234,26 @@ function resolvePartDedupeKey(
return null;
}
function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
const trimmed = typeof modeId === "string" ? modeId.trim() : "";
if (!trimmed || trimmed === "default") {
return "build";
}
return trimmed;
}
function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] {
const order = new Map(DEFAULT_MODES.map((mode, index) => [mode.id, index]));
return [...modes].sort((left, right) => {
const leftOrder = order.get(left.id) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = order.get(right.id) ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.label.localeCompare(right.label);
});
}
export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false;
@@ -794,6 +821,7 @@ class OpenCodeAgentSession implements AgentSession {
private streamedPartKeys = new Set<string>();
/** Tracks assistant messages already emitted from structured payloads. */
private emittedStructuredMessageIds = new Set<string>();
private availableModesCache: AgentMode[] | null = null;
constructor(
config: OpenCodeAgentConfig,
@@ -803,6 +831,7 @@ class OpenCodeAgentSession implements AgentSession {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
}
get id(): string | null {
@@ -871,6 +900,7 @@ class OpenCodeAgentSession implements AgentSession {
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant =
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
// Send prompt asynchronously
const promptResponse = await this.client.session.promptAsync({
@@ -887,6 +917,7 @@ class OpenCodeAgentSession implements AgentSession {
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
@@ -1025,7 +1056,32 @@ class OpenCodeAgentSession implements AgentSession {
}
async getAvailableModes(): Promise<AgentMode[]> {
return DEFAULT_MODES;
if (this.availableModesCache) {
return this.availableModesCache;
}
const response = await this.client.app.agents({
directory: this.config.cwd,
});
const discoveredModes =
response.error || !response.data
? []
: response.data
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
.filter((agent) => OPENCODE_MODE_IDS.has(agent.name))
.map((agent) => ({
id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
description:
typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim()
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
}));
this.availableModesCache =
discoveredModes.length > 0 ? sortOpenCodeModes(discoveredModes) : DEFAULT_MODES;
return this.availableModesCache;
}
async getCurrentMode(): Promise<string | null> {
@@ -1033,7 +1089,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
this.currentMode = modeId;
this.currentMode = normalizeOpenCodeModeId(modeId);
}
getPendingPermissions(): AgentPermissionRequest[] {