Add OpenCode auto accept feature

This commit is contained in:
Mohamed Boudra
2026-05-26 21:51:04 +07:00
parent 0ab41fbd9a
commit 6d205f8853
11 changed files with 265 additions and 65 deletions

View File

@@ -27,6 +27,7 @@ interface ProviderLaunchConfig {
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
modeId?: string; modeId?: string;
featureValues?: Record<string, unknown>;
} }
const SEND_TIMEOUT_MS = 240_000; const SEND_TIMEOUT_MS = 240_000;
@@ -52,7 +53,12 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
modeId: "full-access", modeId: "full-access",
}; };
case "opencode": case "opencode":
return { provider, model: "opencode/big-pickle", modeId: "full-access" }; return {
provider,
model: "opencode/big-pickle",
modeId: "build",
featureValues: { auto_accept: true },
};
case "pi": case "pi":
return { return {
provider, provider,

View File

@@ -19,7 +19,7 @@ import {
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { Brain, ChevronDown, ListTodo, Settings2, Zap } from "lucide-react-native"; import { Brain, ChevronDown, ListTodo, Settings2, ShieldCheck, Zap } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
import { import {
@@ -139,6 +139,7 @@ function findOptionLabel(
const FEATURE_ICONS: Record<string, typeof Zap> = { const FEATURE_ICONS: Record<string, typeof Zap> = {
"list-todo": ListTodo, "list-todo": ListTodo,
"shield-check": ShieldCheck,
zap: Zap, zap: Zap,
}; };
@@ -151,6 +152,7 @@ function getFeatureIconColor(
enabled: boolean, enabled: boolean,
palette: { palette: {
blue: { 400: string }; blue: { 400: string };
green: { 400: string };
yellow: { 400: string }; yellow: { 400: string };
}, },
foregroundMuted: string, foregroundMuted: string,
@@ -162,6 +164,8 @@ function getFeatureIconColor(
switch (getFeatureHighlightColor(featureId)) { switch (getFeatureHighlightColor(featureId)) {
case "blue": case "blue":
return palette.blue[400]; return palette.blue[400];
case "green":
return palette.green[400];
case "yellow": case "yellow":
return palette.yellow[400]; return palette.yellow[400];
default: default:

View File

@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { import {
Bot,
ChevronDown, ChevronDown,
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
@@ -33,6 +34,7 @@ function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompac
} }
const MODE_ICONS = { const MODE_ICONS = {
Bot,
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
ShieldOff, ShieldOff,

View File

@@ -1,7 +1,7 @@
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow"; export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
export function getAgentControlHint(selector: ExplainedAgentControl): string { export function getAgentControlHint(selector: ExplainedAgentControl): string {
switch (selector) { switch (selector) {
@@ -32,6 +32,8 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
switch (featureId) { switch (featureId) {
case "fast_mode": case "fast_mode":
return "yellow"; return "yellow";
case "auto_accept":
return "green";
case "plan_mode": case "plan_mode":
return "blue"; return "blue";
default: default:

View File

@@ -1156,11 +1156,12 @@ export class AgentManager {
async setAgentMode(agentId: string, modeId: string): Promise<void> { async setAgentMode(agentId: string, modeId: string): Promise<void> {
const agent = this.requireSessionAgent(agentId); const agent = this.requireSessionAgent(agentId);
await agent.session.setMode(modeId); await agent.session.setMode(modeId);
agent.config.modeId = modeId; const currentMode = await agent.session.getCurrentMode();
agent.currentModeId = modeId; agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
// Update runtimeInfo to reflect the new mode // Update runtimeInfo to reflect the new mode
if (agent.runtimeInfo) { if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId }; agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
} }
this.touchUpdatedAt(agent); this.touchUpdatedAt(agent);
this.emitState(agent); this.emitState(agent);

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js"; import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js";
const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"]; const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"];
const OPENCODE_MODES = ["build", "full-access", "plan"]; const OPENCODE_MODES = ["build", "plan"];
const CODEX_MODES = ["auto", "full-access"]; const CODEX_MODES = ["auto", "full-access"];
describe("resolveAndValidateCreateAgentMode", () => { describe("resolveAndValidateCreateAgentMode", () => {
@@ -25,7 +25,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES, availableModes: OPENCODE_MODES,
}), }),
).toThrow( ).toThrow(
"Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan", "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, plan",
); );
}); });
@@ -68,7 +68,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES, availableModes: OPENCODE_MODES,
}), }),
).toThrow( ).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, full-access, plan", "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
); );
}); });

View File

@@ -1447,11 +1447,39 @@ describe("create_agent MCP tool", () => {
initialPrompt: "Do work", initialPrompt: "Do work",
}), }),
).rejects.toThrow( ).rejects.toThrow(
"Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan", "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, plan",
); );
expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
}); });
it("accepts legacy OpenCode full-access as build plus auto accept", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "child-agent",
cwd: existingCwd,
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: { title: "Child", featureValues: { auto_accept: true } },
} as ManagedAgent);
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "create_agent");
await tool.handler({
cwd: existingCwd,
title: "Legacy mode",
provider: "opencode/gpt-5.4",
settings: { modeId: "full-access" },
initialPrompt: "Do work",
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined,
undefined,
);
});
it("inherits the caller mode when the new agent uses the same provider", async () => { it("inherits the caller mode when the new agent uses the same provider", async () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ spies.agentManager.getAgent.mockReturnValue({
@@ -1513,12 +1541,12 @@ describe("create_agent MCP tool", () => {
initialPrompt: "Do work", initialPrompt: "Do work",
}), }),
).rejects.toThrow( ).rejects.toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, full-access, plan", "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
); );
expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
}); });
it("inherits the target provider's unattended mode when caller is unattended cross-provider", async () => { it("maps unattended callers to OpenCode auto accept", async () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent", id: "parent-agent",
@@ -1530,9 +1558,9 @@ describe("create_agent MCP tool", () => {
id: "child-agent", id: "child-agent",
cwd: existingCwd, cwd: existingCwd,
lifecycle: "idle", lifecycle: "idle",
currentModeId: "full-access", currentModeId: "build",
availableModes: [], availableModes: [],
config: { title: "Child" }, config: { title: "Child", featureValues: { auto_accept: true } },
} as ManagedAgent); } as ManagedAgent);
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
@@ -1549,7 +1577,7 @@ describe("create_agent MCP tool", () => {
}); });
expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "full-access" }), expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined, undefined,
expect.any(Object), expect.any(Object),
); );

View File

@@ -10,7 +10,7 @@ import type {
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import type { AgentProvider } from "./agent-sdk-types.js"; import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import { import {
AgentFeatureSchema, AgentFeatureSchema,
AgentPermissionRequestPayloadSchema, AgentPermissionRequestPayloadSchema,
@@ -134,6 +134,42 @@ const CODEX_TO_CLAUDE_MODE: Record<string, string> = {
"full-access": "bypassPermissions", "full-access": "bypassPermissions",
}; };
const OPENCODE_PROVIDER_ID = "opencode";
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
function isOpenCodeLegacyFullAccessMode(
provider: AgentProvider,
modeId: string | undefined,
): boolean {
return provider === OPENCODE_PROVIDER_ID && modeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID;
}
function withOpenCodeAutoAcceptFeature(
features: Record<string, unknown> | undefined,
enabled: boolean,
): Record<string, unknown> {
return {
...features,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
}
function hasOpenCodeAutoAcceptFeature(agent: ManagedAgent): boolean {
if (agent.provider !== OPENCODE_PROVIDER_ID) {
return false;
}
return (
agent.features?.some(
(feature) =>
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
feature.type === "toggle" &&
feature.value === true,
) === true || agent.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true
);
}
function mapModeAcrossProviders( function mapModeAcrossProviders(
sourceMode: string, sourceMode: string,
sourceProvider: AgentProvider, sourceProvider: AgentProvider,
@@ -583,6 +619,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
: {}), : {}),
...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}),
...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}),
...(callerAgent.config.featureValues
? { featureValues: callerAgent.config.featureValues }
: {}),
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}), ...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
}; };
@@ -910,6 +949,48 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return modes.some((mode) => mode.id === modeId && mode.isUnattended === true); return modes.some((mode) => mode.id === modeId && mode.isUnattended === true);
}; };
const isAgentInUnattendedState = (agent: ManagedAgent): boolean => {
return (
isParentInUnattendedMode(agent.provider, agent.currentModeId) ||
hasOpenCodeAutoAcceptFeature(agent)
);
};
const resolveCreateModeAndFeatures = (input: {
provider: AgentProvider;
requestedMode: string | undefined;
parent: { provider: AgentProvider; modeId: string | null; isUnattended: boolean } | null;
features: Record<string, unknown> | undefined;
}): { mode: string | undefined; features: Record<string, unknown> | undefined } => {
const legacyOpenCodeFullAccess = isOpenCodeLegacyFullAccessMode(
input.provider,
input.requestedMode,
);
const inheritsOpenCodeUnattended =
input.provider === OPENCODE_PROVIDER_ID &&
input.requestedMode === undefined &&
input.parent?.isUnattended === true;
const inheritsOpenCodeAutoAccept =
inheritsOpenCodeUnattended && input.features?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined;
const requestedMode = legacyOpenCodeFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode;
const features =
legacyOpenCodeFullAccess || inheritsOpenCodeAutoAccept
? withOpenCodeAutoAcceptFeature(input.features, true)
: input.features;
const mode =
inheritsOpenCodeUnattended && requestedMode === undefined
? OPENCODE_BUILD_MODE_ID
: resolveAndValidateCreateAgentMode({
requestedMode,
targetProvider: input.provider,
parent: input.parent,
availableModes: getAvailableModeIds(input.provider),
targetUnattendedMode: getUnattendedModeId(input.provider),
});
return { mode, features };
};
const resolveCallerCreateAgentArgs = ( const resolveCallerCreateAgentArgs = (
args: unknown, args: unknown,
parentAgentId: string, parentAgentId: string,
@@ -928,16 +1009,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
lockedCwd: callerContext?.lockedCwd, lockedCwd: callerContext?.lockedCwd,
allowCustomCwd: callerContext?.allowCustomCwd ?? true, allowCustomCwd: callerContext?.allowCustomCwd ?? true,
}); });
const resolvedMode = resolveAndValidateCreateAgentMode({ const resolvedRuntime = resolveCreateModeAndFeatures({
provider,
requestedMode: settings?.modeId, requestedMode: settings?.modeId,
targetProvider: provider,
parent: { parent: {
provider: parentAgent.provider, provider: parentAgent.provider,
modeId: parentAgent.currentModeId, modeId: parentAgent.currentModeId,
isUnattended: isParentInUnattendedMode(parentAgent.provider, parentAgent.currentModeId), isUnattended: isAgentInUnattendedState(parentAgent),
}, },
availableModes: getAvailableModeIds(provider), features: settings?.features,
targetUnattendedMode: getUnattendedModeId(provider),
}); });
return { return {
provider, provider,
@@ -946,11 +1026,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
normalizedTitle: callerArgs.title.trim(), normalizedTitle: callerArgs.title.trim(),
model: resolvedProviderModel.model, model: resolvedProviderModel.model,
thinkingOptionId: settings?.thinkingOptionId, thinkingOptionId: settings?.thinkingOptionId,
features: settings?.features, features: resolvedRuntime.features,
labels: callerArgs.labels, labels: callerArgs.labels,
notifyOnFinish: callerArgs.notifyOnFinish ?? false, notifyOnFinish: callerArgs.notifyOnFinish ?? false,
resolvedCwd, resolvedCwd,
resolvedMode, resolvedMode: resolvedRuntime.mode,
setupContinuation: undefined, setupContinuation: undefined,
}; };
}; };
@@ -962,12 +1042,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } = const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } =
topLevelArgs; topLevelArgs;
const resolvedMode = resolveAndValidateCreateAgentMode({ const resolvedRuntime = resolveCreateModeAndFeatures({
provider: resolvedProviderModel.provider,
requestedMode: settings?.modeId, requestedMode: settings?.modeId,
targetProvider: resolvedProviderModel.provider,
parent: null, parent: null,
availableModes: getAvailableModeIds(resolvedProviderModel.provider), features: settings?.features,
targetUnattendedMode: getUnattendedModeId(resolvedProviderModel.provider),
}); });
let resolvedCwd = expandUserPath(cwd); let resolvedCwd = expandUserPath(cwd);
let setupContinuation: AgentWorktreeSetupContinuation | undefined; let setupContinuation: AgentWorktreeSetupContinuation | undefined;
@@ -1021,11 +1100,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
normalizedTitle: topLevelArgs.title.trim(), normalizedTitle: topLevelArgs.title.trim(),
model: resolvedProviderModel.model, model: resolvedProviderModel.model,
thinkingOptionId: settings?.thinkingOptionId, thinkingOptionId: settings?.thinkingOptionId,
features: settings?.features, features: resolvedRuntime.features,
labels: topLevelArgs.labels, labels: topLevelArgs.labels,
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
resolvedCwd, resolvedCwd,
resolvedMode, resolvedMode: resolvedRuntime.mode,
setupContinuation, setupContinuation,
}; };
}; };

View File

@@ -2,7 +2,12 @@ import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js"; import type { AgentMode } from "./agent-sdk-types.js";
export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`; export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`;
export type AgentModeIcon = "ShieldCheck" | "ShieldAlert" | "ShieldOff" | "ShieldQuestionMark"; export type AgentModeIcon =
| "Bot"
| "ShieldCheck"
| "ShieldAlert"
| "ShieldOff"
| "ShieldQuestionMark";
export interface AgentModeVisuals { export interface AgentModeVisuals {
icon: AgentModeIcon; icon: AgentModeIcon;
@@ -126,22 +131,14 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
id: "build", id: "build",
label: "Build", label: "Build",
description: "Allows edits and tool execution for implementation work", description: "Allows edits and tool execution for implementation work",
icon: "ShieldCheck", icon: "Bot",
colorTier: "moderate", colorTier: "moderate",
}, },
{
id: "full-access",
label: "Full Access",
description: "Automatically approves all tool permission prompts for the session",
icon: "ShieldAlert",
colorTier: "dangerous",
isUnattended: true,
},
{ {
id: "plan", id: "plan",
label: "Plan", label: "Plan",
description: "Read-only planning mode that avoids file edits", description: "Read-only planning mode that avoids file edits",
icon: "ShieldCheck", icon: "Bot",
colorTier: "planning", colorTier: "planning",
}, },
]; ];

View File

@@ -61,8 +61,8 @@ function questionEvent(): unknown {
}; };
} }
describe("OpenCode full-access mode", () => { describe("OpenCode auto_accept feature", () => {
test("includes virtual full-access mode with dynamic OpenCode agents", async () => { test("lists OpenCode modes without the legacy virtual full-access mode", async () => {
const { runtime } = mockOpenCodeClient({ const { runtime } = mockOpenCodeClient({
agents: [ agents: [
{ name: "build", mode: "primary", hidden: false, description: "Build agent" }, { name: "build", mode: "primary", hidden: false, description: "Build agent" },
@@ -73,14 +73,36 @@ describe("OpenCode full-access mode", () => {
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const modes = await client.listModes({ cwd: "/tmp/project", force: false }); const modes = await client.listModes({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]); expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "paseo-custom"]);
expect(modes.find((mode) => mode.id === "full-access")).toMatchObject({
label: "Full Access",
description: "Automatically approves all tool permission prompts for the session",
});
}); });
test("reports full-access but sends prompts through OpenCode build agent", async () => { test("lists auto accept as a provider feature", async () => {
const { runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const enabledFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
});
const legacyFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
});
expect(enabledFeatures).toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
]);
expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
});
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient(); const { openCodeClient, runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
@@ -90,7 +112,8 @@ describe("OpenCode full-access mode", () => {
modeId: "full-access", modeId: "full-access",
}); });
expect(await session.getCurrentMode()).toBe("full-access"); expect(await session.getCurrentMode()).toBe("build");
expect(session.features).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
await session.run("Implement the change"); await session.run("Implement the change");
@@ -102,7 +125,7 @@ describe("OpenCode full-access mode", () => {
await session.close(); await session.close();
}); });
test("auto-approves tool permissions in full-access without surfacing them", async () => { test("auto-approves tool permissions when auto accept is enabled", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({ const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()], events: [toolPermissionEvent(), idleEvent()],
}); });
@@ -112,7 +135,7 @@ describe("OpenCode full-access mode", () => {
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
modeId: "full-access", featureValues: { auto_accept: true },
}); });
session.subscribe((event) => receivedEvents.push(event)); session.subscribe((event) => receivedEvents.push(event));
@@ -130,7 +153,7 @@ describe("OpenCode full-access mode", () => {
await session.close(); await session.close();
}); });
test("keeps questions separate from full-access tool auto-approval", async () => { test("keeps questions separate from auto accept tool approval", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({ const { openCodeClient, runtime } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()], events: [questionEvent(), idleEvent()],
}); });
@@ -140,7 +163,7 @@ describe("OpenCode full-access mode", () => {
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
modeId: "full-access", featureValues: { auto_accept: true },
}); });
session.subscribe((event) => receivedEvents.push(event)); session.subscribe((event) => receivedEvents.push(event));

View File

@@ -83,7 +83,8 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
}; };
const OPENCODE_BUILD_MODE_ID = "build"; const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access"; const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
const OPENCODE_PERSISTED_SESSION_LIMIT = 200; const OPENCODE_PERSISTED_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
const OPENCODE_PERMISSION_ACTION_ALLOW_ONCE = "allow_once"; const OPENCODE_PERMISSION_ACTION_ALLOW_ONCE = "allow_once";
@@ -100,13 +101,24 @@ const DEFAULT_MODES: AgentMode[] = [
label: "Plan", label: "Plan",
description: "Read-only planning mode that avoids file edits", description: "Read-only planning mode that avoids file edits",
}, },
{
id: OPENCODE_FULL_ACCESS_MODE_ID,
label: "Full Access",
description: "Automatically approves all tool permission prompts for the session",
},
]; ];
function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
}
function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
return {
type: "toggle",
id: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves OpenCode tool permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
value: isOpenCodeAutoAcceptEnabled(config),
};
}
function buildOpenCodePermissionActions(): AgentPermissionAction[] { function buildOpenCodePermissionActions(): AgentPermissionAction[] {
return [ return [
{ {
@@ -436,11 +448,26 @@ function normalizeOpenCodeModeId(modeId: string | null | undefined): string {
function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string { function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string {
const normalizedModeId = normalizeOpenCodeModeId(modeId); const normalizedModeId = normalizeOpenCodeModeId(modeId);
return normalizedModeId === OPENCODE_FULL_ACCESS_MODE_ID return normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID
? OPENCODE_BUILD_MODE_ID ? OPENCODE_BUILD_MODE_ID
: normalizedModeId; : normalizedModeId;
} }
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
return { ...config };
}
return {
...config,
modeId: OPENCODE_BUILD_MODE_ID,
featureValues: {
...config.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
},
};
}
function isSelectableOpenCodeAgent(agent: { mode?: string; hidden?: boolean }): boolean { function isSelectableOpenCodeAgent(agent: { mode?: string; hidden?: boolean }): boolean {
return (agent.mode === "primary" || agent.mode === "all") && agent.hidden !== true; return (agent.mode === "primary" || agent.mode === "all") && agent.hidden !== true;
} }
@@ -462,6 +489,7 @@ function mapOpenCodeAgentToMode(agent: {
return { return {
id: agent.name, id: agent.name,
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1), label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
icon: "Bot",
description: description:
typeof agent.description === "string" && agent.description.trim().length > 0 typeof agent.description === "string" && agent.description.trim().length > 0
? agent.description.trim() ? agent.description.trim()
@@ -473,6 +501,9 @@ function mapOpenCodeAgentToMode(agent: {
function mergeOpenCodeModes(discoveredModes: AgentMode[]): AgentMode[] { function mergeOpenCodeModes(discoveredModes: AgentMode[]): AgentMode[] {
const modesById = new Map(DEFAULT_MODES.map((mode) => [mode.id, mode])); const modesById = new Map(DEFAULT_MODES.map((mode) => [mode.id, mode]));
for (const mode of discoveredModes) { for (const mode of discoveredModes) {
if (mode.id === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
continue;
}
modesById.set(mode.id, mode); modesById.set(mode.id, mode);
} }
return sortOpenCodeModes(Array.from(modesById.values())); return sortOpenCodeModes(Array.from(modesById.values()));
@@ -1329,8 +1360,8 @@ export class OpenCodeAgentClient implements AgentClient {
} }
} }
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> { async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
return []; return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
} }
async listPersistedAgents( async listPersistedAgents(
@@ -1437,7 +1468,7 @@ export class OpenCodeAgentClient implements AgentClient {
if (config.provider !== "opencode") { if (config.provider !== "opencode") {
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);
} }
return { ...config, provider: "opencode" }; return normalizeOpenCodeConfig({ ...config, provider: "opencode" });
} }
private async populateModelContextWindowCache( private async populateModelContextWindowCache(
@@ -2541,6 +2572,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly logger: Logger; private readonly logger: Logger;
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>; private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
private currentMode: string = "default"; private currentMode: string = "default";
private autoAcceptEnabled = false;
private pendingPermissions = new Map<string, AgentPermissionRequest>(); private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
private pendingAbortPromise: Promise<void> | null = null; private pendingAbortPromise: Promise<void> | null = null;
@@ -2590,6 +2622,7 @@ class OpenCodeAgentSession implements AgentSession {
this.logger = logger.child({ agentId: this.agentId }); this.logger = logger.child({ agentId: this.agentId });
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey; this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId); this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.autoAcceptEnabled = isOpenCodeAutoAcceptEnabled(config);
this.releaseServer = releaseServer ?? null; this.releaseServer = releaseServer ?? null;
this.persistSession = persistSession; this.persistSession = persistSession;
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens( this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
@@ -2602,6 +2635,10 @@ class OpenCodeAgentSession implements AgentSession {
return this.sessionId; return this.sessionId;
} }
get features(): AgentFeature[] {
return [buildOpenCodeAutoAcceptFeature(this.config)];
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> { async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
return { return {
provider: "opencode", provider: "opencode",
@@ -3238,7 +3275,28 @@ class OpenCodeAgentSession implements AgentSession {
} }
async setMode(modeId: string): Promise<void> { async setMode(modeId: string): Promise<void> {
this.currentMode = normalizeOpenCodeModeId(modeId); const normalizedModeId = normalizeOpenCodeModeId(modeId);
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
this.currentMode = OPENCODE_BUILD_MODE_ID;
await this.setFeature(OPENCODE_AUTO_ACCEPT_FEATURE_ID, true);
return;
}
this.currentMode = normalizedModeId;
this.config.modeId = normalizedModeId;
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) {
throw new Error(`Unsupported OpenCode feature '${featureId}'`);
}
const enabled = value === true;
this.autoAcceptEnabled = enabled;
this.config.featureValues = {
...this.config.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
} }
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
@@ -3521,7 +3579,7 @@ class OpenCodeAgentSession implements AgentSession {
} }
private async tryAutoApproveToolPermission(request: AgentPermissionRequest): Promise<boolean> { private async tryAutoApproveToolPermission(request: AgentPermissionRequest): Promise<boolean> {
if (this.currentMode !== OPENCODE_FULL_ACCESS_MODE_ID || request.kind !== "tool") { if (!this.autoAcceptEnabled || request.kind !== "tool") {
return false; return false;
} }