import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' import type { ContinuationDecision } 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 InvariantService 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 SubagentService, { 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[0] async function mountInvariants(ctx: Context): Promise { await ctx.plugin(InvariantService) await ctx.plugin(SessionInvariant) await ctx.plugin(AgentInvariant) await ctx.plugin(AgentLoopInvariant) } interface CodeRunRequestLike { bindings: { global: string; functions: Record Promise> }[] } 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(SubagentService) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, inheritsParentContext: false, start: (request: SubagentStartRequest) => 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 { return { 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 // Default continuation would run a second step after the tool call; the // structured runtime's turn-continuation veto stops the turn instead. 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 { 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 { 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.callId === 'c2') expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.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 { 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('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), textResponse('MUST NOT BE CONSUMED'), ]) ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) let wrapperInstalled = false // Register before ready-only start: structured output is attached before session-start and the // loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose // to the later terminal checkpoint. ctx.on('agent/session-start', (child) => { if (child === parent) return wrapperInstalled = true child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, _signal, next): Promise => { const downstream = await next() expect(downstream).toEqual({ action: 'stop' }) return { action: 'continue' } }, { prepend: true }) }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) const result = await run.result expect(wrapperInstalled).toBe(true) expect(result.structured).toEqual({ answer: 7 }) expect(result.stopReason).toBe('completed') expect(adapter.requests).toHaveLength(1) await run.dispose() }) it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), textResponse('MUST NOT BE CONSUMED'), ]) // A downstream policy stops, then a later wrapper delegates and queues steering that ordinary // folding would turn into continue. The terminal checkpoint must discard that steering. ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) ctx.on('agent/session-start', (child) => { if (child.id !== run.id) return child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, _signal, next): Promise => { const downstream = await next() expect(downstream).toEqual({ action: 'stop' }) subject.steer([{ type: 'text', text: 'late steering after downstream stop' }]) return downstream }, { prepend: true }) }) const result = await run.result const child = ctx.agents.get(run.id) expect(result.structured).toEqual({ answer: 9 }) expect(adapter.requests).toHaveLength(1) expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0) 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 as { isError?: boolean }).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 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').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 as { isError?: boolean }).isError).toBe(true) expect(JSON.stringify((results[0]!.data as { content: unknown }).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.callId === 'c1') expect(captureResult?.type === 'tool/result' && captureResult.data.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') 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.callId === CallId('c1')) expect(outer?.type === 'tool/result' && outer.data.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([{ type: 'text', text: 'hello' }]) 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([{ type: 'text', text: 'hello' }]) 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([{ type: 'text', text: 'hello' }]) 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) ? { 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) ? { 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([{ type: 'text', text: 'q' }]) 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() }) })