diff --git a/packages/coding-agent/src/features/step-subagent.ts b/packages/coding-agent/src/features/step-subagent.ts index a634f948..674a017a 100644 --- a/packages/coding-agent/src/features/step-subagent.ts +++ b/packages/coding-agent/src/features/step-subagent.ts @@ -16,6 +16,7 @@ import { type Static, Type } from "typebox"; import { CONFIG_DIR_NAME } from "../config.ts"; import type { ExtensionAPI, ExtensionContext, ExtensionFactory, InlineExtension } from "../core/extensions/types.ts"; import { resolveStepAgentDir, resolveStepConfigDir } from "../step/environment.ts"; +import { requestStepPermissionState, type StepChildPermissionPolicy } from "../step/permissions.ts"; import type { StepTelemetryReporter } from "../step/telemetry.ts"; import { formatBuiltinAgentGuidance, type StepAgentConfig, type StepAgentScope } from "./step-subagent-agents.ts"; import { executeSubagent } from "./subagent/execute.ts"; @@ -33,7 +34,7 @@ import { SubagentListWidget, subagentListSignature, } from "./subagent/rendering.ts"; -import { createSubagentRpcSession } from "./subagent/rpc-adapter.ts"; +import { createSubagentRpcSession, subagentPermissionKey } from "./subagent/rpc-adapter.ts"; // Lane-notification primitives moved to ./subagent/lane-events.ts; re-exported // here to keep this module's public surface stable. @@ -129,6 +130,12 @@ export interface StepSubagentRunInput { * Defaults to `resolveSubagentTurnIdleTimeoutMs()`; `0` disables the watchdog. */ turnIdleTimeoutMs?: number; + /** + * The parent's permission policy, passed to the child as explicit flags so + * the child is never more permissive than the parent. Omitted when the + * parent has no Step permission controller; the child then resolves its own. + */ + permission?: StepChildPermissionPolicy; /** Workflow-owned path ACL passed to the child-side tool_call hook. */ workflowAcl?: { baseCwd: string; @@ -404,7 +411,15 @@ const spawnedSubagentSessions = new Set(); export async function runStepSubagentProcess(input: StepSubagentRunInput): Promise { const sessionId = input.sessionId?.trim() || `${SUBAGENT_SESSION_ID_PREFIX}${randomUUID()}`; const live = getLiveSubagentSession(sessionId); - if (live) return live.runTurn(input); + if (live) { + if (live.isTurnActive() || live.permissionKey === subagentPermissionKey(input.permission)) { + return live.runTurn(input); + } + // A live child keeps the policy it was spawned with. The parent's changed + // since (e.g. tightened via /permissions), so replace the idle child and + // resume its transcript under the current policy. + live.stop(); + } if (spawnedSubagentSessions.has(sessionId)) input.onChildRespawn?.(); spawnedSubagentSessions.add(sessionId); const session = await createSubagentRpcSession(input, sessionId); @@ -517,6 +532,9 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption // tool profile, but does not recursively expose another subagent tool. if (process.env[CHILD_MARKER] === "1") return; + // Read the parent's live permission state at each spawn, so a preset + // switched mid-session reaches the next child. + const run = { ...resolved, permissionState: () => requestStepPermissionState(pi.events) }; const lanes = new Map(); const laneWidgetKey = (id: string): string => `step-agent:${id}`; const updateLaneWidget = (lane: BackgroundAgentLane): void => { @@ -534,7 +552,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption lanes, agentDir: resolved.agentDir, executeSubagent: (runParams, signal, onUpdate, ctx, laneRuntime) => - executeSubagent(runParams, signal, onUpdate, ctx, resolved, laneRuntime), + executeSubagent(runParams, signal, onUpdate, ctx, run, laneRuntime), updateLaneWidget, }); @@ -565,7 +583,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption // normal validation result keeps the error attached to this tool call // instead of emitting a misleading background_done notification. if (Number(hasSingleInput) + Number(hasParallelInput) + Number(hasChainInput) !== 1) { - return executeSubagent(backgroundParams, signal, onUpdate, ctx, resolved); + return executeSubagent(backgroundParams, signal, onUpdate, ctx, run); } const lane = createLane(backgroundParams, ctx); startLane(lane, backgroundParams); @@ -583,7 +601,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption ); } return withSubagentListWidget(toolCallId, ctx, onUpdate, (update) => - executeSubagent(params as SubagentParams, signal, update, ctx, resolved), + executeSubagent(params as SubagentParams, signal, update, ctx, run), ); }, renderCall: (params, theme) => { diff --git a/packages/coding-agent/src/features/step.ts b/packages/coding-agent/src/features/step.ts index 2c51567d..d9f4b2ed 100644 --- a/packages/coding-agent/src/features/step.ts +++ b/packages/coding-agent/src/features/step.ts @@ -13,6 +13,7 @@ import { STEP_INIT_PROMPT } from "../step/init-prompt.ts"; import { createStepMcpExtension } from "../step/mcp.ts"; import { AUTO_RESUME_PROMPT, + answerStepPermissionStateRequests, getStepPermissionPreset, publishStepPermissionStatus, StepAutoResumeController, @@ -123,6 +124,10 @@ export function createStepExtension(options: StepExtensionOptions = {}): Extensi recordStepSlashCommand(options.telemetry, token, builtInSlashCommands.has(name)); }); let permissions = new StepPermissionController(resolvePermissionOptions(options)); + // The subagent tool reads the live policy here when it spawns a child, so + // the child gets the parent's effective policy rather than re-resolving + // its own from env and settings. + answerStepPermissionStateRequests(pi.events, () => permissions.getState()); let notify: ((message: string, type?: "info" | "warning" | "error") => void) | undefined; let autoResumeAllowed = false; let nativeRetryPreference: boolean | undefined; diff --git a/packages/coding-agent/src/features/subagent/execute.ts b/packages/coding-agent/src/features/subagent/execute.ts index 960004e2..7f84a54a 100644 --- a/packages/coding-agent/src/features/subagent/execute.ts +++ b/packages/coding-agent/src/features/subagent/execute.ts @@ -11,6 +11,7 @@ import path from "node:path"; import type { AgentToolResult, AgentToolUpdateCallback } from "@step-harness/agent-core"; import type { Static } from "typebox"; import type { ExtensionContext } from "../../core/extensions/types.ts"; +import { resolveStepChildPermissionPolicy, type StepPermissionState } from "../../step/permissions.ts"; import { type StepTelemetryReporter, trackStepTelemetry } from "../../step/telemetry.ts"; import { cloneUsage, @@ -224,6 +225,8 @@ export async function executeSubagent( worktreeManager: StepWorktreeManager; runner: StepSubagentRunner; telemetry?: StepTelemetryReporter; + /** The parent's live permission state; undefined when no Step controller is loaded. */ + permissionState?: () => StepPermissionState | undefined; }, laneRuntime?: StepSubagentLaneRuntime, ): Promise> { @@ -336,12 +339,15 @@ export async function executeSubagent( }); emit(parallel ? "parallel" : "single"); }; + // Read at spawn time: a preset switched mid-session applies to the next child. + const permissionState = options.permissionState?.(); const child = await options.runner({ agent, task: task.task, cwd: childCwd, model: task.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined), thinkingLevel: ctx.thinkingLevel, + ...(permissionState ? { permission: resolveStepChildPermissionPolicy(permissionState, ctx.hasUI) } : {}), signal, onUpdate: update, onNeedsInput: laneRuntime?.onNeedsInput, diff --git a/packages/coding-agent/src/features/subagent/rpc-adapter.ts b/packages/coding-agent/src/features/subagent/rpc-adapter.ts index 9010d79a..c5d78b8f 100644 --- a/packages/coding-agent/src/features/subagent/rpc-adapter.ts +++ b/packages/coding-agent/src/features/subagent/rpc-adapter.ts @@ -13,6 +13,7 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isValidThinkingLevel } from "../../cli/args.ts"; import { CHILD_MARKER, cloneUsage, @@ -101,6 +102,8 @@ interface SubagentRpcTurn { /** A live `--mode rpc` child bound to one subagent session id. */ export interface StepSubagentRpcSession { readonly sessionId: string; + /** `subagentPermissionKey` of the policy this child was spawned with. */ + readonly permissionKey: string; /** True while the child can still accept stdin commands. */ isAlive(): boolean; /** True while a prompt turn is in flight. */ @@ -177,21 +180,62 @@ export function buildSubagentChildEnv(input: StepSubagentRunInput): NodeJS.Proce ...(input.workflowAcl ? { [WORKFLOW_ACL_ENV]: JSON.stringify(input.workflowAcl) } : { [WORKFLOW_ACL_ENV]: undefined }), + // Auto-resume has no CLI flag. Pin it here so neither an inherited + // `STEP_AUTOPILOT` nor a persisted setting overrides the parent's choice. + ...(input.permission + ? { STEP_AUTOPILOT: undefined, STEP_AUTO_RESUME: input.permission.autoResume ? "1" : "0" } + : {}), }; } +/** Comparable identity of a child's permission policy: the flags it is spawned with. */ +export function subagentPermissionKey(policy: StepSubagentRunInput["permission"]): string { + return JSON.stringify([...permissionArgs(policy), policy?.autoResume === true]); +} + +function permissionArgs(policy: StepSubagentRunInput["permission"]): string[] { + if (!policy) return []; + const args = ["--approval-mode", policy.approvalMode, "--non-interactive-approval", policy.nonInteractiveApproval]; + for (const [tool, mode] of Object.entries(policy.toolOverrides ?? {})) { + args.push("--tool-override", `${tool}=${mode}`); + } + return args; +} + +/** True when a model pattern ends in a `:` suffix. */ +function declaresThinkingLevel(model: string): boolean { + const colon = model.lastIndexOf(":"); + return colon !== -1 && isValidThinkingLevel(model.slice(colon + 1)); +} + +/** + * Argv for one rpc child, minus the `--append-system-prompt` temp file. + * + * The child inherits the parent's thinking level unless its model pattern + * declares one (`provider/id:high`): an explicit `--thinking` would override + * that suffix in the child's resolver. A model without reasoning support + * clamps the level instead of failing. The permission policy goes as explicit + * flags, which outrank any `STEP_*` env var or persisted preset the child + * would otherwise resolve. + */ +export function buildSubagentChildArgs(input: StepSubagentRunInput, sessionId: string): string[] { + const args = ["--mode", "rpc", "--session-id", sessionId]; + const model = input.agent.model ?? input.model; + if (model) args.push("--model", model); + if (input.thinkingLevel && !(model && declaresThinkingLevel(model))) args.push("--thinking", input.thinkingLevel); + const tools = normalizeChildTools(input.agent.tools); + if (tools && tools.length > 0) args.push("--tools", tools.join(",")); + args.push(...permissionArgs(input.permission)); + return args; +} + /** Spawn a long-running `--mode rpc --session-id` child for one subagent session. */ export async function createSubagentRpcSession( input: StepSubagentRunInput, sessionId: string, ): Promise { const tempDir = await mkdtemp(path.join(os.tmpdir(), "stepcode-subagent-")); - const args = ["--mode", "rpc", "--session-id", sessionId]; - const model = input.agent.model ?? input.model; - if (model) args.push("--model", model); - if (input.thinkingLevel && !input.agent.model) args.push("--thinking", input.thinkingLevel); - const tools = normalizeChildTools(input.agent.tools); - if (tools && tools.length > 0) args.push("--tools", tools.join(",")); + const args = buildSubagentChildArgs(input, sessionId); if (input.agent.systemPrompt.trim()) { const promptPath = path.join(tempDir, "system-prompt.md"); await writeFile(promptPath, input.agent.systemPrompt, { encoding: "utf8", mode: 0o600 }); @@ -485,6 +529,7 @@ export async function createSubagentRpcSession( const handle: StepSubagentRpcSession = { sessionId, + permissionKey: subagentPermissionKey(input.permission), isAlive: () => !childExited && !stdinEnded, isTurnActive: () => turn !== undefined, send, diff --git a/packages/coding-agent/src/step/permissions.ts b/packages/coding-agent/src/step/permissions.ts index 31b945c9..e71d3645 100644 --- a/packages/coding-agent/src/step/permissions.ts +++ b/packages/coding-agent/src/step/permissions.ts @@ -7,6 +7,7 @@ */ import type { AgentMessage } from "@step-harness/agent-core"; +import type { EventBus } from "../core/event-bus.ts"; import type { AgentEndEvent, ExtensionContext, @@ -323,6 +324,95 @@ export function resolveInitialStepPermissionState(options: StepPermissionControl return resolved; } +/** + * The explicit policy handed to a subagent child (`--approval-mode`, + * `--non-interactive-approval`, `--tool-override`, and `STEP_AUTO_RESUME`). + * Explicit flags outrank every `STEP_*` env var and persisted preset in the + * child's resolver, so whatever the child inherits cannot loosen this. + */ +export interface StepChildPermissionPolicy { + approvalMode: StepPermissionMode; + nonInteractiveApproval: StepNonInteractiveApproval; + autoResume: boolean; + toolOverrides?: Record; +} + +/** + * Derive a subagent child's policy from the parent's live state. The child must + * never be more permissive than the parent: + * + * - A headless parent passes on the policy it actually enforces: `auto` that + * is refused or unconfigured unattended is downgraded to `confirm`/`deny`, + * the same downgrade StepPermissionController applies to its own calls. + * - A defaulted parent (nothing selected a policy) keeps its mode but never + * hands the child an explicit unattended `allow`: the fallback is `deny`, + * which mirrors the defaulted semantics (permissive only while a UI exists) + * now that the explicit flag takes the child out of the defaulted bucket. + * - `strict` and `confirm` pass through unchanged. The rpc child's blocking + * dialogs are auto-cancelled by the parent, so a child `confirm` blocks. + */ +export function resolveStepChildPermissionPolicy( + state: StepPermissionState, + parentHasUI: boolean, +): StepChildPermissionPolicy { + let approvalMode = state.mode; + let nonInteractiveApproval: StepNonInteractiveApproval = + state.defaulted === true ? "deny" : state.nonInteractiveApproval; + const unattendedRefused = !parentHasUI && (state.nonInteractiveApproval === "deny" || state.defaulted === true); + if (approvalMode === "auto" && unattendedRefused) { + approvalMode = "confirm"; + nonInteractiveApproval = "deny"; + } + const policy: StepChildPermissionPolicy = { + approvalMode, + nonInteractiveApproval, + autoResume: normalizeAutoResume(approvalMode, nonInteractiveApproval, state.autoResume), + }; + if (state.toolOverrides && Object.keys(state.toolOverrides).length > 0) { + policy.toolOverrides = cloneToolOverrides({ ...state.toolOverrides }); + } + return policy; +} + +/** + * Extension event-bus channel the subagent tool uses to read the Step + * extension's live permission state at execution time. The payload carries a + * `reply` callback; the bus dispatches synchronously, so the answer is in + * before `emit` returns. + */ +export const STEP_PERMISSION_STATE_REQUEST = "step:permission-state-request"; + +interface StepPermissionStateRequest { + reply(state: StepPermissionState): void; +} + +/** + * Answer permission-state requests on `events` with the current live state. + * `events` is optional only for partial hosts and test doubles without a bus. + */ +export function answerStepPermissionStateRequests( + events: EventBus | undefined, + getState: () => StepPermissionState, +): () => void { + if (!events) return () => {}; + return events.on(STEP_PERMISSION_STATE_REQUEST, (data) => { + const request = data as Partial | undefined; + if (typeof request?.reply === "function") request.reply(getState()); + }); +} + +/** Read the Step extension's live permission state; undefined when it is not loaded. */ +export function requestStepPermissionState(events: EventBus | undefined): StepPermissionState | undefined { + if (!events) return undefined; + let state: StepPermissionState | undefined; + events.emit(STEP_PERMISSION_STATE_REQUEST, { + reply: (value) => { + state = value; + }, + } satisfies StepPermissionStateRequest); + return state; +} + /** * Decide a tool call without involving the terminal. This is intentionally * conservative for unknown tools: ask mode confirms them, read-only blocks diff --git a/packages/coding-agent/test/subagent-inherit-settings.test.ts b/packages/coding-agent/test/subagent-inherit-settings.test.ts new file mode 100644 index 00000000..995aff25 --- /dev/null +++ b/packages/coding-agent/test/subagent-inherit-settings.test.ts @@ -0,0 +1,311 @@ +/** + * Invariant: a subagent child inherits the parent's model, provider, reasoning + * effort and permission policy unless something explicitly overrides them, and + * its permission policy is never more permissive than the parent's. + * + * Asserted through the args/env the child is actually spawned with, and then + * round-tripped through the child's own flag parser and policy resolver so a + * rename on either side fails here. + */ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseArgs } from "../src/cli/args.ts"; +import { createEventBus } from "../src/core/event-bus.ts"; +import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "../src/core/extensions/types.ts"; +import { + createStepSubagentExtension, + emptyUsage, + runStepSubagentProcess, + type StepSubagentRunInput, +} from "../src/features/step-subagent.ts"; +import type { StepAgentConfig } from "../src/features/step-subagent-agents.ts"; +import { liveSubagentSessions } from "../src/features/subagent/lane-lifecycle.ts"; +import { + buildSubagentChildArgs, + buildSubagentChildEnv, + subagentPermissionKey, +} from "../src/features/subagent/rpc-adapter.ts"; +import { + answerStepPermissionStateRequests, + decideStepToolCall, + requestStepPermissionState, + resolveInitialStepPermissionState, + resolveStepChildPermissionPolicy, + type StepPermissionState, + stepPermissionStateForPreset, +} from "../src/step/permissions.ts"; + +const agent: StepAgentConfig = { + name: "general", + description: "test agent", + systemPrompt: "", + source: "builtin", +}; + +function runInput(overrides: Partial = {}): StepSubagentRunInput { + return { agent, task: "do the thing", cwd: process.cwd(), ...overrides }; +} + +function flag(args: readonly string[], name: string): string | undefined { + const index = args.indexOf(name); + return index === -1 ? undefined : args[index + 1]; +} + +/** + * Resolve the policy the child will actually run under: parse its argv the way + * the Step launcher does and resolve against its env. A hostile inherited env + * (a permissive preset + autopilot) proves the flags win over it. + */ +function childPolicy(parent: StepPermissionState, parentHasUI: boolean): StepPermissionState { + const input = runInput({ permission: resolveStepChildPermissionPolicy(parent, parentHasUI) }); + vi.stubEnv("STEP_PERMISSION_PRESET", "bypass"); + vi.stubEnv("STEP_AUTOPILOT", "1"); + const env = buildSubagentChildEnv(input); + const parsed = parseArgs(buildSubagentChildArgs(input, "child-session")); + return resolveInitialStepPermissionState({ + approvalMode: parsed.approvalMode, + nonInteractiveApproval: parsed.nonInteractiveApproval, + toolOverrides: parsed.toolOverrides, + env, + }); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("subagent child permission policy", () => { + it("parent read-only -> child read-only", () => { + const child = childPolicy(stepPermissionStateForPreset("read-only"), true); + expect(child.mode).toBe("strict"); + expect(child.preset).toBe("read-only"); + expect(child.nonInteractiveApproval).toBe("deny"); + expect(child.autoResume).toBe(false); + }); + + it("parent ask -> child confirms, and denies unattended mutating calls", () => { + const child = childPolicy(stepPermissionStateForPreset("ask"), true); + expect(child.mode).toBe("confirm"); + expect(child.nonInteractiveApproval).toBe("deny"); + // The rpc child's confirm dialogs are auto-cancelled by the parent, so a + // confirm decision is a block, never an unattended allow. + expect(decideStepToolCall("write_file", { path: "x" }, child).action).toBe("confirm"); + }); + + it("parent explicitly chose bypass -> child bypass", () => { + const child = childPolicy(stepPermissionStateForPreset("bypass"), true); + expect(child.mode).toBe("auto"); + expect(child.nonInteractiveApproval).toBe("allow"); + expect(child.preset).toBe("bypass"); + expect(child.autoResume).toBe(false); + }); + + it("parent autopilot -> child autopilot", () => { + const child = childPolicy(stepPermissionStateForPreset("autopilot"), true); + expect(child.preset).toBe("autopilot"); + expect(child.autoResume).toBe(true); + }); + + it("a defaulted interactive parent never hands the child an unattended allow", () => { + const parent = resolveInitialStepPermissionState({ env: {} }); + expect(parent.defaulted).toBe(true); + const child = childPolicy(parent, true); + expect(child.mode).toBe("auto"); + expect(child.nonInteractiveApproval).toBe("deny"); + expect(child.defaulted).toBeUndefined(); + }); + + it("a headless parent passes on its downgraded effective policy", () => { + const defaulted = resolveInitialStepPermissionState({ env: {} }); + expect(childPolicy(defaulted, false).mode).toBe("confirm"); + const refused = resolveInitialStepPermissionState({ + approvalMode: "auto", + nonInteractiveApproval: "deny", + env: {}, + }); + expect(childPolicy(refused, false).mode).toBe("confirm"); + // An explicit unattended allow stays an allow. + expect(childPolicy(stepPermissionStateForPreset("bypass"), false).mode).toBe("auto"); + }); + + it("tool overrides reach the child", () => { + const parent: StepPermissionState = { + ...stepPermissionStateForPreset("bypass"), + toolOverrides: { run_command: "deny", write_file: "confirm" }, + }; + expect(childPolicy(parent, true).toolOverrides).toEqual({ run_command: "deny", write_file: "confirm" }); + }); + + it("no parent policy -> no flags (embedders without the Step extension)", () => { + const args = buildSubagentChildArgs(runInput(), "child-session"); + expect(args).not.toContain("--approval-mode"); + expect(args).not.toContain("--non-interactive-approval"); + }); + + it("the subagent tool reads the parent's live state over the extension event bus", () => { + const events = createEventBus(); + expect(requestStepPermissionState(events)).toBeUndefined(); + let state = stepPermissionStateForPreset("bypass"); + answerStepPermissionStateRequests(events, () => state); + expect(requestStepPermissionState(events)?.preset).toBe("bypass"); + state = stepPermissionStateForPreset("read-only"); + expect(requestStepPermissionState(events)?.preset).toBe("read-only"); + }); +}); + +describe("subagent child model and thinking", () => { + it("inherits the parent's qualified model and thinking level", () => { + const args = buildSubagentChildArgs( + runInput({ model: "stepfun/step-3", thinkingLevel: "high" }), + "child-session", + ); + expect(flag(args, "--model")).toBe("stepfun/step-3"); + expect(flag(args, "--thinking")).toBe("high"); + }); + + it("agent file sets a model but no thinking -> child gets the parent's thinking", () => { + const args = buildSubagentChildArgs( + runInput({ + agent: { ...agent, model: "openai/gpt-5" }, + model: "stepfun/step-3", + thinkingLevel: "high", + }), + "child-session", + ); + expect(flag(args, "--model")).toBe("openai/gpt-5"); + expect(flag(args, "--thinking")).toBe("high"); + }); + + it("agent declares thinking via its model suffix -> that wins", () => { + const args = buildSubagentChildArgs( + runInput({ + agent: { ...agent, model: "openai/gpt-5:low" }, + model: "stepfun/step-3", + thinkingLevel: "high", + }), + "child-session", + ); + expect(flag(args, "--model")).toBe("openai/gpt-5:low"); + // `--thinking` would override the suffix in the child's resolver. + expect(args).not.toContain("--thinking"); + expect(parseArgs(args).thinking).toBeUndefined(); + }); + + it("a non-thinking colon suffix is part of the model id, not a declaration", () => { + const args = buildSubagentChildArgs( + runInput({ agent: { ...agent, model: "ollama/llama3:8b" }, thinkingLevel: "medium" }), + "child-session", + ); + expect(flag(args, "--thinking")).toBe("medium"); + }); +}); + +describe("subagent tool wiring", () => { + it("hands the runner the parent's live policy, qualified model and thinking level", async () => { + const events = createEventBus(); + let parentState = stepPermissionStateForPreset("ask"); + answerStepPermissionStateRequests(events, () => parentState); + const tools = new Map(); + const api = { + events, + registerTool: (tool: ToolDefinition) => tools.set(tool.name, tool), + registerCommand: () => {}, + registerFlag: () => {}, + registerShortcut: () => {}, + on: () => {}, + getFlag: () => false, + } as unknown as ExtensionAPI; + const inputs: StepSubagentRunInput[] = []; + createStepSubagentExtension({ + agentDir: "/tmp/step-agent-inherit-test", + runner: async (input) => { + inputs.push(input); + return { messages: [], stderr: "", exitCode: 0, usage: emptyUsage() }; + }, + })(api); + const ctx = { + mode: "tui", + hasUI: true, + cwd: process.cwd(), + model: { provider: "stepfun", id: "step-3" }, + thinkingLevel: "high", + isProjectTrusted: () => true, + ui: { setWidget: () => {}, setStatus: () => {}, notify: () => {} }, + } as unknown as ExtensionContext; + const execute = (): Promise => + tools.get("subagent")!.execute("call", { agent: "general", task: "go" }, undefined, undefined, ctx); + + await execute(); + expect(inputs[0]?.model).toBe("stepfun/step-3"); + expect(inputs[0]?.thinkingLevel).toBe("high"); + expect(inputs[0]?.permission).toEqual({ + approvalMode: "confirm", + nonInteractiveApproval: "deny", + autoResume: false, + }); + + // A preset switched mid-session reaches the next child. + parentState = stepPermissionStateForPreset("read-only"); + await execute(); + expect(inputs[1]?.permission?.approvalMode).toBe("strict"); + }); +}); + +describe("keep-alive subagent lanes", () => { + it("replace an idle child whose spawn-time policy no longer matches the parent's", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "step-subagent-inherit-")); + // Stand-in for `step --mode rpc`: `currentStepInvocation` reuses argv[1]. + const script = path.join(dir, "fake-child.mjs"); + await writeFile( + script, + `let buffer = ""; +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split("\\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.trim()) continue; + const command = JSON.parse(line); + if (command.type !== "prompt") continue; + process.stdout.write(JSON.stringify({ type: "response", id: command.id, command: "prompt", success: true }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "agent_settled" }) + "\\n"); + } +}); +process.stdin.on("end", () => process.exit(0)); +`, + "utf8", + ); + const originalArgv1 = process.argv[1]; + process.argv[1] = script; + const sessionId = `inherit-keepalive-${Date.now()}`; + try { + const bypass = resolveStepChildPermissionPolicy(stepPermissionStateForPreset("bypass"), true); + const readOnly = resolveStepChildPermissionPolicy(stepPermissionStateForPreset("read-only"), true); + const respawns: string[] = []; + const turn = (permission: typeof bypass, label: string) => + runStepSubagentProcess( + runInput({ sessionId, keepAlive: true, permission, onChildRespawn: () => respawns.push(label) }), + ); + + await turn(bypass, "first"); + const firstChild = liveSubagentSessions.get(sessionId); + expect(firstChild?.permissionKey).toBe(subagentPermissionKey(bypass)); + await turn(bypass, "same policy"); + expect(liveSubagentSessions.get(sessionId)).toBe(firstChild); + expect(respawns).toEqual([]); + + await turn(readOnly, "tightened"); + expect(respawns).toEqual(["tightened"]); + const replaced = liveSubagentSessions.get(sessionId); + expect(replaced).not.toBe(firstChild); + expect(replaced?.permissionKey).toBe(subagentPermissionKey(readOnly)); + replaced?.stop(); + } finally { + process.argv[1] = originalArgv1; + liveSubagentSessions.clear(); + await rm(dir, { recursive: true, force: true }); + } + }, 20_000); +});