Add OpenCode build and plan modes
This commit is contained in:
@@ -59,9 +59,14 @@ const CODEX_MODES: AgentMode[] = [
|
|||||||
|
|
||||||
const OPENCODE_MODES: AgentMode[] = [
|
const OPENCODE_MODES: AgentMode[] = [
|
||||||
{
|
{
|
||||||
id: "default",
|
id: "build",
|
||||||
label: "Default",
|
label: "Build",
|
||||||
description: "Standard permission rules",
|
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",
|
label: "OpenCode",
|
||||||
description:
|
description:
|
||||||
"Open-source coding assistant with multi-provider model support",
|
"Open-source coding assistant with multi-provider model support",
|
||||||
defaultModeId: "default",
|
defaultModeId: "build",
|
||||||
modes: OPENCODE_MODES,
|
modes: OPENCODE_MODES,
|
||||||
voice: {
|
voice: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
defaultModeId: "default",
|
defaultModeId: "build",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
@@ -202,4 +209,72 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
60_000
|
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 = {
|
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
supportsDynamicModes: false,
|
supportsDynamicModes: true,
|
||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
@@ -47,12 +47,19 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
|
|
||||||
const DEFAULT_MODES: AgentMode[] = [
|
const DEFAULT_MODES: AgentMode[] = [
|
||||||
{
|
{
|
||||||
id: "default",
|
id: "build",
|
||||||
label: "Default",
|
label: "Build",
|
||||||
description: "Standard permission rules",
|
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 OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
|
||||||
type OpenCodeMessageRole = "user" | "assistant";
|
type OpenCodeMessageRole = "user" | "assistant";
|
||||||
|
|
||||||
@@ -227,6 +234,26 @@ function resolvePartDedupeKey(
|
|||||||
return null;
|
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 {
|
export class OpenCodeServerManager {
|
||||||
private static instance: OpenCodeServerManager | null = null;
|
private static instance: OpenCodeServerManager | null = null;
|
||||||
private static exitHandlerRegistered = false;
|
private static exitHandlerRegistered = false;
|
||||||
@@ -794,6 +821,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private streamedPartKeys = new Set<string>();
|
private streamedPartKeys = new Set<string>();
|
||||||
/** Tracks assistant messages already emitted from structured payloads. */
|
/** Tracks assistant messages already emitted from structured payloads. */
|
||||||
private emittedStructuredMessageIds = new Set<string>();
|
private emittedStructuredMessageIds = new Set<string>();
|
||||||
|
private availableModesCache: AgentMode[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: OpenCodeAgentConfig,
|
config: OpenCodeAgentConfig,
|
||||||
@@ -803,6 +831,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
|
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string | null {
|
get id(): string | null {
|
||||||
@@ -871,6 +900,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const thinkingOptionId = this.config.thinkingOptionId;
|
const thinkingOptionId = this.config.thinkingOptionId;
|
||||||
const effectiveVariant =
|
const effectiveVariant =
|
||||||
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
|
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
|
||||||
|
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
|
||||||
|
|
||||||
// Send prompt asynchronously
|
// Send prompt asynchronously
|
||||||
const promptResponse = await this.client.session.promptAsync({
|
const promptResponse = await this.client.session.promptAsync({
|
||||||
@@ -887,6 +917,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
: {}),
|
: {}),
|
||||||
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1025,7 +1056,32 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableModes(): Promise<AgentMode[]> {
|
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> {
|
async getCurrentMode(): Promise<string | null> {
|
||||||
@@ -1033,7 +1089,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setMode(modeId: string): Promise<void> {
|
async setMode(modeId: string): Promise<void> {
|
||||||
this.currentMode = modeId;
|
this.currentMode = normalizeOpenCodeModeId(modeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingPermissions(): AgentPermissionRequest[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user