Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance
# Conflicts: # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # docs/cordis-catalog/services.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/core/session/README.i18n.yaml # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/ui/user-approval/src/index.ts
This commit is contained in:
@@ -186,7 +186,7 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
toolCallResponse('c-write', 'write', { file_path: blocked, content: 'escaped' }),
|
||||
textResponse('child done'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage the session policy' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'stage the session policy' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentLogLength = parent.session.events.length
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
},
|
||||
textResponse('child done'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
@@ -264,9 +264,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
textResponse('fork child done'),
|
||||
textResponse('turn two done'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'turn one' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'turn one' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
parent.followup([{ type: 'text', text: 'turn two: delegate' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'turn two: delegate' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const child = captured[0] as Agent
|
||||
@@ -297,9 +297,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
textResponse('race child done'),
|
||||
textResponse('turn two done'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
parent.followup([{ type: 'text', text: 'delegate' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'delegate' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const child = captured[0] as Agent
|
||||
@@ -329,9 +329,9 @@ describe('sandbox-mode inheritance against the real fs fence', () => {
|
||||
textResponse('child done'),
|
||||
textResponse('parent done'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
parent.followup([{ type: 'text', text: 'delegate twice' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'delegate twice' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
expect(captured).toHaveLength(2)
|
||||
@@ -363,7 +363,7 @@ describe('inheritance survives prompt vetoes', () => {
|
||||
},
|
||||
// No child model entries: the blocked prompt closes a zero-step turn.
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
@@ -432,7 +432,7 @@ describe('what a blocked child experiences', () => {
|
||||
},
|
||||
textResponse('child done'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
@@ -468,7 +468,7 @@ describe('what a blocked child experiences', () => {
|
||||
}),
|
||||
textResponse('child gave up'),
|
||||
)
|
||||
parent.followup([{ type: 'text', text: 'stage' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'stage' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await startInProcessRun(spawnRequest(parent), {})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -116,8 +115,7 @@ describe('in-process structured output', () => {
|
||||
])
|
||||
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.
|
||||
// The structured tool marks its successful result as turn-concluding.
|
||||
expect(adapter.requests.length).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
@@ -219,63 +217,6 @@ describe('in-process structured output', () => {
|
||||
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<ContinuationDecision>({ 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<ContinuationDecision> => {
|
||||
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<ContinuationDecision>({ 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<ContinuationDecision> => {
|
||||
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' }),
|
||||
@@ -522,7 +463,7 @@ describe('in-process structured output', () => {
|
||||
textResponse('parent answer'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'hello' }])
|
||||
parent.followup({ 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))
|
||||
@@ -538,7 +479,7 @@ describe('in-process structured output', () => {
|
||||
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' }])
|
||||
parent.followup({ 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()
|
||||
@@ -552,7 +493,7 @@ describe('in-process structured output', () => {
|
||||
// Child turn: must see it, with the run's schema.
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'hello' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
|
||||
|
||||
@@ -630,7 +571,7 @@ describe('in-process structured output', () => {
|
||||
|
||||
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' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const request = adapter.requests[0]!
|
||||
expect(request.tools).toBeUndefined()
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('startInProcessRun', () => {
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
|
||||
expect(injected).toBe(true)
|
||||
expect(child.session.events.findLast(event => event.type === 'turn/end'))
|
||||
.toMatchObject({ data: { reason: { kind: 'completed' } } })
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
@@ -87,7 +88,7 @@ describe('startInProcessRun', () => {
|
||||
|
||||
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([{ type: 'text', text: 'parent question' }])
|
||||
parent.followup({ 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 })
|
||||
|
||||
Reference in New Issue
Block a user