diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index f2ac24dc7..48add004f 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -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", }, }, ]; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index deb43ff6a..5d59c804b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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 + ); + }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 7d8035ae0..38abb6399 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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(); /** Tracks assistant messages already emitted from structured payloads. */ private emittedStructuredMessageIds = new Set(); + 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 { - 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 { @@ -1033,7 +1089,7 @@ class OpenCodeAgentSession implements AgentSession { } async setMode(modeId: string): Promise { - this.currentMode = modeId; + this.currentMode = normalizeOpenCodeModeId(modeId); } getPendingPermissions(): AgentPermissionRequest[] {