Pārlūkot izejas kodu

test(subagent): update service, send_message, and delegation specs

The continuable path has no Task, so send_message reports a queued next turn
and continuable delegation returns only the durable child id. Pins that a
follow-up queues behind an open turn rather than steering it, and that a
non-parent caller is rejected.

Also makes startContinuable/followup reject rather than throw synchronously
when continuation services are absent, so callers have one failure mode.
Dudu-0223 2 mēneši atpakaļ
vecāks
revīzija
357f317b4c

+ 3 - 3
packages/subagent/subagent/src/index.ts

@@ -198,7 +198,7 @@ export class SubagentService extends Service {
    * @returns the durable child id and the accepted prompt's message id.
    * @throws when continuation services are unavailable or materialization fails.
    */
-  startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {
+  async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {
     return this.requireContinuations().startContinuable(spec)
   }
 
@@ -217,7 +217,7 @@ export class SubagentService extends Service {
    * @throws when continuation services are unavailable, authority is rejected,
    *   or the message was not admitted.
    */
-  followup(
+  async followup(
     authority: SubagentAuthority,
     childId: SessionId,
     content: ContentBlock[],
@@ -342,7 +342,7 @@ export class SubagentService extends Service {
   private requireContinuations(): SubagentContinuationManager {
     if (this.continuations === undefined) {
       throw new SubagentError(
-        'continuable subagents require the tasks and agents services',
+        'continuable subagents require the agents service',
         'CONTINUATION_UNAVAILABLE',
       )
     }

+ 18 - 13
packages/subagent/subagent/tests/service.spec.ts

@@ -12,7 +12,6 @@ import SubagentService, {
   assertSubagentMaxDepth,
   type SubagentCapabilities,
   type SubagentProvider,
-  type SubagentProviderStartRequest,
   type SubagentResult,
   type SubagentRun,
   type SubagentStartRequest,
@@ -38,7 +37,7 @@ function baseRequest(overrides: Partial<SubagentStartRequest> = {}): SubagentSta
 class StubProvider implements SubagentProvider {
   readonly inheritsParentContext = false
   startCount = 0
-  lastRequest: SubagentProviderStartRequest | undefined
+  lastRequest: SubagentStartRequest | undefined
 
   constructor(
     readonly name: string,
@@ -49,7 +48,7 @@ class StubProvider implements SubagentProvider {
     },
   ) {}
 
-  async start(request: SubagentProviderStartRequest): Promise<SubagentRun> {
+  async start(request: SubagentStartRequest): Promise<SubagentRun> {
     this.startCount += 1
     this.lastRequest = request
     return {
@@ -112,21 +111,27 @@ describe('SubagentService', () => {
     const request = baseRequest()
     await subagents.start('one-shot', request)
 
+    // One-shot start borrows the caller's exact request; the seam has no
+    // provider-facing resume or steer surface to dispatch through.
     expect(provider.lastRequest).toBe(request)
-    expectTypeOf<SubagentProviderStartRequest>()
-      .not.toExtend<Parameters<SubagentService['start']>[1]>()
+    expectTypeOf<Parameters<SubagentService['start']>[1]>().toExtend<SubagentStartRequest>()
     expect('resume' in subagents).toBe(false)
+    expect('resume' in provider).toBe(false)
   })
 
-  it('rejects Task-backed continuation operations when their runtime services are absent', async () => {
+  it('rejects continuable operations when their runtime services are absent', async () => {
     const { subagents } = await service()
-    expect(() => {
-      subagents.startContinuable({
-        provider: 'unused',
-        label: 'work',
-        request: baseRequest(),
-      })
-    }).toThrow(expect.objectContaining({ code: 'CONTINUATION_UNAVAILABLE' }))
+    await expect(subagents.startContinuable({
+      provider: 'unused',
+      request: baseRequest(),
+      signal: new AbortController().signal,
+    })).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
+    await expect(subagents.followup(
+      { kind: 'user' },
+      SessionId('child'),
+      [{ type: 'text', text: 'hello' }],
+      { source: { kind: 'user' }, signal: new AbortController().signal },
+    )).rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
   })
 
   it.each([

+ 58 - 75
packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts

@@ -10,8 +10,6 @@ import { SessionId } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import SubagentService from '@deepseek-ai/dsh-subagent'
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
-import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
-import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import * as tool from '../src/index.ts'
 
@@ -31,8 +29,6 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SubagentService)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
-  await ctx.plugin(LocalTaskService)
-  await ctx.plugin(ToolTasks, {})
   await ctx.plugin(tool)
   const adapter = new MockAdapter(script)
   ctx.llm.registerAdapter(['mock'], adapter)
@@ -61,6 +57,13 @@ function callTool(
   })
 }
 
+/** Wait until a child's Activation released its handle. */
+async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
+  await vi.waitFor(() => {
+    expect(ctx.agents.get(childId)).toBeUndefined()
+  }, { timeout: 5_000 })
+}
+
 describe('dsh-tool-subagent-control', () => {
   it('registers send_message once, globally, with the two required parameters', async () => {
     const { ctx } = await setup([])
@@ -68,90 +71,62 @@ describe('dsh-tool-subagent-control', () => {
     expect(schemas).toHaveLength(1)
     const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
     expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id'])
-    expect(schemas[0]!.description).toContain('task_output')
+    // The continuable path has no Task, so the schema must not promise one.
+    expect(schemas[0]!.description).not.toContain('task_output')
+    expect(schemas[0]!.description).not.toContain('task id')
+    // Follow-up ordering is model-visible: it cannot redirect the open turn.
+    expect(schemas[0]!.description).toContain('next turn')
   })
 
-  it('cold-resumes a settled child and renders the started route with its task id', async () => {
+  it('cold-resumes a settled child and reports the queued next turn', async () => {
     const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
-    const started = ctx.subagents.startContinuable({
+    const started = await ctx.subagents.startContinuable({
       provider: 'spawn',
-      label: 'work',
       request: { prompt: [{ type: 'text', text: 'child task' }], parent },
+      signal: testToolSignal,
     })
-    await ctx.tasks.wait(started.taskId, 5_000, parent)
+    await waitNoActivation(ctx, started.childId)
 
     const result = await callTool(ctx, 'send_message', {
       subagent_id: started.childId,
       message: 'and then?',
     }, parent)
+
     expect(result.isError).toBe(false)
-    expect(text(result)).toBe(`message started task subagent-2 continuing subagent ${started.childId}`)
-    const collected = await callTool(ctx, 'task_output', { task_id: 'subagent-2', wait: true }, parent)
-    expect(text(collected)).toBe('second answer\n[status: completed]')
+    expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`)
+    await waitNoActivation(ctx, started.childId)
+
     const loaded = await ctx.sessionPersistence.load(started.childId)
-    const followUp = loaded.events.findLast(event =>
-      event.type === 'user/message',
-    )
+    const followUp = loaded.events.findLast(event => event.type === 'user/message')
+    // Durable provenance records the calling agent without granting authority.
     expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
       kind: 'coordinator',
       senderSessionId: parent.id,
     })
   })
 
-  it('renders the steered route when the child is still running', async () => {
-    // Script the child's single turn as two steps: the steer joins mid-turn.
-    const { ctx, parent } = await setup([])
-    let steered: string | undefined
-    let source: unknown
-    // Reach past the tool into the subagent service to fake a running route
-    // deterministically: the tool is a thin adapter, so its steered wording is
-    // what this test pins.
-    ctx.subagents.followup = async (agent, _childId, message, options) => {
-      steered = (message[0] as { text: string }).text
-      source = options.source
-      return { route: 'steered', taskId: ctx.tasks.list(agent)[0]?.id ?? ('subagent-9' as never) }
-    }
-    const result = await callTool(ctx, 'send_message', {
-      subagent_id: 'some-child',
-      message: 'also consider Y',
-    }, parent)
-    expect(result.isError).toBe(false)
-    expect(steered).toBe('also consider Y')
-    expect(source).toEqual({ kind: 'coordinator', senderSessionId: parent.id })
-    expect(text(result)).toBe('message delivered to running task subagent-9')
-  })
-
-  it('cancels a pending live-delivery wait when the tool signal aborts', async () => {
-    const { ctx, parent, adapter } = await setup(['hang'])
-    const started = ctx.subagents.startContinuable({
+  it('queues behind an open turn instead of joining it', async () => {
+    const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
+    const started = await ctx.subagents.startContinuable({
       provider: 'spawn',
-      label: 'hung work',
-      request: { prompt: [{ type: 'text', text: 'wait' }], parent },
+      request: { prompt: [{ type: 'text', text: 'long work' }], parent },
+      signal: testToolSignal,
     })
     await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
-    const deliveryStarted: PromiseWithResolvers<void> = Promise.withResolvers()
-    const followup = ctx.subagents.followup.bind(ctx.subagents)
-    ctx.subagents.followup = (agent, childId, message, options) => {
-      const delivery = followup(agent, childId, message, options)
-      deliveryStarted.resolve()
-      return delivery
-    }
-
-    const controller = new AbortController()
-    const execution = callTool(ctx, 'send_message', {
+
+    const result = await callTool(ctx, 'send_message', {
       subagent_id: started.childId,
-      message: 'follow up',
-    }, parent, controller.signal)
-    await deliveryStarted.promise
-    controller.abort('parent tool cancelled')
+      message: 'also consider Y',
+    }, parent)
+    expect(result.isError).toBe(false)
 
-    const result = await execution
-    expect(result.isError).toBe(true)
-    expect(result.error?.info?.code).toBe('CANCELLED')
-    expect(ctx.agents.get(started.childId)).toBeUndefined()
-    const snapshot = await ctx.tasks.wait(started.taskId, 5_000, parent)
-    expect(snapshot.status).toBe('killed')
+    await waitNoActivation(ctx, started.childId)
     const loaded = await ctx.sessionPersistence.load(started.childId)
+    const prompts = loaded.events.flatMap(event => event.type === 'user/message'
+      ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
+      : [])
+    // A follow-up is its own later turn, never steering inside the first one.
+    expect(prompts).toEqual(['long work', 'also consider Y'])
     expect(loaded.events.some(event => event.type === 'steering/message')).toBe(false)
   })
 
@@ -161,17 +136,26 @@ describe('dsh-tool-subagent-control', () => {
       subagent_id: 'no-such-child',
       message: 'hello?',
     }, parent)
-    // Unknown ids start a Task whose failure carries the unavailable detail;
-    // synchronous rejections (ownership conflicts) become isError results.
-    if (result.isError) {
-      expect(text(result)).toContain('not delivered')
-    } else {
-      const taskId = text(result).match(/task (\S+) /)?.[1]
-      expect(taskId).toBeDefined()
-      const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent)
-      expect(snapshot.status).toBe('failed')
-      expect(snapshot.detail).toContain('unavailable')
-    }
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('unavailable')
+  })
+
+  it('rejects a caller that is not the child\'s durable direct parent', async () => {
+    const { ctx, parent } = await setup([textResponse('first')])
+    const started = await ctx.subagents.startContinuable({
+      provider: 'spawn',
+      request: { prompt: [{ type: 'text', text: 'child task' }], parent },
+      signal: testToolSignal,
+    })
+    await waitNoActivation(ctx, started.childId)
+    const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
+
+    const result = await callTool(ctx, 'send_message', {
+      subagent_id: started.childId,
+      message: 'mine now',
+    }, stranger)
+    expect(result.isError).toBe(true)
+    expect(text(result)).toContain('another parent session')
   })
 
   it('fails loud when invoked without a calling agent', async () => {
@@ -186,7 +170,6 @@ describe('dsh-tool-subagent-control', () => {
     await mountAgentLoopTestDependencies(ctx)
     await ctx.plugin(AgentLoop, { agents: [] })
     await ctx.plugin(SubagentService)
-    await ctx.plugin(LocalTaskService)
     const fiber = await ctx.plugin(tool)
     expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
     await fiber.dispose()

+ 22 - 15
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts

@@ -68,7 +68,7 @@ function text(result: { content: { type: string; text?: string }[] }): string {
 }
 
 describe('dsh-tool-subagent', () => {
-  it('rejects continuable background policy when the configured provider cannot resume', async () => {
+  it('rejects continuable background policy when the provider cannot prepare continuable children', async () => {
     let failure: unknown
     try {
       await setup({
@@ -668,10 +668,10 @@ describe('dsh-tool-subagent background mode', () => {
     return ctx
   }
 
-  it('keeps a resumable provider one-shot when backgroundMode selects one-shot', async () => {
+  it('keeps a continuable-capable provider one-shot when backgroundMode selects one-shot', async () => {
     const ctx = await backgroundSetup({ provider: 'mock' })
     const parent = ownerAgent(ctx, 'sess-parent')
-    let resumeCalls = 0
+    let prepareCalls = 0
     ctx.subagents.registerProvider({
       name: 'resumable',
       capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
@@ -685,9 +685,9 @@ describe('dsh-tool-subagent background mode', () => {
         }),
         dispose: () => Promise.resolve(),
       }),
-      resume: async () => {
-        resumeCalls += 1
-        throw new Error('one-shot policy must not resume')
+      prepareContinuable: async () => {
+        prepareCalls += 1
+        throw new Error('one-shot policy must not prepare a continuable child')
       },
     })
     tool.apply(ctx, {
@@ -706,7 +706,7 @@ describe('dsh-tool-subagent background mode', () => {
     })
 
     expect(text(started)).toBe('started background subagent task subagent-1')
-    expect(resumeCalls).toBe(0)
+    expect(prepareCalls).toBe(0)
   })
 
   it('returns a task id immediately and the answer is collected through task_output', async () => {
@@ -899,10 +899,13 @@ describe('dsh-tool-subagent continuable background mode', () => {
     return { ctx, parent }
   }
 
-  it('starts a continuable child and returns both ids without send_message', async () => {
+  it('starts a continuable child and returns only its durable id, creating no Task', async () => {
     const { ctx, parent } = await continuableSetup()
     const schema = ctx.tools.schemas().find(s => s.name === 'subagent')!
-    expect(schema.description).not.toContain('send_message')
+    // Continuable delegation has no Task, so the schema promises no collection.
+    expect(schema.description).not.toContain('task_output')
+    expect(schema.description).not.toContain('task_kill')
+    expect(schema.description).toContain('send_message')
 
     const started = await callSubagent(
       ctx,
@@ -910,15 +913,19 @@ describe('dsh-tool-subagent continuable background mode', () => {
       { agent: parent },
     )
     expect(started.isError).toBe(false)
-    const match = /^started subagent (\S+) as task (\S+)$/.exec(text(started))
+    const match = /^started subagent (\S+)$/.exec(text(started))
     expect(match).not.toBeNull()
-    const [, childId, taskId] = match!
-    const snapshot = await ctx.tasks.wait(taskId as never, 5_000, parent)
-    expect(snapshot.status).toBe('completed')
-    expect(ctx.tasks.read(taskId as never, parent).text).toBe('continuable answer')
-    // The child id names a durable session that outlives the settled Task.
+    const [, childId] = match!
+    // No Task was created for the continuable child.
+    expect(ctx.tasks.list(parent)).toEqual([])
+
+    await vi.waitFor(() => {
+      expect(ctx.agents.get(SessionId(childId!))).toBeUndefined()
+    }, { timeout: 5_000 })
+    // The child id names a durable session carrying its continuation descriptor.
     const loaded = await ctx.sessionPersistence.load(SessionId(childId!))
     expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true)
+    expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true)
   })
 
 })