list-agents.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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 '@deepseek-ai/cordis'
  6. import { ToolCallId } 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 SubagentRuntime from '@deepseek-ai/dsh-subagent'
  12. import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
  13. import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
  14. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  15. import { LlmAdapter } from '@deepseek-ai/dsh-llm'
  16. import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  17. import * as tool from '../src/list-agents.ts'
  18. import { parkParent } from './park-parent.ts'
  19. import { TestSessionQuery } from './test-session-query.ts'
  20. /** One scripted response that may wait on a caller-released gate before streaming. */
  21. interface GatedEntry {
  22. chunks: StreamChunk[]
  23. gate?: Promise<undefined>
  24. }
  25. /** Adapter whose entries can hold a model call open until the test releases it. */
  26. class GatedAdapter extends LlmAdapter {
  27. readonly requests: GenerateOptions[] = []
  28. constructor(private script: GatedEntry[]) {
  29. super()
  30. }
  31. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  32. this.requests.push(options)
  33. const entry = this.script.shift()
  34. if (!entry) throw new Error('GatedAdapter: script exhausted')
  35. if (entry.gate) await entry.gate
  36. for (const chunk of entry.chunks) {
  37. if (options.signal?.aborted) throw new Error('aborted')
  38. yield chunk
  39. }
  40. }
  41. }
  42. const testToolSignal = new AbortController().signal
  43. const roots: string[] = []
  44. afterEach(() => {
  45. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  46. })
  47. async function setupWith(adapter: MockAdapter | GatedAdapter) {
  48. const ctx = new Context()
  49. await mountAgentLoopTestDependencies(ctx)
  50. const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
  51. roots.push(root)
  52. await ctx.plugin(JsonlSessionPersistence, { root })
  53. await ctx.plugin(TestSessionQuery)
  54. await ctx.plugin(AgentLoop, { agents: [] })
  55. await ctx.plugin(SubagentRuntime)
  56. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  57. await ctx.plugin(tool)
  58. ctx.llm.registerAdapter(['mock'], adapter)
  59. const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
  60. parkParent(ctx, parent)
  61. return { ctx, parent, adapter }
  62. }
  63. async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
  64. return setupWith(new MockAdapter(script))
  65. }
  66. function text(result: { content: { type: string; text?: string }[] }): string {
  67. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  68. }
  69. let calls = 0
  70. function callTool(
  71. ctx: Context,
  72. name: string,
  73. args: unknown,
  74. agent?: unknown,
  75. signal: AbortSignal = testToolSignal,
  76. ) {
  77. return ctx.tools.execute({
  78. signal,
  79. callId: ToolCallId(`call-${++calls}`),
  80. name,
  81. arguments: args,
  82. ...agent !== undefined ? { agent: agent as never } : {},
  83. })
  84. }
  85. /** Wait until a continuable child released its current Activation. */
  86. async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
  87. await vi.waitFor(() => {
  88. expect(ctx.agents.get(childId)).toBeUndefined()
  89. }, { timeout: 5_000 })
  90. }
  91. describe('dsh-tool-subagent-control/list-agents', () => {
  92. it('registers list_agents once, globally, with only the optional scope parameter', async () => {
  93. const { ctx } = await setup([])
  94. const schemas = ctx.tools.schemas().filter(schema => schema.name === 'list_agents')
  95. expect(schemas).toHaveLength(1)
  96. const parameters = schemas[0]!.parameters as {
  97. properties?: Record<string, { enum?: string[] }>
  98. required?: string[]
  99. }
  100. expect(Object.keys(parameters.properties ?? {})).toEqual(['scope'])
  101. expect(parameters.properties?.scope?.enum).toEqual(['children', 'descendants'])
  102. expect(parameters.required ?? []).toEqual([])
  103. expect(schemas[0]!.description).toContain('send_message')
  104. expect(schemas[0]!.description).toContain('steers a running child at its nearest step boundary')
  105. expect(schemas[0]!.description).not.toContain('send_message` starts a new turn')
  106. expect(schemas[0]!.description).toContain('interrupt_agent')
  107. })
  108. it('renders the empty result as (no subagents)', async () => {
  109. const { ctx, parent } = await setup([])
  110. await ctx.sessions.flush(parent.session)
  111. const result = await callTool(ctx, 'list_agents', {}, parent)
  112. expect(result.isError).toBe(false)
  113. expect(text(result)).toBe('(no subagents)')
  114. })
  115. it('renders children and diagnostics in array order with registry-derived statuses', async () => {
  116. const { ctx, parent } = await setup([textResponse('done')])
  117. const started = await ctx.subagents.startContinuable({
  118. provider: 'spawn',
  119. label: 'real child',
  120. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  121. signal: testToolSignal,
  122. })
  123. await waitNoActivation(ctx, started.childId)
  124. // Pin the render deterministically past the service: the tool is a thin
  125. // adapter, so its fixed text forms are what this test pins. Status comes
  126. // from the live Agent registry, stubbed per candidate id.
  127. const entries: SubagentListEntry[] = [
  128. {
  129. kind: 'child',
  130. id: SessionId('one-shot-child'),
  131. label: 'finished once',
  132. mode: 'one-shot',
  133. activity: 'inactive',
  134. hasChildren: false,
  135. },
  136. {
  137. kind: 'child',
  138. id: started.childId,
  139. label: 'real child',
  140. mode: 'continuable',
  141. activity: 'inactive',
  142. hasChildren: false,
  143. },
  144. {
  145. kind: 'child',
  146. id: SessionId('running-child'),
  147. label: 'still working',
  148. mode: 'continuable',
  149. activity: 'running',
  150. hasChildren: true,
  151. },
  152. {
  153. kind: 'child',
  154. id: SessionId('waiting-child'),
  155. label: 'waiting on descendants',
  156. mode: 'continuable',
  157. activity: 'running',
  158. hasChildren: true,
  159. },
  160. { kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
  161. ]
  162. ctx.subagents.listChildren = () => Promise.resolve(entries)
  163. const agents = new Map<string, { status: 'running' | 'idle' }>([
  164. ['running-child', { status: 'running' }],
  165. ['waiting-child', { status: 'idle' }],
  166. ])
  167. vi.spyOn(ctx.agents, 'get').mockImplementation(id => agents.get(id) as never)
  168. const result = await callTool(ctx, 'list_agents', {}, parent)
  169. expect(result.isError).toBe(false)
  170. // `ready` is the resumable counterpart to a live `running` record, not a
  171. // claim that the child's conversation ended with a result to collect.
  172. expect(text(result)).toBe(
  173. `${started.childId} [ready] — real child\n`
  174. + 'running-child [running] — still working\n'
  175. + 'waiting-child [idle] — waiting on descendants\n'
  176. + 'broken-child [diagnostic: corrupt]',
  177. )
  178. })
  179. it('resolves omitted scope to children and forwards the tool cancellation signal', async () => {
  180. const { ctx, parent } = await setup([])
  181. const signal = new AbortController().signal
  182. const listChildren = vi.spyOn(ctx.subagents, 'listChildren').mockResolvedValue([])
  183. const result = await callTool(ctx, 'list_agents', {}, parent, signal)
  184. expect(result.isError).toBe(false)
  185. expect(listChildren).toHaveBeenCalledWith(parent.id, signal)
  186. })
  187. it('lists a real settled continuable child and omits a real one-shot sibling', async () => {
  188. const { ctx, parent } = await setup([textResponse('once'), textResponse('done')])
  189. const oneShot = await ctx.subagents.start('spawn', {
  190. label: 'finished once',
  191. prompt: [{ type: 'text', text: 'one-shot task' }],
  192. parent,
  193. signal: new AbortController().signal,
  194. })
  195. await oneShot.result
  196. await oneShot.dispose()
  197. const started = await ctx.subagents.startContinuable({
  198. provider: 'spawn',
  199. label: 'summarize the doc',
  200. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  201. signal: testToolSignal,
  202. })
  203. await waitNoActivation(ctx, started.childId)
  204. const result = await callTool(ctx, 'list_agents', {}, parent)
  205. expect(result.isError).toBe(false)
  206. expect(text(result)).toBe(`${started.childId} [ready] — summarize the doc`)
  207. })
  208. it('describes ready as resumable and pins the status vocabulary', async () => {
  209. const { ctx } = await setup([])
  210. const schema = ctx.tools.schemas().find(candidate => candidate.name === 'list_agents')
  211. // Completion reaches the parent through its notice; listing is discovery,
  212. // so its inactive status must not send the model looking for a result.
  213. expect(schema?.description).toContain('you are told when one finishes')
  214. expect(schema?.description).toContain('resumable, not terminal')
  215. // The enum is the closed vocabulary the model renders, so pin it rather than
  216. // scanning prose that legitimately reads "not to poll for completion".
  217. const variants = ctx.tools.get('list_agents')?.output.schema.items?.oneOf ?? []
  218. const child = variants.find(variant => variant.properties?.kind?.enum?.includes('child'))
  219. expect(child?.properties?.status?.enum).toEqual(['running', 'idle', 'ready'])
  220. })
  221. it('fails loud when invoked without a calling agent', async () => {
  222. const { ctx } = await setup([])
  223. const result = await callTool(ctx, 'list_agents', {})
  224. expect(result.isError).toBe(true)
  225. expect(text(result)).toContain('requires a calling agent')
  226. })
  227. it('unregisters with its plugin fiber (HMR safety)', async () => {
  228. const ctx = new Context()
  229. await mountAgentLoopTestDependencies(ctx)
  230. await ctx.plugin(AgentLoop, { agents: [] })
  231. await ctx.plugin(SubagentRuntime)
  232. const fiber = await ctx.plugin(tool)
  233. expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
  234. await fiber.dispose()
  235. expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
  236. })
  237. it('has the namespace-plugin export shape', () => {
  238. expect('default' in tool).toBe(false)
  239. expect(tool.name).toBe('tool-subagent-list-agents')
  240. expect(tool.inject).toEqual(['tools', 'subagents', 'agents'])
  241. expect(typeof tool.apply).toBe('function')
  242. })
  243. it('walks the complete descendant tree in pre-order with parent and depth annotations', async () => {
  244. const releaseChild = Promise.withResolvers<undefined>()
  245. const releaseGrandchild = Promise.withResolvers<undefined>()
  246. const adapter = new GatedAdapter([
  247. { chunks: textResponse('child'), gate: releaseChild.promise },
  248. { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
  249. ])
  250. const { ctx, parent } = await setupWith(adapter)
  251. const started = await ctx.subagents.startContinuable({
  252. provider: 'spawn',
  253. label: 'waiting branch',
  254. request: { prompt: [{ type: 'text', text: 'branch work' }], parent },
  255. signal: testToolSignal,
  256. })
  257. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  258. const child = ctx.agents.get(started.childId)!
  259. const grandchild = await ctx.subagents.startContinuable({
  260. provider: 'spawn',
  261. label: 'nested leaf',
  262. request: { prompt: [{ type: 'text', text: 'leaf work' }], parent: child },
  263. signal: testToolSignal,
  264. })
  265. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
  266. // The branch finishes its own turn but stays resident waiting on the
  267. // grandchild it owns: the live-registry `idle` status.
  268. releaseChild.resolve(undefined)
  269. await vi.waitFor(() => {
  270. expect(ctx.agents.get(started.childId)?.status).toBe('idle')
  271. }, { timeout: 5_000 })
  272. const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
  273. expect(result.isError).toBe(false)
  274. expect(text(result)).toBe(
  275. `${started.childId} [idle] parent=${parent.id} depth=1 — waiting branch\n`
  276. + `${grandchild.childId} [running] parent=${started.childId} depth=2 — nested leaf`,
  277. )
  278. releaseGrandchild.resolve(undefined)
  279. await waitNoActivation(ctx, grandchild.childId)
  280. await waitNoActivation(ctx, started.childId)
  281. })
  282. it('omits one-shot intermediates from descendants output while surfacing what they own', async () => {
  283. const { ctx, parent } = await setup([])
  284. // Deterministic service rows: a one-shot intermediate owning a continuable
  285. // leaf, plus a positioned diagnostic. The tool filters only the one-shot.
  286. ctx.subagents.listDescendants = () => Promise.resolve([
  287. {
  288. kind: 'child',
  289. id: SessionId('one-shot-mid'),
  290. label: 'one-shot intermediate',
  291. mode: 'one-shot',
  292. activity: 'inactive',
  293. hasChildren: true,
  294. parentId: parent.id,
  295. depth: 1,
  296. },
  297. {
  298. kind: 'child',
  299. id: SessionId('deep-leaf'),
  300. label: 'deep leaf',
  301. mode: 'continuable',
  302. activity: 'inactive',
  303. hasChildren: false,
  304. parentId: SessionId('one-shot-mid'),
  305. depth: 2,
  306. },
  307. {
  308. kind: 'diagnostic',
  309. id: SessionId('broken-node'),
  310. reason: 'unavailable',
  311. parentId: parent.id,
  312. depth: 1,
  313. },
  314. ])
  315. const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent)
  316. expect(result.isError).toBe(false)
  317. expect(text(result)).toBe(
  318. 'deep-leaf [ready] parent=one-shot-mid depth=2 — deep leaf\n'
  319. + `broken-node [diagnostic: unavailable] parent=${parent.id} depth=1`,
  320. )
  321. })
  322. it('preserves explicit descendants scope and forwards the tool cancellation signal', async () => {
  323. const { ctx, parent } = await setup([])
  324. const signal = new AbortController().signal
  325. const listDescendants = vi.spyOn(ctx.subagents, 'listDescendants').mockResolvedValue([])
  326. const result = await callTool(ctx, 'list_agents', { scope: 'descendants' }, parent, signal)
  327. expect(result.isError).toBe(false)
  328. expect(listDescendants).toHaveBeenCalledWith(parent.id, signal)
  329. })
  330. })