diff --git a/packages/server/src/server/agent/create-agent-mode.test.ts b/packages/server/src/server/agent/create-agent-mode.test.ts index 2045c3bd5..24f39137c 100644 --- a/packages/server/src/server/agent/create-agent-mode.test.ts +++ b/packages/server/src/server/agent/create-agent-mode.test.ts @@ -105,4 +105,54 @@ describe("resolveAndValidateCreateAgentMode", () => { }), ).toThrow("Available modes for 'zai-custom': unknown"); }); + + it("inherits target's unattended mode when caller is unattended cross-provider", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "codex", + parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true }, + availableModes: CODEX_MODES, + targetUnattendedMode: "full-access", + }); + expect(resolved).toBe("full-access"); + }); + + it("still refuses cross-provider inheritance when caller is not unattended", () => { + expect(() => + resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "codex", + parent: { provider: "claude", modeId: "default", isUnattended: false }, + availableModes: CODEX_MODES, + targetUnattendedMode: "full-access", + }), + ).toThrow( + "cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access", + ); + }); + + it("still refuses cross-provider inheritance when target has no unattended mode", () => { + expect(() => + resolveAndValidateCreateAgentMode({ + requestedMode: undefined, + targetProvider: "zai-custom", + parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true }, + availableModes: undefined, + targetUnattendedMode: undefined, + }), + ).toThrow( + "cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown", + ); + }); + + it("explicit mode wins over unattended inheritance", () => { + const resolved = resolveAndValidateCreateAgentMode({ + requestedMode: "auto", + targetProvider: "codex", + parent: { provider: "claude", modeId: "bypassPermissions", isUnattended: true }, + availableModes: CODEX_MODES, + targetUnattendedMode: "full-access", + }); + expect(resolved).toBe("auto"); + }); }); diff --git a/packages/server/src/server/agent/create-agent-mode.ts b/packages/server/src/server/agent/create-agent-mode.ts index 44d08290b..375ffd063 100644 --- a/packages/server/src/server/agent/create-agent-mode.ts +++ b/packages/server/src/server/agent/create-agent-mode.ts @@ -3,6 +3,7 @@ import type { AgentProvider } from "./agent-sdk-types.js"; interface CreateAgentModeParent { provider: AgentProvider; modeId: string | null; + isUnattended: boolean; } export interface ResolveCreateAgentModeInput { @@ -12,6 +13,9 @@ export interface ResolveCreateAgentModeInput { // `undefined` = target provider's modes unknown: explicit modes pass through // unvalidated, but cross-provider inheritance is still refused. availableModes: string[] | undefined; + // Target provider's own unattended mode id, if it has one. Used to bridge + // unattended parents into unattended children across providers. + targetUnattendedMode: string | undefined; } function listModes(modes: string[] | undefined): string { @@ -43,6 +47,10 @@ export function resolveAndValidateCreateAgentMode( return parent.modeId ?? undefined; } + if (parent.isUnattended && input.targetUnattendedMode !== undefined) { + return input.targetUnattendedMode; + } + 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 f6e965357..3c9a53533 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1345,13 +1345,13 @@ describe("create_agent MCP tool", () => { ); }); - it("refuses cross-provider mode inheritance when no explicit mode is given", async () => { + it("refuses cross-provider mode inheritance when caller is not in an unattended mode", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", cwd: existingCwd, provider: "claude", - currentModeId: "bypassPermissions", + currentModeId: "default", } as ManagedAgent); const server = await createAgentMcpServer({ @@ -1369,11 +1369,48 @@ describe("create_agent MCP tool", () => { 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", + "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", ); expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); }); + it("inherits the target provider's unattended mode when caller is unattended cross-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: "full-access", + availableModes: [], + config: { title: "Child" }, + } as ManagedAgent); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "create_agent"); + await tool.handler({ + title: "Child", + provider: "opencode/gpt-5.4", + initialPrompt: "Do work", + }); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ modeId: "full-access" }), + undefined, + expect.any(Object), + ); + }); + it("accepts an explicit valid mode across providers", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index b158a0c22..0261d2574 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -635,18 +635,33 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom setupContinuation: AgentWorktreeSetupContinuation | undefined; } - const getAvailableModeIds = (provider: AgentProvider): string[] | undefined => { + const getProviderModes = (provider: AgentProvider) => { const fromRegistry = providerRegistry?.[provider]; if (fromRegistry) { - return fromRegistry.modes.map((mode) => mode.id); + return fromRegistry.modes; } try { - return getAgentProviderDefinition(provider).modes.map((mode) => mode.id); + return getAgentProviderDefinition(provider).modes; } catch { return undefined; } }; + const getAvailableModeIds = (provider: AgentProvider): string[] | undefined => { + return getProviderModes(provider)?.map((mode) => mode.id); + }; + + const getUnattendedModeId = (provider: AgentProvider): string | undefined => { + return getProviderModes(provider)?.find((mode) => mode.isUnattended)?.id; + }; + + const isParentInUnattendedMode = (provider: AgentProvider, modeId: string | null): boolean => { + if (modeId === null) return false; + const modes = getProviderModes(provider); + if (!modes) return false; + return modes.some((mode) => mode.id === modeId && mode.isUnattended === true); + }; + const resolveCallerCreateAgentArgs = ( args: unknown, parentAgentId: string, @@ -667,8 +682,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const resolvedMode = resolveAndValidateCreateAgentMode({ requestedMode: callerArgs.mode, targetProvider: provider, - parent: { provider: parentAgent.provider, modeId: parentAgent.currentModeId }, + parent: { + provider: parentAgent.provider, + modeId: parentAgent.currentModeId, + isUnattended: isParentInUnattendedMode(parentAgent.provider, parentAgent.currentModeId), + }, availableModes: getAvailableModeIds(provider), + targetUnattendedMode: getUnattendedModeId(provider), }); return { provider, @@ -696,6 +716,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom targetProvider: resolvedProviderModel.provider, parent: null, availableModes: getAvailableModeIds(resolvedProviderModel.provider), + targetUnattendedMode: getUnattendedModeId(resolvedProviderModel.provider), }); let resolvedCwd = expandUserPath(cwd); let setupContinuation: AgentWorktreeSetupContinuation | undefined;