mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'master' into sdk/ts-client-and-subagent
This commit is contained in:
@@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
|
||||
])
|
||||
|
||||
// Parent does one real turn first, so the fork has a completed turn to seed.
|
||||
parent.followup([{ type: 'text', text: 'parent q1' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent q1' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
|
||||
await forkRun.dispose()
|
||||
|
||||
// The parent is unaffected and keeps working after both delegations.
|
||||
parent.followup([{ type: 'text', text: 'parent q2' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent q2' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
|
||||
expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two')
|
||||
|
||||
@@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => {
|
||||
|
||||
it('seeds every completed parent turn through the last turn/end', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')])
|
||||
parent.followup([{ type: 'text', text: 'q1' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q1' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
parent.followup([{ type: 'text', text: 'q2' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentPrefixLen = parent.session.events.length
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => {
|
||||
// Parent runs one turn, then we fork. The child's seeded log should contain
|
||||
// the parent's first turn, and the child should run its own new turn on top.
|
||||
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 parentPrefixLen = parent.session.events.length
|
||||
|
||||
@@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => {
|
||||
// open (a hanging model call), and fork while it's in flight. The seed must stop after the
|
||||
// balanced first turn; including the open turn would fail invariant replay during start.
|
||||
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
|
||||
parent.followup([{ type: 'text', text: 'q1' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q1' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
// Start a second turn that hangs (open turn/start + open step, never ends).
|
||||
parent.followup([{ type: 'text', text: 'q2' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })
|
||||
await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
|
||||
|
||||
// Forking now must NOT throw (the open second turn is excluded from the seed).
|
||||
@@ -155,7 +155,7 @@ describe('dsh-subagent-fork', () => {
|
||||
// 1 from the seeded parent turn + 1 from the child's own completed turn.
|
||||
expect(seedTurnEnds.length).toBe(2)
|
||||
|
||||
parent.cancel()
|
||||
parent.cancel({ kind: 'user' })
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => {
|
||||
textResponse('parent turn'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'warm up' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'warm up' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const run = await start(ctx, 'fork', {
|
||||
prompt: [{ type: 'text', text: 'report structured' }],
|
||||
@@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => {
|
||||
// `readResult` must scan only child-owned events after the seed. The child emits no assistant
|
||||
// message, so scanning the whole log would incorrectly return the parent's distinctive text.
|
||||
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
|
||||
parent.followup([{ type: 'text', text: 'parent question' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: d4483b7ff3aa3869f496c2bf2c41ac2fd151f4ef
|
||||
README.zh.md: 765580a1614e8ec46e751334f8618f6ca1380392
|
||||
README.md: 74a1b504270ec18921156c490c9898a1b0ec1d0e
|
||||
README.zh.md: 2c358f7ed48cfbbefacf00ce307253d1bdb0c3c8
|
||||
|
||||
@@ -38,7 +38,7 @@ Depth enforcement is internal to `startInProcessRun`: it reads the parent depth
|
||||
- An order-190 system-prompt section tells the child that the tool call is the terminal answer.
|
||||
- Both contributions are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child.
|
||||
- A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch.
|
||||
- A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits.
|
||||
- A monotonic tool guard blocks later calls after capture, and the structured-output execution's `concludeTurn()` marker ends the turn after the result commits.
|
||||
|
||||
A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
- 一个顺序为 190 的系统提示词段会告诉子 agent,该工具调用就是终态答案。
|
||||
- 两项贡献都是普通的子 agent 作用域注册。专家级 `system-prompt/assemble` 监听器可以替换它们,因此负责为该子 agent 保留结构化输出协议。
|
||||
- `tools/result` 观察器只会在该次执行的权威最终工具结果成功后提交暂存值;Code Mode 子分派外层的 `run_code` 结果也包括在内。
|
||||
- 单调工具防护会在捕获值后阻止后续调用,`agent/turn-stop` 则在结构化结果提交后结束轮次。
|
||||
- 单调工具防护会在捕获值后阻止后续调用,结构化输出执行的 `concludeTurn()` 标记则在结果提交后结束轮次。
|
||||
|
||||
正常结束却始终未提交必需结构化值的轮次会报告 `error`;驱动器不会重新提示。所有注册都附着于子 agent fiber,并随其一同消失。
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function startInProcessRun(
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
child.followup(request.prompt)
|
||||
child.followup({ content: request.prompt, source: { kind: 'user' } })
|
||||
await child.whenIdle()
|
||||
return readResult(
|
||||
child,
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
* contribution is ordinary reconstructed request state.
|
||||
*
|
||||
* Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also
|
||||
* waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard
|
||||
* then prevent later listeners or calls from reopening a completed structured run.
|
||||
* waits for the enclosing `run_code` result. The terminal result marker and monotonic tool
|
||||
* guard prevent later calls from reopening a completed structured run.
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution, ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The model-facing tool name a structured child must call to finish. */
|
||||
@@ -83,7 +82,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
},
|
||||
render: () => [{ type: 'text', text: 'Structured output recorded.' }],
|
||||
},
|
||||
execute(args: unknown, exec: ToolExecution): Promise<{ recorded: true }> {
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<{ recorded: true }> {
|
||||
const violations = validateJsonSchemaValue(schema, args)
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
// within the same turn, exactly like a schema-validated defineTool call.
|
||||
@@ -92,6 +91,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
// waterfalls may still turn the success into an error. ToolRegistry has
|
||||
// already frozen model-bound arguments at the actual input boundary.
|
||||
staged.set(exec, { value: args })
|
||||
exec.concludeTurn()
|
||||
return Promise.resolve({ recorded: true })
|
||||
},
|
||||
})
|
||||
@@ -102,13 +102,6 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
})
|
||||
|
||||
// Stop the child's turn once its output is captured. This monotonic serial
|
||||
// checkpoint runs after the ordinary continuation waterfall, its reason,
|
||||
// and late-steering folding, so no ordering trick can resume a finished run.
|
||||
childCtx.on('agent/turn-stop', function (this: unknown, _agent, _turn, _signal): ContinuationStop | undefined {
|
||||
return captured === undefined ? undefined : { action: 'stop' }
|
||||
})
|
||||
|
||||
// Terminal WITHIN the step. Guards run after the whole pre-execute
|
||||
// waterfall and compose monotonically (deny or abstain, never allow), so a
|
||||
// later prepended listener cannot resurrect dispatch. Calls that precede
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -80,14 +80,14 @@ describe('startInProcessRun', () => {
|
||||
const child = ctx.agents.get(run.id)!
|
||||
|
||||
expect(child.session.events.findLast(event => event.type === 'turn/end'))
|
||||
.toMatchObject({ data: { reason: { kind: 'completed' } } })
|
||||
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
|
||||
expect(result.stopReason).toBe('max-tokens')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
@@ -24,10 +24,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
|
||||
ctx = await spawnHarness(workdir)
|
||||
const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
parent.followup([{ type: 'text', text:
|
||||
parent.followup({ content: [{ type: 'text', text:
|
||||
'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
|
||||
+ 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." '
|
||||
+ 'After the subagent finishes, tell me it is done.' }])
|
||||
+ 'After the subagent finishes, tell me it is done.' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, parent)
|
||||
|
||||
// Assert the filesystem effect independently of the model response.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, symbols, type EffectMeta } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } 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'
|
||||
@@ -52,6 +52,17 @@ function start(ctx: Context, provider: string, request: Omit<SubagentStartReques
|
||||
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
|
||||
}
|
||||
|
||||
/** Invoke the child lifecycle effect while its parent-owned setup is still unpublished. */
|
||||
function disposeChildLifecycle(parent: Agent): void {
|
||||
const lifecycle = [...parent.ctx.fiber._disposables]
|
||||
.find((dispose) => {
|
||||
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
|
||||
return effect?.label.startsWith('agentLoop.lifecycle(') === true
|
||||
})
|
||||
if (lifecycle === undefined) throw new Error('child lifecycle effect not found')
|
||||
void lifecycle()
|
||||
}
|
||||
|
||||
describe('dsh-subagent-spawn', () => {
|
||||
it('runs a fresh child to completion and returns its final assistant output', async () => {
|
||||
// One model call for the child: a plain text answer.
|
||||
@@ -95,7 +106,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => {
|
||||
// Drive the parent through one real turn so it has history, THEN spawn.
|
||||
const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')])
|
||||
parent.followup([{ type: 'text', text: 'parent prompt' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'parent prompt' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
const parentEventCount = parent.session.events.length
|
||||
expect(parentEventCount).toBeGreaterThan(0)
|
||||
@@ -372,7 +383,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
textResponse('parent answer'),
|
||||
textResponse('child answer'),
|
||||
])
|
||||
parent.followup([{ type: 'text', text: 'hi' }])
|
||||
parent.followup({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
|
||||
await parent.whenIdle()
|
||||
|
||||
const run = await start(ctx, 'spawn', {
|
||||
@@ -460,6 +471,12 @@ describe('dsh-subagent-spawn', () => {
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
let teardownStarted = false
|
||||
ctx.on('internal/plugin', (fiber) => {
|
||||
if (teardownStarted || fiber.name !== 'scope') return
|
||||
teardownStarted = true
|
||||
disposeChildLifecycle(parentHandle.agent)
|
||||
})
|
||||
|
||||
const starting = start(ctx, 'spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }],
|
||||
@@ -468,8 +485,8 @@ describe('dsh-subagent-spawn', () => {
|
||||
// The factory has entered its awaited unpublished setup transaction. The
|
||||
// parent context owns that transaction, so disposal wins without an
|
||||
// observer ever seeing the child.
|
||||
await parentHandle.dispose()
|
||||
await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
|
||||
await parentHandle.dispose()
|
||||
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user