diff --git a/packages/server/src/server/agent/create-agent-mode.test.ts b/packages/server/src/server/agent/create-agent-mode.test.ts new file mode 100644 index 000000000..2045c3bd5 --- /dev/null +++ b/packages/server/src/server/agent/create-agent-mode.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js"; + +const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"]; +const OPENCODE_MODES = ["build", "full-access", "plan"]; +const CODEX_MODES = ["auto", "full-access"]; + +describe("resolveAndValidateCreateAgentMode", () => { + it("returns the requested mode when it is valid for the target provider", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: "plan", + targetProvider: "opencode", + parent: null, + availableModes: OPENCODE_MODES, + }); + expect(resolved).toBe("plan"); + }); + + it("throws when the requested mode is invalid for the target provider", () => { + expect(() => + resolveAndValidateCreateAgentMode({ + requestedMode: "bypassPermissions", + targetProvider: "opencode", + parent: null, + availableModes: OPENCODE_MODES, + }), + ).toThrow( + "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan", + ); + }); + + it("returns undefined (provider default) when no mode and no caller", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "claude", + parent: null, + availableModes: CLAUDE_MODES, + }); + expect(resolved).toBeUndefined(); + }); + + it("inherits the caller mode when caller and target share a provider", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "claude", + parent: { provider: "claude", modeId: "bypassPermissions" }, + availableModes: CLAUDE_MODES, + }); + expect(resolved).toBe("bypassPermissions"); + }); + + it("returns undefined when same-provider caller has no mode", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "claude", + parent: { provider: "claude", modeId: null }, + availableModes: CLAUDE_MODES, + }); + expect(resolved).toBeUndefined(); + }); + + it("refuses cross-provider inheritance with the target provider's modes in the message", () => { + expect(() => + resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "opencode", + parent: { provider: "claude", modeId: "bypassPermissions" }, + availableModes: OPENCODE_MODES, + }), + ).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", + ); + }); + + it("refuses cross-provider inheritance even when the caller mode is null", () => { + expect(() => + resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "codex", + parent: { provider: "opencode", modeId: null }, + availableModes: CODEX_MODES, + }), + ).toThrow( + "cannot inherit mode '' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access", + ); + }); + + it("passes through an explicit mode when the target provider's modes are unknown", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: "default", + targetProvider: "zai-custom", + parent: null, + availableModes: undefined, + }); + expect(resolved).toBe("default"); + }); + + it("renders 'unknown' in cross-provider error when target modes are unknown", () => { + expect(() => + resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "zai-custom", + parent: { provider: "claude", modeId: "default" }, + availableModes: undefined, + }), + ).toThrow("Available modes for 'zai-custom': unknown"); + }); +}); diff --git a/packages/server/src/server/agent/create-agent-mode.ts b/packages/server/src/server/agent/create-agent-mode.ts new file mode 100644 index 000000000..44d08290b --- /dev/null +++ b/packages/server/src/server/agent/create-agent-mode.ts @@ -0,0 +1,49 @@ +import type { AgentProvider } from "./agent-sdk-types.js"; + +interface CreateAgentModeParent { + provider: AgentProvider; + modeId: string | null; +} + +export interface ResolveCreateAgentModeInput { + requestedMode: string | undefined; + targetProvider: AgentProvider; + parent: CreateAgentModeParent | null; + // `undefined` = target provider's modes unknown: explicit modes pass through + // unvalidated, but cross-provider inheritance is still refused. + availableModes: string[] | undefined; +} + +function listModes(modes: string[] | undefined): string { + if (modes === undefined) { + return "unknown"; + } + return modes.length > 0 ? modes.join(", ") : "(none)"; +} + +export function resolveAndValidateCreateAgentMode( + input: ResolveCreateAgentModeInput, +): string | undefined { + const { requestedMode, targetProvider, parent, availableModes } = input; + + if (requestedMode !== undefined) { + if (availableModes !== undefined && !availableModes.includes(requestedMode)) { + throw new Error( + `Invalid mode '${requestedMode}' for provider '${targetProvider}'. Available modes: ${listModes(availableModes)}`, + ); + } + return requestedMode; + } + + if (!parent) { + return undefined; + } + + if (parent.provider === targetProvider) { + return parent.modeId ?? undefined; + } + + throw new Error( + `cannot inherit mode '${parent.modeId ?? ""}' from caller (provider '${parent.provider}') for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`, + ); +} diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 91963502a..b972937a9 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -593,7 +593,7 @@ describe("create_agent MCP tool", () => { await tool.callback({ cwd: existingCwd, title: "Config test", - mode: "default", + mode: "auto", initialPrompt: "Do work", provider: "codex/gpt-5.4", thinking: "think-hard", @@ -1169,7 +1169,7 @@ describe("create_agent MCP tool", () => { await tool.callback({ cwd: existingCwd, title: "Injected config test", - mode: "default", + mode: "auto", provider: "codex/gpt-5.4", initialPrompt: "Do work", }); @@ -1183,6 +1183,129 @@ describe("create_agent MCP tool", () => { expect(agentIdArg).toBeUndefined(); expect(optionsArg).toBeUndefined(); }); + + it("rejects an explicit mode that is not valid for the target provider", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const tool = registeredTool(server, "create_agent"); + + await expect( + tool.callback({ + cwd: existingCwd, + title: "Bad mode", + provider: "opencode/gpt-5.4", + mode: "bypassPermissions", + initialPrompt: "Do work", + }), + ).rejects.toThrow( + "Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan", + ); + expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); + }); + + it("inherits the caller mode when the new agent uses the same provider", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue({ + id: "parent-agent", + cwd: existingCwd, + provider: "claude", + currentModeId: "bypassPermissions", + } as ManagedAgent); + spies.agentManager.createAgent.mockResolvedValue({ + id: "child-agent", + cwd: existingCwd, + lifecycle: "idle", + currentModeId: "bypassPermissions", + availableModes: [], + config: { title: "Child" }, + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.callback({ + title: "Child", + provider: "claude/claude-sonnet-4-20250514", + initialPrompt: "Do work", + }); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ modeId: "bypassPermissions" }), + undefined, + expect.any(Object), + ); + }); + + it("refuses cross-provider mode inheritance when no explicit mode is given", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue({ + id: "parent-agent", + cwd: existingCwd, + provider: "claude", + currentModeId: "bypassPermissions", + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "create_agent"); + + await expect( + tool.callback({ + title: "Child", + provider: "opencode/gpt-5.4", + initialPrompt: "Do work", + }), + ).rejects.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", + ); + expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); + }); + + it("accepts an explicit valid mode across providers", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue({ + id: "parent-agent", + cwd: existingCwd, + provider: "claude", + currentModeId: "bypassPermissions", + } as ManagedAgent); + spies.agentManager.createAgent.mockResolvedValue({ + id: "child-agent", + cwd: existingCwd, + lifecycle: "idle", + currentModeId: "build", + availableModes: [], + config: { title: "Child" }, + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.callback({ + title: "Child", + provider: "opencode/gpt-5.4", + mode: "build", + initialPrompt: "Do work", + }); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ modeId: "build" }), + undefined, + expect.any(Object), + ); + }); }); describe("create_schedule MCP tool", () => { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index d479c1c64..a381385b3 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -49,6 +49,8 @@ import type { import type { ScheduleService } from "../schedule/service.js"; import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; +import { getAgentProviderDefinition } from "./provider-manifest.js"; +import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js"; import { resolveSnapshotCwd } from "./provider-snapshot-manager.js"; import { AgentModelSchema, @@ -492,6 +494,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom .trim() .min(1, "initialPrompt is required") .describe("Required first task to run immediately after creation."), + mode: z + .string() + .optional() + .describe( + "Optional session mode for the new agent. Required when the new agent uses a different provider than the caller agent.", + ), background: z .boolean() .optional() @@ -628,6 +636,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom setupContinuation: AgentWorktreeSetupContinuation | undefined; } + const getAvailableModeIds = (provider: AgentProvider): string[] | undefined => { + const fromRegistry = providerRegistry?.[provider]; + if (fromRegistry) { + return fromRegistry.modes.map((mode) => mode.id); + } + try { + return getAgentProviderDefinition(provider).modes.map((mode) => mode.id); + } catch { + return undefined; + } + }; + const resolveCallerCreateAgentArgs = ( args: unknown, parentAgentId: string, @@ -645,10 +665,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom lockedCwd: callerContext?.lockedCwd, allowCustomCwd: callerContext?.allowCustomCwd ?? true, }); - const parentMode = parentAgent.currentModeId; - const resolvedMode = parentMode - ? mapModeAcrossProviders(parentMode, parentAgent.provider, provider) - : undefined; + const resolvedMode = resolveAndValidateCreateAgentMode({ + requestedMode: callerArgs.mode, + targetProvider: provider, + parent: { provider: parentAgent.provider, modeId: parentAgent.currentModeId }, + availableModes: getAvailableModeIds(provider), + }); return { provider, initialPrompt: callerArgs.initialPrompt, @@ -670,6 +692,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs; + const resolvedMode = resolveAndValidateCreateAgentMode({ + requestedMode: mode, + targetProvider: resolvedProviderModel.provider, + parent: null, + availableModes: getAvailableModeIds(resolvedProviderModel.provider), + }); let resolvedCwd = expandUserPath(cwd); let setupContinuation: AgentWorktreeSetupContinuation | undefined; @@ -725,7 +753,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom labels: topLevelArgs.labels, notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, resolvedCwd, - resolvedMode: mode, + resolvedMode, setupContinuation, }; };