From 357f317b4cddab70010a5c8e2d14b056489b5680 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 30 Jul 2026 13:33:46 +0800 Subject: [PATCH] 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. --- packages/subagent/subagent/src/index.ts | 6 +- .../subagent/subagent/tests/service.spec.ts | 31 ++-- .../tests/tool-subagent-control.spec.ts | 133 ++++++++---------- .../tool-subagent/tests/tool-subagent.spec.ts | 37 +++-- 4 files changed, 101 insertions(+), 106 deletions(-) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0bfebc8cf5..3be59f2266 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/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 { + async startContinuable(spec: ContinuableStartSpec): Promise { 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', ) } diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index e86de737ed..8b68e9554f 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/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 = {}): 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 { + async start(request: SubagentStartRequest): Promise { 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() - .not.toExtend[1]>() + expectTypeOf[1]>().toExtend() 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([ diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index b035fa1127..3b1225f63e 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/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[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 { + 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 }).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) } - } + 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', + request: { prompt: [{ type: 'text', text: 'long work' }], parent }, + signal: testToolSignal, + }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const result = await callTool(ctx, 'send_message', { - subagent_id: 'some-child', + subagent_id: started.childId, 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({ - provider: 'spawn', - label: 'hung work', - request: { prompt: [{ type: 'text', text: 'wait' }], parent }, - }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const deliveryStarted: PromiseWithResolvers = 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', { - subagent_id: started.childId, - message: 'follow up', - }, parent, controller.signal) - await deliveryStarted.promise - controller.abort('parent tool cancelled') - - 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() diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1b45640a94..fdcc359447 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/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) }) })