Add OpenCode build and plan modes
This commit is contained in:
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user