list-agents.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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 type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
  13. import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
  14. import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  15. import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
  16. import * as tool from '../src/list-agents.ts'
  17. const testToolSignal = new AbortController().signal
  18. const roots: string[] = []
  19. afterEach(() => {
  20. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  21. })
  22. async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
  23. const ctx = new Context()
  24. await mountAgentLoopTestDependencies(ctx)
  25. const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
  26. roots.push(root)
  27. await ctx.plugin(JsonlSessionPersistence, { root })
  28. await ctx.plugin(AgentLoop, { agents: [] })
  29. await ctx.plugin(SubagentService)
  30. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  31. await ctx.plugin(TestSessionQueryService)
  32. await ctx.plugin(tool)
  33. ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
  34. const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
  35. return { ctx, parent }
  36. }
  37. function text(result: { content: { type: string; text?: string }[] }): string {
  38. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  39. }
  40. let calls = 0
  41. function callTool(
  42. ctx: Context,
  43. name: string,
  44. args: unknown,
  45. agent?: unknown,
  46. signal: AbortSignal = testToolSignal,
  47. ) {
  48. return ctx.tools.execute({
  49. signal,
  50. callId: CallId(`call-${++calls}`),
  51. name,
  52. arguments: args,
  53. ...agent !== undefined ? { agent: agent as never } : {},
  54. })
  55. }
  56. /** Wait until a continuable child released its current Activation. */
  57. async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
  58. await vi.waitFor(() => {
  59. expect(ctx.agents.get(childId)).toBeUndefined()
  60. }, { timeout: 5_000 })
  61. }
  62. describe('dsh-tool-subagent-control/list-agents', () => {
  63. it('registers list_agents once, globally, with no parameters', async () => {
  64. const { ctx } = await setup([])
  65. const schemas = ctx.tools.schemas().filter(schema => schema.name === 'list_agents')
  66. expect(schemas).toHaveLength(1)
  67. const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  68. expect(Object.keys(props)).toEqual([])
  69. expect(schemas[0]!.description).toContain('send_message')
  70. })
  71. it('renders the empty result as (no subagents)', async () => {
  72. const { ctx, parent } = await setup([])
  73. await ctx.sessions.flush(parent.session)
  74. const result = await callTool(ctx, 'list_agents', {}, parent)
  75. expect(result.isError).toBe(false)
  76. expect(text(result)).toBe('(no subagents)')
  77. })
  78. it('renders children and diagnostics in array order with the fixed text forms', async () => {
  79. const { ctx, parent } = await setup([textResponse('done')])
  80. const started = await ctx.subagents.startContinuable({
  81. provider: 'spawn',
  82. label: 'real child',
  83. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  84. signal: testToolSignal,
  85. })
  86. await waitNoActivation(ctx, started.childId)
  87. // Pin the render deterministically past the service: the tool is a thin
  88. // adapter, so its fixed text forms are what this test pins.
  89. const entries: SubagentListEntry[] = [
  90. {
  91. kind: 'child',
  92. id: SessionId('one-shot-child'),
  93. label: 'finished once',
  94. mode: 'one-shot',
  95. activity: 'inactive',
  96. hasChildren: false,
  97. },
  98. {
  99. kind: 'child',
  100. id: started.childId,
  101. label: 'real child',
  102. mode: 'continuable',
  103. activity: 'inactive',
  104. hasChildren: false,
  105. },
  106. {
  107. kind: 'child',
  108. id: SessionId('running-child'),
  109. label: 'still working',
  110. mode: 'continuable',
  111. activity: 'running',
  112. hasChildren: true,
  113. },
  114. { kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
  115. ]
  116. ctx.subagents.listChildren = () => Promise.resolve(entries)
  117. const result = await callTool(ctx, 'list_agents', {}, parent)
  118. expect(result.isError).toBe(false)
  119. expect(text(result)).toBe(
  120. `${started.childId} [complete] — real child\n`
  121. + 'running-child [running] — still working\n'
  122. + 'broken-child [diagnostic: corrupt]',
  123. )
  124. })
  125. it('forwards the tool cancellation signal to child enumeration', async () => {
  126. const { ctx, parent } = await setup([])
  127. const signal = new AbortController().signal
  128. const listChildren = vi.spyOn(ctx.subagents, 'listChildren').mockResolvedValue([])
  129. const result = await callTool(ctx, 'list_agents', {}, parent, signal)
  130. expect(result.isError).toBe(false)
  131. expect(listChildren).toHaveBeenCalledWith(parent.id, signal)
  132. })
  133. it('lists a real settled continuable child and omits a real one-shot sibling', async () => {
  134. const { ctx, parent } = await setup([textResponse('once'), textResponse('done')])
  135. const oneShot = await ctx.subagents.start('spawn', {
  136. label: 'finished once',
  137. prompt: [{ type: 'text', text: 'one-shot task' }],
  138. parent,
  139. signal: new AbortController().signal,
  140. })
  141. await oneShot.result
  142. await oneShot.dispose()
  143. const started = await ctx.subagents.startContinuable({
  144. provider: 'spawn',
  145. label: 'summarize the doc',
  146. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  147. signal: testToolSignal,
  148. })
  149. await waitNoActivation(ctx, started.childId)
  150. const result = await callTool(ctx, 'list_agents', {}, parent)
  151. expect(result.isError).toBe(false)
  152. expect(text(result)).toBe(`${started.childId} [complete] — summarize the doc`)
  153. })
  154. it('fails loud when invoked without a calling agent', async () => {
  155. const { ctx } = await setup([])
  156. const result = await callTool(ctx, 'list_agents', {})
  157. expect(result.isError).toBe(true)
  158. expect(text(result)).toContain('requires a calling agent')
  159. })
  160. it('unregisters with its plugin fiber (HMR safety)', async () => {
  161. const ctx = new Context()
  162. await mountAgentLoopTestDependencies(ctx)
  163. await ctx.plugin(AgentLoop, { agents: [] })
  164. await ctx.plugin(SubagentService)
  165. await ctx.plugin(TestSessionQueryService)
  166. const fiber = await ctx.plugin(tool)
  167. expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
  168. await fiber.dispose()
  169. expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
  170. })
  171. it('has the namespace-plugin export shape and requires sessionQuery at load', () => {
  172. expect('default' in tool).toBe(false)
  173. expect(tool.name).toBe('tool-subagent-list-agents')
  174. expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery'])
  175. expect(typeof tool.apply).toBe('function')
  176. })
  177. })