feat(mcp): inherit target's unattended mode when caller is unattended cross-provider
create_agent previously threw on cross-provider inheritance unless an explicit mode was passed. In practice, sub-agents launched without a mode should run unattended — keying permissions per sub-agent rarely makes sense. When the caller is in an unattended mode (Claude bypassPermissions, Codex/OpenCode full-access, Copilot autopilot) and no mode is requested, default the child to the target provider's own unattended mode. Non-unattended callers still require an explicit cross-provider mode, and explicit modes always win.
This commit is contained in:
@@ -105,4 +105,54 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow("Available modes for 'zai-custom': unknown");
|
).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AgentProvider } from "./agent-sdk-types.js";
|
|||||||
interface CreateAgentModeParent {
|
interface CreateAgentModeParent {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
modeId: string | null;
|
modeId: string | null;
|
||||||
|
isUnattended: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveCreateAgentModeInput {
|
export interface ResolveCreateAgentModeInput {
|
||||||
@@ -12,6 +13,9 @@ export interface ResolveCreateAgentModeInput {
|
|||||||
// `undefined` = target provider's modes unknown: explicit modes pass through
|
// `undefined` = target provider's modes unknown: explicit modes pass through
|
||||||
// unvalidated, but cross-provider inheritance is still refused.
|
// unvalidated, but cross-provider inheritance is still refused.
|
||||||
availableModes: string[] | undefined;
|
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 {
|
function listModes(modes: string[] | undefined): string {
|
||||||
@@ -43,6 +47,10 @@ export function resolveAndValidateCreateAgentMode(
|
|||||||
return parent.modeId ?? undefined;
|
return parent.modeId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parent.isUnattended && input.targetUnattendedMode !== undefined) {
|
||||||
|
return input.targetUnattendedMode;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`cannot inherit mode '${parent.modeId ?? "<none>"}' from caller (provider '${parent.provider}') for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
`cannot inherit mode '${parent.modeId ?? "<none>"}' from caller (provider '${parent.provider}') for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.getAgent.mockReturnValue({
|
spies.agentManager.getAgent.mockReturnValue({
|
||||||
id: "parent-agent",
|
id: "parent-agent",
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
currentModeId: "bypassPermissions",
|
currentModeId: "default",
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -1369,11 +1369,48 @@ describe("create_agent MCP tool", () => {
|
|||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(
|
).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();
|
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 () => {
|
it("accepts an explicit valid mode across providers", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.getAgent.mockReturnValue({
|
spies.agentManager.getAgent.mockReturnValue({
|
||||||
|
|||||||
@@ -635,18 +635,33 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvailableModeIds = (provider: AgentProvider): string[] | undefined => {
|
const getProviderModes = (provider: AgentProvider) => {
|
||||||
const fromRegistry = providerRegistry?.[provider];
|
const fromRegistry = providerRegistry?.[provider];
|
||||||
if (fromRegistry) {
|
if (fromRegistry) {
|
||||||
return fromRegistry.modes.map((mode) => mode.id);
|
return fromRegistry.modes;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return getAgentProviderDefinition(provider).modes.map((mode) => mode.id);
|
return getAgentProviderDefinition(provider).modes;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
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 = (
|
const resolveCallerCreateAgentArgs = (
|
||||||
args: unknown,
|
args: unknown,
|
||||||
parentAgentId: string,
|
parentAgentId: string,
|
||||||
@@ -667,8 +682,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
const resolvedMode = resolveAndValidateCreateAgentMode({
|
const resolvedMode = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: callerArgs.mode,
|
requestedMode: callerArgs.mode,
|
||||||
targetProvider: provider,
|
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),
|
availableModes: getAvailableModeIds(provider),
|
||||||
|
targetUnattendedMode: getUnattendedModeId(provider),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
@@ -696,6 +716,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
targetProvider: resolvedProviderModel.provider,
|
targetProvider: resolvedProviderModel.provider,
|
||||||
parent: null,
|
parent: null,
|
||||||
availableModes: getAvailableModeIds(resolvedProviderModel.provider),
|
availableModes: getAvailableModeIds(resolvedProviderModel.provider),
|
||||||
|
targetUnattendedMode: getUnattendedModeId(resolvedProviderModel.provider),
|
||||||
});
|
});
|
||||||
let resolvedCwd = expandUserPath(cwd);
|
let resolvedCwd = expandUserPath(cwd);
|
||||||
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user