fix(subagent): inherit parent policy overrides in continuable children

A continuable background child (the default backgroundMode for both
delegation tools) never received the parent session's explicit
sandbox/approval overrides: materialization applied only child
composition, so a danger-full-access parent produced workspace-write
children whose every out-of-workspace operation raised an approval
prompt.

Move the one-shot driver's capture/append pair into the shared
child-agent module (captureDelegatedPolicyOverrides /
appendDelegatedPolicyOverrides) and call it from both paths:
startContinuable captures before its first await, only fresh
materialization appends the source-tagged events (after any fork seed),
and a cold resume replays the persisted delegation events instead of
re-capturing the parent.

Adds the continuable inheritance unit suite, the ACP snapshot scenario
subagent-continuable-inheritance (fails without the fix), the
continuable policy-inheritance Agent Note, and the seam-level README
contract, with bilingual counterparts.

Fixes #1692
This commit is contained in:
Hypatia May
2026-08-10 12:16:19 +08:00
parent abaf8f5061
commit 64e0fbfd6d
36 changed files with 1106 additions and 61 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md
README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d
README.zh.md: 648a160be5f1c3dcbe66a867a273a3df610dbc0a
README.md: 4189979806ccd4e7dcfee231ab3e9e2331550b0d
README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32

View File

@@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p
This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output.
When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
## Cancellation and ownership

View File

@@ -20,7 +20,7 @@
该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering中途引导属于子运行提供方不会声称输出只归初始 follow-up 所有。
当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
## 取消与所有权

View File

@@ -17,8 +17,10 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import {
appendDelegatedPolicyOverrides,
applyChildComposition,
assertSubagentMaxDepth,
captureDelegatedPolicyOverrides,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
@@ -30,11 +32,6 @@ import type {
SubagentRun,
SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — the driver consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import {
attachStructuredRuntime,
type StructuredAttachment,
@@ -111,20 +108,11 @@ export async function startInProcessRun(
// Capture before the first await: a later parent switch belongs to the
// parent's future.
const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
const inherited = captureDelegatedPolicyOverrides(parent)
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
// Inherited overrides land on the child's own log, so its effective policy
// is reconstructable from that log alone.
const childSession = (childCtx.agent as Agent).session
if (inheritedMode !== undefined) {
childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
}
if (inheritedPolicy !== undefined) {
childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
}
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)
applyChildComposition(childCtx, {
persona: request.persona,
toolFilter: request.toolFilter,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
README.md: 762030629c09305c48adebc71244655a5faa6585
README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff
README.md: 6cea175de3d07290b293ad55ccbe60d7d918d37c
README.zh.md: c5fecd554357146d317b5f75824f40a1cb976f2c

View File

@@ -52,6 +52,10 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th
`inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority.
## Delegated policy inheritance
Both in-process delegation paths seed the parent's explicit policy overrides into the child through the shared child-agent helpers: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf()` and `approval.overrideOf()` synchronously at the delegation boundary (both services are optional `ctx.get` consumers), and `appendDelegatedPolicyOverrides()` writes each captured value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state, a later child switch wins the snapshot, and the child's effective policy stays reconstructable from its log alone. Deployment defaults are never copied: an unswitched parent stamps nothing and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) policy-inheritance Agent Notes.
## One-shot ownership and lifecycle
`provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.

View File

@@ -52,6 +52,10 @@ subagent seam 允许一个 agent智能体通过具名提供方把工作委
`inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
## 委派策略继承
两条进程内委派路径都会通过共享的子 agent 辅助函数,把父级的显式策略覆盖项作为种子注入子 agent`captureDelegatedPolicyOverrides(parent)` 在委派边界同步对 `sandboxPolicy.overrideOf()``approval.overrideOf()` 获取快照(这两个服务都是可选的 `ctx.get` 消费方),`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个捕获值作为一条 `source: 'delegation'``sandbox/mode``approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,子 agent 后续的切换压过该快照,而子 agent 的生效策略始终可以仅凭其日志重建。部署默认值绝不复制:未切换的父级不会记录任何值,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇策略继承 Agent Note。
## 一次性所有权与生命周期
`provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`

View File

@@ -37,6 +37,8 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
@@ -44,9 +46,16 @@
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-sandbox": {
"optional": true
},
"@deepseek-ai/dsh-sandbox-policy": {
"optional": true
},
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
@@ -58,6 +67,9 @@
},
"@deepseek-ai/dsh-tasks": {
"optional": true
},
"@deepseek-ai/dsh-user-approval": {
"optional": true
}
},
"devDependencies": {
@@ -65,6 +77,8 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
@@ -74,6 +88,7 @@
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,17 +1,23 @@
/**
* Shared in-process child composition: the delegation-depth budget, the
* durable session metadata, the resolved child `AgentOptions`, and the scoped
* setup a child agent needs. Both the one-shot provider driver and the
* continuation manager compose children this way, so depth accounting and
* lineage stamping have one home.
* durable session metadata, the resolved child `AgentOptions`, the delegated
* policy snapshot, and the scoped setup a child agent needs. Both the one-shot
* provider driver and the continuation manager compose children this way, so
* depth accounting, lineage stamping, and policy inheritance have one home.
*
* @module @deepseek-ai/dsh-subagent/child-agent
*/
import type { Context } from 'cordis'
import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
// to the policy services when composed — delegation consumes both
// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { delegationDepthOf } from './depth.ts'
/** Thrown when starting a child would exceed the requested depth cap. */
@@ -119,6 +125,51 @@ export function applyChildComposition(childCtx: Context, composition: ChildCompo
if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter)
}
/** Parent-session policy overrides captured at the delegation boundary. */
export interface DelegatedPolicyOverrides {
/** The parent session's explicit sandbox-mode override, or `undefined` without one. */
readonly sandboxMode: SandboxMode | undefined
/** The parent session's explicit approval-policy override, or `undefined` without one. */
readonly approvalPolicy: ApprovalPolicy | undefined
}
/**
* Capture the parent session's explicit policy overrides for one delegation.
* Call synchronously before the child start's first await: a later parent
* switch belongs to the parent's future, not to this child. Deployment
* defaults and one-shot grants are never captured, so an unswitched parent
* leaves the child following the deployment default dynamically.
* @param parent - the delegating parent agent.
* @returns the overrides to seed into the child, each `undefined` without one.
*/
export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {
return {
sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),
approvalPolicy: parent.ctx.get('approval')?.overrideOf(parent.session),
}
}
/**
* Append captured parent overrides onto the child's own log as
* `source: 'delegation'` events inside the unpublished creation window, so the
* child's effective policy is reconstructable from its log alone. Appends land
* after any fork seed, so fresh policy wins stale seed state; later child
* switches still win over these events.
* @param childSession - the unpublished child's session.
* @param overrides - the overrides captured at delegation.
*/
export function appendDelegatedPolicyOverrides(
childSession: Session,
overrides: DelegatedPolicyOverrides,
): void {
if (overrides.sandboxMode !== undefined) {
childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' })
}
if (overrides.approvalPolicy !== undefined) {
childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' })
}
}
/** Identity and lineage inputs shared by every in-process child creation. */
export interface ChildCreateInputs {
/** The child's reserved session id. */

View File

@@ -32,11 +32,14 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
import type { SubagentDescriptorData } from './descriptor.ts'
import {
appendDelegatedPolicyOverrides,
applyChildComposition,
captureDelegatedPolicyOverrides,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
} from './child-agent.ts'
import type { DelegatedPolicyOverrides } from './child-agent.ts'
import { assertSubagentMaxDepth } from './depth.ts'
import { seedDescriptorTurn } from './descriptor-seed.ts'
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
@@ -203,8 +206,17 @@ interface MaterializeInputs {
childId: SessionId
provider: string
parent: Agent
/** Creation inputs; absent for a cold resume, which loads the persisted session. */
create?: { seed: readonly SessionEvent[]; meta: NonNullable<CreateAgentOptions['meta']> }
/**
* Creation inputs; absent for a cold resume, which loads the persisted
* session — including the delegation policy events a fresh creation seeded,
* so a resume never re-captures the parent's policy.
*/
create?: {
seed: readonly SessionEvent[]
meta: NonNullable<CreateAgentOptions['meta']>
/** Parent policy overrides captured at the delegation boundary. */
inheritedPolicies: DelegatedPolicyOverrides
}
agentOptions: AgentOptions
composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
signal: AbortSignal
@@ -341,6 +353,9 @@ export class SubagentContinuationManager {
...request.persona !== undefined ? { persona: request.persona } : {},
...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
})
// Capture before the first await: a later parent switch belongs to the
// parent's future, not to this child.
const inheritedPolicies = captureDelegatedPolicyOverrides(parent)
const prepared = await this.host.prepareContinuable(spec.provider, {
sessionId: childId,
@@ -357,7 +372,7 @@ export class SubagentContinuationManager {
childId,
provider: spec.provider,
parent,
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) },
create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), inheritedPolicies },
agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
composition: { persona: request.persona, toolFilter: request.toolFilter },
signal: spec.signal,
@@ -878,18 +893,23 @@ export class SubagentContinuationManager {
inputs: MaterializeInputs,
parentLineage: readonly Agent[],
): Promise<Activation> {
const { childId, provider, parent } = inputs
const { childId, provider, parent, create } = inputs
// No id pre-check here: the child lock serializes each durable child, both
// callers reach this only after confirming no Activation exists, and
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
// some other owner holds — a duplicate would reject there with rollback.
inputs.signal.throwIfAborted()
const setup = (childCtx: Context): AgentSetupCommit => {
// Only fresh creation seeds captured parent policy onto the child's own
// log (after any fork seed, so fresh policy wins stale seed state); a
// cold resume replays those persisted events instead.
if (create !== undefined) {
appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.inheritedPolicies)
}
applyChildComposition(childCtx, inputs.composition)
return this.setupRegistry.apply(childCtx)
}
const observer = this.host.observeActivation(provider, childId, parent)
const { create } = inputs
// Agent creation owns rollback before handle transfer. A rejection leaves
// no resident Activation and therefore publishes no lifecycle edge.
const handle: AgentHandle = create === undefined

View File

@@ -100,13 +100,15 @@ export { SubagentError } from './error.ts'
export { settleRun } from './run-settlement.ts'
export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts'
export {
appendDelegatedPolicyOverrides,
applyChildComposition,
captureDelegatedPolicyOverrides,
childSessionMeta,
resolveChildAgentOptions,
resolveChildDepth,
SubagentDepthError,
} from './child-agent.ts'
export type { ChildComposition } from './child-agent.ts'
export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts'
export type {
ContinuableStart,
ContinuableStartSpec,

View File

@@ -0,0 +1,171 @@
/**
* Continuable-child policy inheritance: a fresh continuable start seeds the
* parent's explicit sandbox/approval overrides onto the child's own log as
* `source: 'delegation'` events, and a cold resume replays that persisted
* snapshot instead of re-capturing the parent (the one-shot
* `subagent-inprocess/tests/inheritance.spec.ts` counterpart).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import ApprovalService, { effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentService from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** Boot the continuable stack plus both policy services the manager consumes opportunistically. */
async function setup(script: Script) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root })
await ctx.plugin(ApprovalService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent }
}
function startSpec(parent: Agent, provider = 'spawn') {
return {
provider,
label: 'child task',
request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
signal: new AbortController().signal,
}
}
/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
await vi.waitFor(() => {
expect(ctx.agents.get(childId)).toBeUndefined()
}, { timeout: 5_000 })
}
function policyEvents(events: readonly SessionEvent[]) {
return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy')
}
describe('continuable policy inheritance', () => {
it('seeds parent overrides into a fresh continuable child', async () => {
const { ctx, parent } = await setup([textResponse('child done')])
setSandboxMode(parent.session, 'danger-full-access')
setApprovalPolicy(parent.session, 'never')
let child: Agent | undefined
ctx.on('agent/created', ({ agent }) => {
if (agent !== parent) child = agent
})
const started = await ctx.subagents.startContinuable(startSpec(parent))
// The delegation events are appended in the creation window, so they are
// already the child's effective policy at inbox acceptance.
if (child === undefined) throw new Error('expected the continuable child to be created')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
expect(ctx.approval.overrideOf(child.session)).toBe('never')
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } },
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
// Durable: a reload folds the same effective policy.
expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access')
expect(effectiveApprovalPolicy(loaded.events)).toBe('never')
})
it('captures policy at delegation before asynchronous child creation', async () => {
const { ctx, parent } = await setup([textResponse('child done')])
setSandboxMode(parent.session, 'read-only')
const starting = ctx.subagents.startContinuable(startSpec(parent))
// A parent switch after the synchronous capture belongs to the parent's
// future, not to this child.
setSandboxMode(parent.session, 'danger-full-access')
const started = await starting
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
})
it('does not freeze deployment defaults into an unswitched child', async () => {
const { ctx, parent } = await setup([textResponse('child done')])
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(policyEvents(loaded.events)).toEqual([])
})
it('cold-resumes on the persisted snapshot without re-capturing the parent', async () => {
const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')])
setSandboxMode(parent.session, 'read-only')
const started = await ctx.subagents.startContinuable(startSpec(parent))
await waitNoActivation(ctx, started.childId)
// The parent widens AFTER the child was created; the resumed child keeps
// the delegation-time snapshot from its own log.
setSandboxMode(parent.session, 'danger-full-access')
await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], {
source: { kind: 'user' },
signal: new AbortController().signal,
})
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ data: { mode: 'read-only', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
})
it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')])
// The stale mode lands inside the completed turn the fork seed replays.
setSandboxMode(parent.session, 'workspace-write')
parent.followup(createUserMessage({
content: [{ type: 'text', text: 'parent work' }],
source: { kind: 'user' },
}))
await parent.whenIdle()
setSandboxMode(parent.session, 'read-only')
const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork'))
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(loaded.meta.seedLength).toBeGreaterThan(0)
expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ data: { mode: 'workspace-write' } },
{ data: { mode: 'read-only', source: 'delegation' } },
])
expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
})
})

View File

@@ -26,6 +26,15 @@
{
"path": "../../core/scope"
},
{
"path": "../../interaction/user-approval"
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../session/session-persistence"
},