refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,20 @@
// A preset row standing in for the agent-plane tool rows a real preset mounts.
// Import-free on purpose — the Loader resolves entry modules through Node's ESM
// resolver, which cannot see this workspace's TypeScript sources.
export const name = 'preset-tool'
export const inject = ['tools', 'systemPrompt']
export function apply(ctx, config) {
ctx.effect(() => ctx.tools.register({
name: config.tool,
description: `fixture tool ${config.tool}`,
parameters: { type: 'object', properties: {}, additionalProperties: false },
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] },
execute: () => Promise.resolve(config.tool),
}))
ctx.effect(() => ctx.systemPrompt.section({
name: `preset:${config.tool}`,
order: 10,
text: `section for ${config.tool}`,
}))
}

View File

@@ -0,0 +1,5 @@
# Agent-plane composition: the model-facing row lives here, not in the host.
- id: only
name: ../../plugins/preset-tool.js
config:
tool: preset_only

View File

@@ -0,0 +1,6 @@
# A second agent-plane composition, so a switch is a real switch: the tool a
# joined child sees has to change with it.
- id: only
name: ../../plugins/preset-tool.js
config:
tool: reviewing_only

View File

@@ -0,0 +1,255 @@
/**
* Delegation policy through child session events appended before publication:
* the parent's sandbox override plus the pinned `approval/policy: never`.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/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 SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
const READ_ONLY_DENIAL = '[sandbox: file access denied under read-only mode]'
const contexts: Context[] = []
let workspace: string
beforeEach(async () => {
workspace = await realpath(await mkdtemp(join(tmpdir(), 'dsh-inherit-')))
})
afterEach(async () => {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
await rm(workspace, { recursive: true, force: true })
})
async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agent }> {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
await ctx.plugin(ToolFs)
await ctx.plugin(ApprovalService)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
const parent = ctx.agentLoop.create(
SessionId('parent'),
{ provider: 'mock', model: 'mock' },
{ cwd: workspace },
)
return { ctx, parent }
}
function spawnRequest(parent: Agent) {
return {
label: 'child task',
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal: new AbortController().signal,
descriptor: snapshotSubagentDescriptor({
mode: 'one-shot',
provider: 'spawn',
label: 'child task',
}),
}
}
function toolResultTexts(agent: Agent): string[] {
return agent.session.events
.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
.map(event => event.data.message.content
.flatMap(block => block.content)
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join(''))
}
describe('in-process policy inheritance', () => {
it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'spawn-blocked.txt')
setSandboxMode(parent.session, 'read-only')
// No parent approval override: the child pin must not depend on one.
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
const parentLogLength = parent.session.events.length
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
const result = await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL)
expect(result.stopReason).toBe('completed')
expect(child.session.events.slice(0, 2)).toMatchObject([
{ type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } },
{ type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(0)
expect(child.session.header.seedLength).toBeUndefined()
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
expect(ctx.approval.overrideOf(child.session)).toBe('never')
const request = child.session.events.find(
(event): event is SessionEvent<'request/header'> => event.type === 'request/header',
)
const runtimeContext = child.session.events.find(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
)
if (request === undefined || runtimeContext === undefined) throw new Error('child request lacks its runtime policy context')
expect(runtimeContext.seq).toBeLessThan(request.seq)
const contextText = runtimeContext.data.content
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('\n')
expect(contextText).toContain('Current DSH file policy: read-only')
expect(contextText).toContain('Approval prompts are disabled')
// The statement rides runtime context; the system prompt stays uniform.
expect(contextText).toContain('You are a delegated subagent')
expect(request.data.header.system).not.toContain('Approval prompts are disabled')
expect(request.data.header.system).not.toContain('You are a delegated subagent')
expect(parent.session.events).toHaveLength(parentLogLength)
} finally {
await run.dispose()
}
})
it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
const blocked = join(workspace, 'fork-blocked.txt')
setSandboxMode(parent.session, 'workspace-write')
const seed = [...parent.session.events]
setSandboxMode(parent.session, 'read-only')
script.push(
toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), { seed })
try {
await run.result
const child = run.localAgent as Agent
expect(child.session.header.seedLength).toBe(1)
expect(child.session.firstLiveSeq).toBe(seed.length)
// seq 1 is the constructor's end-seed marker.
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ seq: 0, data: { mode: 'workspace-write' } },
{ seq: 2, data: { mode: 'read-only', source: 'delegation' } },
])
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
setSandboxMode(child.session, 'danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
} finally {
await run.dispose()
}
})
it('captures policy at delegation before asynchronous child creation', async () => {
const script: Script = [textResponse('child done')]
const { ctx, parent } = await setupWalled(script)
setSandboxMode(parent.session, 'read-only')
const starting = startInProcessRun(spawnRequest(parent), {})
setSandboxMode(parent.session, 'danger-full-access')
const run = await starting
try {
await run.result
const child = run.localAgent as Agent
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
} finally {
await run.dispose()
}
})
it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => {
const script: Script = []
const { parent } = await setupWalled(script)
const allowed = join(workspace, 'default-allowed.txt')
script.push(
toolCallResponse('write', 'write', { file_path: allowed, content: 'fine' }),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
await run.result
const child = run.localAgent as Agent
expect(await readFile(allowed, 'utf8')).toBe('fine')
expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false)
expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([
{ seq: 0, data: { policy: 'never', source: 'delegation' } },
])
expect(child.session.firstLiveSeq).toBe(0)
} finally {
await run.dispose()
}
})
it('rejects a child escalation deterministically even when an answerer would allow it', async () => {
const script: Script = []
const { ctx, parent } = await setupWalled(script)
// A granting answerer proves the pin resolves before any answerer runs.
let consulted = false
ctx.on('approval/request', () => {
consulted = true
return Promise.resolve('allowed-once' as const)
})
const blocked = join(workspace, 'escalation-blocked.txt')
setSandboxMode(parent.session, 'read-only')
script.push(
toolCallResponse('write', 'write', {
file_path: blocked,
content: 'escaped',
sandbox_permissions: 'workspace-write',
justification: 'test escalation from a delegated child',
}),
textResponse('child done'),
)
const run = await startInProcessRun(spawnRequest(parent), {})
try {
await run.result
const child = run.localAgent as Agent
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
expect(consulted).toBe(false)
expect(toolResultTexts(child).join('\n'))
.toContain('the user rejected escalating this operation to "workspace-write"')
const asked = child.session.events.find(
(event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked',
)
const decided = child.session.events.find(
(event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided',
)
expect(asked?.data.toolName).toBe('write')
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
} finally {
await run.dispose()
}
})
})

View File

@@ -0,0 +1,135 @@
/**
* Composition inheritance: a child runs on the preset its parent runs on.
*
* With every model-facing row on the agent plane, the tool registry's global
* layer is empty, so a child that joins no preset reaches the model with no
* tools at all. These assert the model-visible result — the schemas in the
* child's own request — rather than the join that produces it.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
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 AgentPresets from '@deepseek-ai/dsh-agent-presets'
import { SessionId } from '@deepseek-ai/dsh-session'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const ROOTS = [{ path: join(FIXTURES, 'presets'), trust: 'system' as const }]
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
})
/** A host composition carrying no model-facing rows, plus the preset roster. */
async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; parent: Agent }> {
const ctx = new Context()
contexts.push(ctx)
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false })
const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('parent'),
agentOptions: { provider: 'mock', model: 'mock' },
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'coding'),
})
return { ctx, adapter, parent: handle.agent }
}
/** The one-shot spawn request shape both in-process providers build. */
function spawnRequest(parent: Agent) {
return {
label: 'child task',
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal: new AbortController().signal,
descriptor: snapshotSubagentDescriptor({
mode: 'one-shot' as const,
provider: 'spawn',
label: 'child task',
}),
}
}
describe('a child agent composed in-process', () => {
it('reaches the model with its parent\'s preset tools', async () => {
const { ctx, adapter, parent } = await setupPresetHost()
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
const childRequest = adapter.requests.at(-1)
expect(childRequest?.tools?.map(tool => tool.name)).toEqual(['preset_only'])
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['preset_only'])
await run.dispose()
})
it('carries its parent\'s prompt sections', async () => {
const { parent } = await setupPresetHost()
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
expect(run.localAgent?.session.events.some(event =>
event.type === 'request/header'
&& JSON.stringify(event.data).includes('section for preset_only'))).toBe(true)
await run.dispose()
})
it('records the composition it ran under on the child header', async () => {
const { parent } = await setupPresetHost()
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
// Without this the child's own history reads back under the deployment
// default, which is a different tool set than the one it actually used.
expect(run.localAgent?.session.header.agentPreset).toBe('coding')
await run.dispose()
})
it('honours a tool filter over the preset tools it inherited', async () => {
const { ctx, parent } = await setupPresetHost()
const run = await startInProcessRun(
{ ...spawnRequest(parent), toolFilter: { deny: ['preset_only'] } },
{},
)
await run.result
// The capability filter is the only thing bounding a delegated child, and
// every tool it can name now arrives from the preset rather than the host.
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual([])
await run.dispose()
})
it('follows a parent that switched preset while blank', async () => {
const { ctx, parent } = await setupPresetHost()
// A DIFFERENT preset, so the assertion below distinguishes reading the
// parent's live scope chain from reading its creation header — re-linking
// to the same id would pass either way.
await ctx.agentPresets.recompose(parent.ctx, 'reviewing')
const run = await startInProcessRun(spawnRequest(parent), {})
await run.result
expect(ctx.tools.schemas(run.localAgent).map(schema => schema.name)).toEqual(['reviewing_only'])
expect(run.localAgent?.session.header.agentPreset).toBe('reviewing')
await run.dispose()
})
})

View File

@@ -0,0 +1,757 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentRuntime, {
type ResolvedSubagentStartRequest,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
import {
STRUCTURED_OUTPUT_INSTRUCTION,
STRUCTURED_OUTPUT_TOOL,
} from '../src/structured.ts'
const testToolSignal = new AbortController().signal
type Script = ConstructorParameters<typeof MockAdapter>[0]
async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(InvariantRegistry)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
interface CodeRunRequestLike {
bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
}
interface SetupOptions {
toolMode?: ToolConfig['mode']
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
}
const SCHEMA: ObjectJsonSchema = {
type: 'object',
properties: { answer: { type: 'number' }, note: { type: 'string' } },
required: ['answer'],
}
/**
* Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading
* spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while
* this fixture isolates driver behavior and scripts the child's `structured_output` calls.
*/
async function setup(script: Script, options: SetupOptions = {}) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await mountAgentLoopTestDependencies(ctx, {
tools: { mode: options.toolMode ?? 'native' },
})
if (options.toolMode === 'code' || options.toolMode === 'both') {
ctx.provide('codeRuntime', {
language: 'typescript',
isolation: 'test',
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
} as never)
}
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentRuntime)
const disposeProvider = ctx.subagents.registerProvider({
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}),
})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
return { ctx, parent, adapter, disposeProvider }
}
function structuredRequest(parent: SubagentStartRequest['parent'], extra?: Partial<SubagentStartRequest>): SubagentStartRequest {
return {
label: 'produce the answer',
prompt: [{ type: 'text', text: 'produce the answer' }],
parent,
signal: new AbortController().signal,
outputSchema: SCHEMA,
...extra,
}
}
/** The tool names of one recorded model request. */
function toolNames(request: GenerateOptions): string[] {
return (request.tools ?? []).map(tool => tool.name)
}
describe('in-process structured output', () => {
it('captures a valid structured_output call and surfaces result.structured', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42, note: 'done' }),
])
let acknowledgement: unknown
ctx.on('tools/result', (exec, toolResult) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && !toolResult.isError) acknowledgement = toolResult.value
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 42, note: 'done' })
expect(acknowledgement).toEqual({ recorded: true })
await run.dispose()
})
it('stops the turn after a successful capture — no extra model step is spent', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
textResponse('MUST NOT BE CONSUMED'),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// The structured tool marks its successful result as turn-concluding.
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
it('denies tool calls that FOLLOW the capture in the same response — terminal means terminal', async () => {
// One model response carrying structured_output FIRST and a side-effecting
// call after it: the continuation veto only fires at step end, so without
// the pre-execute deny the trailing call would still run after the final
// answer was accepted.
const response = [
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 5 })
// The deny skipped dispatch entirely: the probe body never ran.
expect(sideEffectRan).toBe(false)
await run.dispose()
})
it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
const response = [
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after the child and prepended: this listener returns allow
// after every downstream pre-execute decision. The service-owned guard
// runs after the waterfall and can only deny, so the body still cannot run.
ctx.on('tools/pre-execute', async (_exec, next) => {
await next()
return { kind: 'allow' as const }
}, { prepend: true })
const result = await run.result
expect(result.structured).toEqual({ answer: 5 })
expect(sideEffectRan).toBe(false)
const child = ctx.agents.get(run.id)
const sideEffectResult = child?.session.events.find(event =>
event.type === 'tool/result' && event.data.message.source.callId === 'c2')
expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
const response = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'side_effect', arguments: '{}' } },
...toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 6 }).map(chunk =>
'index' in chunk ? { ...chunk, index: 1 } : chunk),
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register(defineContentToolFixture({
name: 'side_effect',
description: 'probe',
parameters: {},
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
}))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// The call ran BEFORE captured was set: the deny gate only guards the
// window after the terminal answer landed.
expect(sideEffectRan).toBe(true)
expect(result.structured).toEqual({ answer: 6 })
await run.dispose()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
// The child's log carries the isError tool/result for the invalid call.
const child = ctx.agents.get(run.id)!
const results = child.session.events.filter(e => e.type === 'tool/result')
expect(results.length).toBe(2)
expect(results[0]!.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('a clean finish without a capture is an immediate error to the parent — deliberately NO re-prompt', async () => {
const { ctx, parent, adapter } = await setup([
textResponse('here is my answer in prose'),
textResponse('MUST NOT BE CONSUMED'),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// Exactly one model request and one caller-supplied user message: no nudge turn exists.
expect(adapter.requests.length).toBe(1)
const child = ctx.agents.get(run.id)!
expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1)
await run.dispose()
})
it('an errored child keeps its honest error result (no capture expected)', async () => {
// Script exhaustion on the first call → the child turn errors.
const { ctx, parent, adapter } = await setup([])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('error')
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
const { ctx, parent } = await setup([textResponse('prose, no capture')])
const controller = new AbortController()
const run = await ctx.subagents.start('spawn', structuredRequest(parent, { signal: controller.signal }))
// Cancel synchronously inside the turn's end recording: the cancel
// contract outranks the schema shortfall, so the result maps to aborted.
ctx.on('session/event', (session, event) => {
const child = ctx.agents.get(run.id)
if (session === child?.session && event.type === 'turn/end') controller.abort('cancelled at turn end')
})
const result = await run.result
expect(result.stopReason).toBe('aborted')
await run.dispose()
})
it('rejects a schema outside the subset loud, before any child exists', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema/)
expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
})
it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => {
const { ctx, parent } = await setup([])
// Semantic assertion runs before provider startup.
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/)
})
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('continues after the blocked capture'),
])
// A PostToolUse-style hook turns the tool body's provisional success into
// the authoritative final error observed by the commit notification.
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
}
return next()
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
// No capture was committed: the run reports the schema shortfall...
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
// ...the logged tool result is the blocked isError with the feedback...
const child = ctx.agents.get(run.id)!
const results = child.session.events.filter(e => e.type === 'tool/result')
expect(results[0]!.data.message.content[0].isError).toBe(true)
expect(JSON.stringify(results[0]!.data.message.content)).toContain('capture rejected by hook')
// ...and the turn CONTINUED past the blocked call (no captured veto):
// the model got to react to the failure with a second step.
expect(adapter.requests.length).toBe(2)
await run.dispose()
})
it('a post-execute accept-with-replacement still commits the capture', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
])
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'recorded (rewritten)' }] })
}
return next()
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(result.structured).toEqual({ answer: 8 })
await run.dispose()
})
it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
textResponse('capture was rejected'),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after attachment and prepended, so it wraps every listener
// the child installed. It delegates first, then converts the apparent
// capture success into the pipeline's authoritative failure.
ctx.on('tools/post-execute', async (exec, _result, next) => {
const downstream = await next()
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
}, { prepend: true })
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
const child = ctx.agents.get(run.id)
const captureResult = child?.session.events.find(event =>
event.type === 'tool/result' && event.data.message.source.callId === 'c1')
expect(captureResult?.type === 'tool/result' && captureResult.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
// A context-wide section stands in for the deployment persona: the
// instruction must APPEND to the other scoped and global sections, not
// replace them (AgentOptions has no prompt field — the instruction is an
// ordinary child-scoped prompt registration).
ctx.systemPrompt.section({ name: 'test:persona', order: 10, text: 'You are a counter.' })
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests.at(-1)!
expect(childRequest.system).toContain('You are a counter.')
expect(childRequest.system!.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
expect(childRequest.system!.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)).toBeGreaterThan(0)
await run.dispose()
})
it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return { logs: [], value: 'captured' }
},
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toEqual({ answer: 12 })
const request = adapter.requests[0]!
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
expect(request.system).toContain('interface ToolArgsMap')
expect(request.system).toContain('interface ToolOutputMap')
expect(request.system).toContain('recorded: true;')
expect(request.system).toContain('Promise<ToolOutputMap[K]>')
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
await run.dispose()
})
it('discards a nested capture when the enclosing run_code execution fails', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")', description: 'Capture then fail the program' }),
textResponse('outer code failed'),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return {
logs: [],
error: { kind: 'runtime', message: 'boom after capture' },
} as never
},
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
expect(adapter.requests).toHaveLength(2)
const child = ctx.agents.get(run.id)!
const outer = child.session.events.find(event =>
event.type === 'tool/result' && event.data.message.source.callId === CallId('c1'))
expect(outer?.type === 'tool/result' && outer.data.message.content[0].isError).toBe(true)
await run.dispose()
})
it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }),
textResponse('outer code was blocked'),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return { logs: [], value: 'captured' }
},
})
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
: next())
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
expect(adapter.requests).toHaveLength(2)
await run.dispose()
})
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
const { ctx, parent, adapter } = await setup([
textResponse('parent answer'),
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
await parent.whenIdle()
expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// The loop always assembles a base prompt (the harness identity section),
// so the instruction APPENDS — never replaces.
const childSystem = adapter.requests.at(-1)!.system!
expect(childSystem.endsWith(STRUCTURED_OUTPUT_INSTRUCTION)).toBe(true)
expect(childSystem.length).toBeGreaterThan(STRUCTURED_OUTPUT_INSTRUCTION.length)
await run.dispose()
})
describe('scoped registration (each child owns its capture tool)', () => {
it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
await parent.whenIdle()
// Scoped registration: the global view has no capture tool, ever.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
})
it('a structured child sees structured_output with ITS schema; a plain agent never sees the tool', async () => {
const { ctx, parent, adapter } = await setup([
// Parent turn (a plain agent): must NOT see the tool.
textResponse('parent answer'),
// Child turn: must see it, with the run's schema.
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }))
await parent.whenIdle()
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const childRequest = adapter.requests[1]!
expect(toolNames(childRequest)).toContain(STRUCTURED_OUTPUT_TOOL)
const entry = childRequest.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
expect(entry.parameters).toEqual(SCHEMA)
await run.dispose()
})
it('two concurrent structured children each see their OWN schema', async () => {
const otherSchema: ObjectJsonSchema = {
type: 'object',
properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
required: ['verdict'],
}
const { ctx, parent, adapter } = await setup([
(options: GenerateOptions) => {
// Answer with whatever schema this child was given — proves each
// request carried the right one regardless of scheduling order.
const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
? { verdict: 'real' }
: { answer: 1 }
return toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, args)
},
(options: GenerateOptions) => {
const entry = options.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!
const args = 'verdict' in (entry.parameters.properties as Record<string, unknown>)
? { verdict: 'real' }
: { answer: 1 }
return toolCallResponse('c2', STRUCTURED_OUTPUT_TOOL, args)
},
])
const runA = await ctx.subagents.start('spawn', structuredRequest(parent))
const runB = await ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: otherSchema }))
const [a, b] = await Promise.all([runA.result, runB.result])
expect(a.structured).toEqual({ answer: 1 })
expect(b.structured).toEqual({ verdict: 'real' })
const schemas = adapter.requests.map(request =>
request.tools!.find(tool => tool.name === STRUCTURED_OUTPUT_TOOL)!.parameters)
expect(schemas).toContainEqual(SCHEMA)
expect(schemas).toContainEqual(otherSchema)
await runA.dispose()
await runB.dispose()
})
it('places the capture tool and instruction in their canonical orders', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
// A global tool sorts lexicographically after structured_output, while a
// global section above the 190 band follows the capture instruction.
ctx.tools.register(defineContentToolFixture({
name: 'zz_probe',
description: 'probe',
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'x' }]),
}))
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
const request = adapter.requests[0]!
const names = toolNames(request)
expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeGreaterThanOrEqual(0)
expect(names.indexOf(STRUCTURED_OUTPUT_TOOL)).toBeLessThan(names.indexOf('zz_probe'))
const system = request.system ?? ''
const instructionAt = system.indexOf(STRUCTURED_OUTPUT_INSTRUCTION)
expect(instructionAt).toBeGreaterThanOrEqual(0)
expect(system.indexOf('AFTER-BAND')).toBeGreaterThan(instructionAt)
await run.dispose()
})
it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
const { parent, adapter } = await setup([textResponse('plain')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
await parent.whenIdle()
const request = adapter.requests[0]!
expect(request.tools).toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 0))
})
it('registrations ride the child fiber: disposing the run removes them; a provider reload mid-run cannot', async () => {
const { ctx, parent, disposeProvider } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 4 }),
])
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A backend hot-reload mid-run must not unregister the capture tool out
// from under the live child: the registration rides the CHILD's fiber.
disposeProvider()
const result = await run.result
expect(result.structured).toEqual({ answer: 4 })
const child = ctx.agents.get(run.id)!
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeDefined()
await run.dispose()
// Child disposed ⇒ its scoped registrations are gone.
expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL, child)).toBeUndefined()
})
})
it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
const { ctx, parent } = await setup([])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
agent: parent,
})
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
const { ctx } = await setup([])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
})
expect(result.isError).toBe(true)
expect(result.error?.info?.code).toBe('UNKNOWN_TOOL')
})
it('a failed execution stage is discarded and never promoted by a later call', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// A prepended post-execute listener blocks the first capture without
// delegating. The final-result notification discards that execution's
// stage when it observes the error.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
blocks -= 1
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
}
return next()
}, { prepend: true })
const result = await run.result
const child = ctx.agents.get(run.id)!
// The blocked capture must NOT surface as structured success…
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// …and a LATER invalid call (its own body staged nothing) must not
// resurrect c1's discarded value: drive the pipeline directly.
const invalid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c2' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 'not-a-number' },
agent: child,
})
expect(invalid.isError).toBe(true)
// A fresh valid call still captures ITS OWN value.
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c3' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 9 },
agent: child,
})
expect(valid.isError).toBeFalsy()
await run.dispose()
})
it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Block the first capture after its body stages a value. Its final error
// discards that execution's stage.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
blocks -= 1
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
}
return next()
}, { prepend: true })
await run.result
const child = ctx.agents.get(run.id)!
// A SECOND capture call with the SAME call id whose body never stages
// (invalid args throw before the stage): the discarded value must not ride
// its acceptance.
const reused = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 'not-a-number' },
agent: child,
})
expect(reused.isError).toBe(true)
// Nothing was ever committed: a fresh valid call is still required.
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 5 },
agent: child,
})
expect(valid.isError).toBeFalsy()
await run.dispose()
})
it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
// Discard the first capture's stage via a final post-execute block.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
blocks -= 1
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected' }] })
}
return next()
}, { prepend: true })
await run.result
const child = ctx.agents.get(run.id)!
// A prepended pre-execute deny skips the body, while the denied call still
// reaches the final notification with the same adapter-minted call id.
const offDeny = ctx.on('tools/pre-execute', (exec) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
}
return undefined as never
}, { prepend: true })
const denied = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 2 },
agent: child,
})
expect(denied.isError).toBe(true)
offDeny()
// The discarded value was never promoted: a fresh valid call is required
// (and succeeds, proving the runtime is not wedged).
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 5 },
agent: child,
})
expect(valid.isError).toBeFalsy()
await run.dispose()
})
})

View File

@@ -0,0 +1,381 @@
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
type Script = ConstructorParameters<typeof MockAdapter>[0]
async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(InvariantRegistry)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
async function setup(script: Script, parentOptions: Partial<AgentOptions> = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentRuntime)
const adapter = new MockAdapter(script)
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock', ...parentOptions })
return { ctx, parent, adapter }
}
function request(parent: Agent, signal = new AbortController().signal) {
return {
label: 'child task',
prompt: [{ type: 'text' as const, text: 'child task' }],
parent,
signal,
descriptor: snapshotSubagentDescriptor({
mode: 'one-shot',
provider: 'test',
label: 'child task',
}),
}
}
function text(blocks: readonly { type: string; text?: string }[]): string {
return blocks.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe('startInProcessRun', () => {
it('returns only after publication, drives a fresh child, and disposes it', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
const run = await startInProcessRun(request(parent), {})
expect(ctx.agents.get(run.id)).toBeDefined()
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('driver answer')
expect(ctx.agents.get(run.id)!.options.subagentDepth).toBe(1)
await run.dispose()
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
})
it('uses explicit child model selectors when the parent has none and preserves its cwd', async () => {
const { ctx } = await setup([textResponse('driver answer')])
const parent = ctx.agentLoop.create(SessionId('bare-parent'), {}, { cwd: '/workspace' })
const run = await startInProcessRun({
...request(parent),
agentOptions: { provider: 'mock', model: 'mock' },
}, {})
const child = ctx.agents.get(run.id)!
expect(child.options).toMatchObject({ provider: 'mock', model: 'mock' })
expect(child.session.header.cwd).toBe('/workspace')
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
await run.dispose()
})
it('reports a prompt a pre-step rejection discarded as refusal, not completion', async () => {
const { ctx, parent } = await setup([])
// A UserPromptSubmit deny or a policy plugin: the child claims its prompt,
// the rejection discards it, and the turn closes `blocked` with no step.
ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
if (subject === parent) return next()
return { kind: 'reject' as const }
})
const run = await startInProcessRun(request(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' })
await run.dispose()
})
it('does not add a final durability checkpoint to a foreground run', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
let flushes = 0
ctx.on('session/flush', (session) => {
if (session.header.parentSession === undefined) return
flushes++
throw new Error('disk full')
})
const run = await startInProcessRun(request(parent), {})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
expect(flushes).toBe(0)
await run.dispose()
})
it('keeps published run and handle disposal failures on separate channels', async () => {
const { ctx, parent } = await setup([])
const runError = new Error('published run failed')
const disposalError = new Error('published handle disposal failed')
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const parentWithFailedDisposal = {
options: parent.options,
session: parent.session,
ctx: {
get: () => undefined,
agents: {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
handle.agent.followup = () => { throw runError }
return {
...handle,
dispose: async () => {
await handle.dispose()
throw disposalError
},
}
},
},
},
} as unknown as Agent
const run = await startInProcessRun(request(parentWithFailedDisposal), {})
expect(ctx.agents.get(run.id)).toBeDefined()
await expect(run.result).rejects.toBe(runError)
await expect(run.dispose()).rejects.toBe(disposalError)
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('reports the turn outcome when later metadata is appended during flush', async () => {
const { ctx, parent } = await setup([maxTokensResponse('partial answer')])
let injected = false
ctx.on('session/flush', (session) => {
if (injected || session.header.parentSession === undefined) return
const lastEnd = session.events.findLast(event => event.type === 'turn/end')
if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return
injected = true
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late metadata' }],
source: { kind: 'plugin', plugin: 'late-metadata' },
}), { surfaceOp: 'append' })
})
const run = await startInProcessRun(request(parent), {})
const result = await run.result
const child = ctx.agents.get(run.id)!
expect(injected).toBe(false)
expect(child.session.events.findLast(event => event.type === 'turn/end'))
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
})
it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => {
// A tool-only max-tokens step records an empty assistant/message for
// usage. The result retains the preceding assistant output.
const { ctx, parent } = await setup([
toolCallResponse('t1', 'noop', {}, 'partial one'),
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
],
])
const disposeNoop = ctx.tools.register(defineContentToolFixture({
name: 'noop', description: 'probe', parameters: {},
execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) },
}))
const run = await startInProcessRun(request(parent), {})
const result = await run.result
expect(result.stopReason).toBe('max-tokens')
expect(text(result.output)).toBe('partial one')
await run.dispose()
disposeNoop()
})
it('seeds a forked child but reads only the child-owned output', async () => {
const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } }))
await parent.whenIdle()
const seed = parent.session.events.slice()
const run = await startInProcessRun(request(parent), { seed })
const result = await run.result
expect(text(result.output)).toBe('child answer')
const child = ctx.agents.get(run.id)!
expect(child.session.header.seedLength).toBe(seed.length)
expect(child.session.events.slice(0, seed.length)).toEqual(seed)
await run.dispose()
})
it('persists the child origin and depth in its session header', async () => {
const { ctx, parent } = await setup([textResponse('child answer')])
const run = await startInProcessRun(request(parent), {})
await run.result
// The recursion budget is durable session data, not only runtime options —
// a depth that lived only in AgentOptions would reset to 0 on resume.
expect(ctx.agents.get(run.id)!.session.header).toMatchObject({
origin: 'subagent',
delegationDepth: 1,
})
await run.dispose()
})
it('inherits the parent output-token cap and accepts an explicit child override', async () => {
const { ctx, parent, adapter } = await setup(
[textResponse('inherited'), textResponse('overridden')],
{ maxTokens: 111 },
)
const inherited = await startInProcessRun(request(parent), {})
await inherited.result
expect(adapter.requests[0]?.maxTokens).toBe(111)
expect(ctx.agents.get(inherited.id)?.options.maxTokens).toBe(111)
await inherited.dispose()
const overridden = await startInProcessRun({
...request(parent),
agentOptions: { maxTokens: 222 },
}, {})
await overridden.result
expect(adapter.requests[1]?.maxTokens).toBe(222)
expect(ctx.agents.get(overridden.id)?.options.maxTokens).toBe(222)
await overridden.dispose()
})
it('counts a RESUMED child by its persisted header depth, not the absent runtime depth', async () => {
// Resume rebuilds runtime options, so the durable header must keep this
// depth-1 child from delegating as though it were top-level.
const { ctx } = await setup([textResponse('unused')])
const resumed = (await ctx.agents.create({
sessionId: SessionId('resumed-child'),
meta: { parentSession: SessionId('root'), delegationDepth: 1 },
agentOptions: { provider: 'mock', model: 'mock' },
signal: new AbortController().signal,
})).agent
await expect(startInProcessRun({ ...request(resumed), maxDepth: 1 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 2, maxDepth: 1 })
})
it('lets runtime options deepen but never lower the persisted depth', async () => {
const { ctx } = await setup([textResponse('unused')])
const parent = (await ctx.agents.create({
sessionId: SessionId('deep-parent'),
meta: { delegationDepth: 2 },
agentOptions: { provider: 'mock', model: 'mock', subagentDepth: 1 },
signal: new AbortController().signal,
})).agent
// Persisted 2 vs runtime 1: the child is depth 3, so maxDepth 2 rejects.
await expect(startInProcessRun({ ...request(parent), maxDepth: 2 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError', attemptedDepth: 3, maxDepth: 2 })
})
it('rejects invalid and exceeded depth before publication', async () => {
const { parent } = await setup([])
await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {}))
.rejects.toThrow('non-negative safe integer')
await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {}))
.rejects.toMatchObject({ name: 'SubagentDepthError' })
for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) {
const malformed = { options: { subagentDepth: value }, session: { header: {} } } as unknown as Agent
await expect(startInProcessRun(request(malformed), {}))
.rejects.toThrow('agent subagentDepth must be a non-negative safe integer')
}
const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER }, session: { header: {} } } as unknown as Agent
await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError)
})
it('rejects an already-aborted request without publishing a child', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const controller = new AbortController()
controller.abort('too late')
await expect(startInProcessRun(request(parent, controller.signal), {}))
.rejects.toThrow('aborted before child publication')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('stamps only the resolved depth when neither parent nor request declares a model route', async () => {
// The one-shot analogue of the deleted resume coverage ("resumes without
// inventing undeclared agent model options"): a bare parent with no request
// agentOptions yields a child whose options carry ONLY the stamped depth —
// no provider/model is fabricated, so the child's turn errors for want of a
// route rather than silently adopting one.
const { ctx } = await setup([])
const parent = ctx.agentLoop.create(SessionId('routeless-parent'), {})
const run = await startInProcessRun(request(parent), {})
const child = ctx.agents.get(run.id)!
expect(child.options).toEqual({ subagentDepth: 1 })
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
await run.dispose()
})
it('uses the request signal after publication and dispose as cancellation paths', async () => {
const { parent, adapter } = await setup(['hang', 'hang'])
const controller = new AbortController()
const signalled = await startInProcessRun(request(parent, controller.signal), {})
await new Promise(resolve => setTimeout(resolve, 30))
controller.abort('stop child')
// No step completed a message, so the text streamed before the abort is
// the cancelled run's output.
await expect(signalled.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial' }],
stopReason: 'aborted',
})
expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' })
const child = parent.ctx.agents.get(signalled.id)
const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } })
await signalled.dispose()
const disposed = await startInProcessRun(request(parent), {})
await new Promise(resolve => setTimeout(resolve, 30))
await disposed.dispose()
await expect(disposed.result).resolves.toMatchObject({ stopReason: 'aborted' })
})
it('cleans a failed unpublished setup before rejecting', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
await expect(startInProcessRun({
...request(parent),
toolFilter: { deny: ['unknown-tool'] },
}, {})).rejects.toThrow('unknown global tool')
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
it('treats abort after factory publication as a cancelled run with an id', async () => {
const { ctx, parent } = await setup([])
const controller = new AbortController()
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const parentWithAbortAtHandoff = {
options: parent.options,
session: parent.session,
ctx: {
// The driver's synchronous inheritance capture probes both policy
// services opportunistically; this stub composes neither.
get: () => undefined,
agents: {
create: async (options: Parameters<typeof ctx.agents.create>[0]) => {
const handle = await ctx.agents.create(options)
// `create()` has detached its creation-only listener, but the
// published run has not installed its live listener yet.
controller.abort('handoff race')
return handle
},
},
},
} as unknown as Agent
const run = await startInProcessRun(request(parentWithAbortAtHandoff, controller.signal), {})
expect(ctx.agents.get(run.id)).toBeDefined()
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
await run.dispose()
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
})
})