tool-subagent-control.spec.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { Context } from 'cordis'
  6. import { CallId } from '@deepseek-ai/dsh-llm'
  7. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  8. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  9. import { SessionId } from '@deepseek-ai/dsh-session'
  10. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  11. import SubagentService from '@deepseek-ai/dsh-subagent'
  12. import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
  13. import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  14. import * as tool from '../src/index.ts'
  15. const testToolSignal = new AbortController().signal
  16. const roots: string[] = []
  17. afterEach(() => {
  18. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  19. })
  20. async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
  21. const ctx = new Context()
  22. await mountAgentLoopTestDependencies(ctx)
  23. const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
  24. roots.push(root)
  25. await ctx.plugin(JsonlSessionPersistence, { root })
  26. await ctx.plugin(AgentLoop, { agents: [] })
  27. await ctx.plugin(SubagentService)
  28. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  29. await ctx.plugin(tool)
  30. const adapter = new MockAdapter(script)
  31. ctx.llm.registerAdapter(['mock'], adapter)
  32. const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
  33. return { ctx, parent, adapter }
  34. }
  35. function text(result: { content: { type: string; text?: string }[] }): string {
  36. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  37. }
  38. let calls = 0
  39. function callTool(
  40. ctx: Context,
  41. name: string,
  42. args: unknown,
  43. agent?: unknown,
  44. signal: AbortSignal = testToolSignal,
  45. ) {
  46. return ctx.tools.execute({
  47. signal,
  48. callId: CallId(`call-${++calls}`),
  49. name,
  50. arguments: args,
  51. ...agent !== undefined ? { agent: agent as never } : {},
  52. })
  53. }
  54. /** Wait until a child's Activation released its handle. */
  55. async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
  56. await vi.waitFor(() => {
  57. expect(ctx.agents.get(childId)).toBeUndefined()
  58. }, { timeout: 5_000 })
  59. }
  60. describe('dsh-tool-subagent-control', () => {
  61. it('registers send_message once, globally, with the two required parameters', async () => {
  62. const { ctx } = await setup([])
  63. const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message')
  64. expect(schemas).toHaveLength(1)
  65. const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  66. expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id'])
  67. // The continuable path has no Task, so the schema must not promise one.
  68. expect(schemas[0]!.description).not.toContain('task_output')
  69. expect(schemas[0]!.description).not.toContain('task id')
  70. // Follow-up ordering is model-visible: it cannot redirect the open turn.
  71. expect(schemas[0]!.description).toContain('next turn')
  72. })
  73. it('cold-resumes a settled child and reports the queued next turn', async () => {
  74. const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
  75. const started = await ctx.subagents.startContinuable({
  76. provider: 'spawn',
  77. label: 'child task',
  78. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  79. signal: testToolSignal,
  80. })
  81. await waitNoActivation(ctx, started.childId)
  82. const result = await callTool(ctx, 'send_message', {
  83. subagent_id: started.childId,
  84. message: 'and then?',
  85. }, parent)
  86. expect(result.isError).toBe(false)
  87. expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`)
  88. await waitNoActivation(ctx, started.childId)
  89. const loaded = await ctx.sessionPersistence.load(started.childId)
  90. const followUp = loaded.events.findLast(event => event.type === 'user/message')
  91. // Durable provenance records the calling agent without granting authority.
  92. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
  93. kind: 'coordinator',
  94. senderSessionId: parent.id,
  95. })
  96. })
  97. it('queues behind an open turn instead of joining it', async () => {
  98. const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
  99. const started = await ctx.subagents.startContinuable({
  100. provider: 'spawn',
  101. label: 'long work',
  102. request: { prompt: [{ type: 'text', text: 'long work' }], parent },
  103. signal: testToolSignal,
  104. })
  105. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  106. const result = await callTool(ctx, 'send_message', {
  107. subagent_id: started.childId,
  108. message: 'also consider Y',
  109. }, parent)
  110. expect(result.isError).toBe(false)
  111. await waitNoActivation(ctx, started.childId)
  112. const loaded = await ctx.sessionPersistence.load(started.childId)
  113. const prompts = loaded.events.flatMap(event => event.type === 'user/message'
  114. ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
  115. : [])
  116. // A follow-up is its own later turn, never steering inside the first one.
  117. expect(prompts).toEqual(['long work', 'also consider Y'])
  118. })
  119. it('reports a delivery failure as an errored, not-delivered result', async () => {
  120. const { ctx, parent } = await setup([])
  121. const result = await callTool(ctx, 'send_message', {
  122. subagent_id: 'no-such-child',
  123. message: 'hello?',
  124. }, parent)
  125. expect(result.isError).toBe(true)
  126. expect(text(result)).toContain('unavailable')
  127. })
  128. it('rejects a caller that is not the child\'s durable direct parent', async () => {
  129. const { ctx, parent } = await setup([textResponse('first')])
  130. const started = await ctx.subagents.startContinuable({
  131. provider: 'spawn',
  132. label: 'child task',
  133. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  134. signal: testToolSignal,
  135. })
  136. await waitNoActivation(ctx, started.childId)
  137. const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
  138. const result = await callTool(ctx, 'send_message', {
  139. subagent_id: started.childId,
  140. message: 'mine now',
  141. }, stranger)
  142. expect(result.isError).toBe(true)
  143. expect(text(result)).toContain('another parent session')
  144. })
  145. it('fails loud when invoked without a calling agent', async () => {
  146. const { ctx } = await setup([])
  147. const result = await callTool(ctx, 'send_message', { subagent_id: 'x', message: 'y' })
  148. expect(result.isError).toBe(true)
  149. expect(text(result)).toContain('requires a calling agent')
  150. })
  151. it('unregisters with its plugin fiber (HMR safety)', async () => {
  152. const ctx = new Context()
  153. await mountAgentLoopTestDependencies(ctx)
  154. await ctx.plugin(AgentLoop, { agents: [] })
  155. await ctx.plugin(SubagentService)
  156. const fiber = await ctx.plugin(tool)
  157. expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
  158. await fiber.dispose()
  159. expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
  160. })
  161. it('has the namespace-plugin export shape (no stray default)', () => {
  162. expect('default' in tool).toBe(false)
  163. expect(tool.name).toBe('tool-subagent-control')
  164. expect(tool.inject).toEqual(['tools', 'subagents'])
  165. expect(typeof tool.apply).toBe('function')
  166. })
  167. })