tool-subagent-control.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  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 { 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  12. import SubagentRuntime 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/index.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, maxRetries: 10, retryDelay: 100 })
  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-subagent-control-'))
  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(SessionProjectionRegistry)
  56. await ctx.plugin(SubagentRuntime)
  57. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  58. await ctx.plugin(tool)
  59. ctx.llm.registerAdapter(['mock'], adapter)
  60. const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
  61. parkParent(ctx, parent)
  62. return { ctx, parent, adapter }
  63. }
  64. async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
  65. return setupWith(new MockAdapter(script))
  66. }
  67. function text(result: { content: { type: string; text?: string }[] }): string {
  68. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  69. }
  70. let calls = 0
  71. function callTool(
  72. ctx: Context,
  73. name: string,
  74. args: unknown,
  75. agent?: unknown,
  76. signal: AbortSignal = testToolSignal,
  77. ) {
  78. return ctx.tools.execute({
  79. signal,
  80. callId: CallId(`call-${++calls}`),
  81. name,
  82. arguments: args,
  83. ...agent !== undefined ? { agent: agent as never } : {},
  84. })
  85. }
  86. /** Wait until a child's Activation released its handle. */
  87. async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
  88. await vi.waitFor(() => {
  89. expect(ctx.agents.get(childId)).toBeUndefined()
  90. }, { timeout: 5_000 })
  91. }
  92. describe('dsh-tool-subagent-control', () => {
  93. it('registers send_message once, globally, with the two required parameters', async () => {
  94. const { ctx } = await setup([])
  95. const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message')
  96. expect(schemas).toHaveLength(1)
  97. const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  98. expect(Object.keys(props).sort()).toEqual(['message', 'subagent_id'])
  99. // The continuable path has no Task, so the schema must not promise one.
  100. expect(schemas[0]!.description).not.toContain('job_output')
  101. expect(schemas[0]!.description).not.toContain('job id')
  102. // Follow-up ordering is model-visible: it cannot redirect the open turn.
  103. expect(schemas[0]!.description).toContain('next turn')
  104. })
  105. it('cold-resumes a settled child and reports the queued next turn', async () => {
  106. const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
  107. const started = await ctx.subagents.startContinuable({
  108. provider: 'spawn',
  109. label: 'child task',
  110. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  111. signal: testToolSignal,
  112. })
  113. await waitNoActivation(ctx, started.childId)
  114. const result = await callTool(ctx, 'send_message', {
  115. subagent_id: started.childId,
  116. message: 'and then?',
  117. }, parent)
  118. expect(result.isError).toBe(false)
  119. expect(text(result)).toBe(`message queued as the next turn for subagent ${started.childId}`)
  120. await waitNoActivation(ctx, started.childId)
  121. const loaded = await ctx.sessionPersistence.load(started.childId)
  122. const followUp = loaded.events.findLast(event => event.type === 'user/message')
  123. // The durable message source records the calling agent without granting authority.
  124. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
  125. kind: 'coordinator',
  126. form: 'relay',
  127. senderSessionId: parent.id,
  128. })
  129. })
  130. it('queues behind an open turn instead of joining it', async () => {
  131. const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
  132. const started = await ctx.subagents.startContinuable({
  133. provider: 'spawn',
  134. label: 'long work',
  135. request: { prompt: [{ type: 'text', text: 'long work' }], parent },
  136. signal: testToolSignal,
  137. })
  138. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  139. const result = await callTool(ctx, 'send_message', {
  140. subagent_id: started.childId,
  141. message: 'also consider Y',
  142. }, parent)
  143. expect(result.isError).toBe(false)
  144. await waitNoActivation(ctx, started.childId)
  145. const loaded = await ctx.sessionPersistence.load(started.childId)
  146. const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
  147. ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
  148. : [])
  149. // A follow-up is its own later turn, never steering inside the first one.
  150. expect(prompts).toEqual(['long work', 'also consider Y'])
  151. })
  152. it('reports a delivery failure as an errored, not-delivered result', async () => {
  153. const { ctx, parent } = await setup([])
  154. const result = await callTool(ctx, 'send_message', {
  155. subagent_id: 'no-such-child',
  156. message: 'hello?',
  157. }, parent)
  158. expect(result.isError).toBe(true)
  159. expect(text(result)).toContain('unavailable')
  160. })
  161. it('rejects a caller that is not the child\'s durable direct parent', async () => {
  162. const { ctx, parent } = await setup([textResponse('first')])
  163. const started = await ctx.subagents.startContinuable({
  164. provider: 'spawn',
  165. label: 'child task',
  166. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  167. signal: testToolSignal,
  168. })
  169. await waitNoActivation(ctx, started.childId)
  170. const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
  171. const result = await callTool(ctx, 'send_message', {
  172. subagent_id: started.childId,
  173. message: 'mine now',
  174. }, stranger)
  175. expect(result.isError).toBe(true)
  176. expect(text(result)).toContain('another parent session')
  177. })
  178. it('fails loud when invoked without a calling agent', async () => {
  179. const { ctx } = await setup([])
  180. const result = await callTool(ctx, 'send_message', { subagent_id: 'x', message: 'y' })
  181. expect(result.isError).toBe(true)
  182. expect(text(result)).toContain('requires a calling agent')
  183. })
  184. it('unregisters with its plugin fiber (HMR safety)', async () => {
  185. const ctx = new Context()
  186. await mountAgentLoopTestDependencies(ctx)
  187. await ctx.plugin(AgentLoop, { agents: [] })
  188. await ctx.plugin(SubagentRuntime)
  189. const fiber = await ctx.plugin(tool)
  190. expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
  191. expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(true)
  192. await fiber.dispose()
  193. expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
  194. expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(false)
  195. })
  196. it('has the namespace-plugin export shape (no stray default)', () => {
  197. expect('default' in tool).toBe(false)
  198. expect(tool.name).toBe('tool-subagent-control')
  199. expect(tool.inject).toEqual(['tools', 'subagents'])
  200. expect(typeof tool.apply).toBe('function')
  201. })
  202. })
  203. describe('dsh-tool-subagent-control interrupt_agent', () => {
  204. it('registers interrupt_agent with the single agent_id parameter and current-turn wording', async () => {
  205. const { ctx } = await setup([])
  206. const schemas = ctx.tools.schemas().filter(schema => schema.name === 'interrupt_agent')
  207. expect(schemas).toHaveLength(1)
  208. const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  209. expect(Object.keys(props)).toEqual(['agent_id'])
  210. expect(schemas[0]!.description).toContain('current turn')
  211. expect(schemas[0]!.description).toContain('send_message')
  212. })
  213. it('interrupts a running direct child with the parent cause, parking its queue', async () => {
  214. const releaseFirst = Promise.withResolvers<undefined>()
  215. const adapter = new GatedAdapter([
  216. { chunks: textResponse('held'), gate: releaseFirst.promise },
  217. { chunks: textResponse('parked answer') },
  218. { chunks: textResponse('waking answer') },
  219. ])
  220. const { ctx, parent } = await setupWith(adapter)
  221. const started = await ctx.subagents.startContinuable({
  222. provider: 'spawn',
  223. label: 'long work',
  224. request: { prompt: [{ type: 'text', text: 'long work' }], parent },
  225. signal: testToolSignal,
  226. })
  227. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  228. const child = ctx.agents.get(started.childId)!
  229. const queued = await callTool(ctx, 'send_message', {
  230. subagent_id: started.childId,
  231. message: 'parked follow-up',
  232. }, parent)
  233. expect(queued.isError).toBe(false)
  234. const cancelSpy = vi.spyOn(child, 'cancel')
  235. const result = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
  236. expect(result.isError).toBe(false)
  237. expect(text(result)).toBe(`interrupt requested for agent ${started.childId}`)
  238. expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
  239. releaseFirst.resolve(undefined)
  240. await child.whenIdle()
  241. // Parked, not resumed: the queued follow-up waits for a waking send.
  242. expect(adapter.requests).toHaveLength(1)
  243. expect(child.inbox.nextTurn).toHaveLength(1)
  244. const waking = await callTool(ctx, 'send_message', {
  245. subagent_id: started.childId,
  246. message: 'wake up',
  247. }, parent)
  248. expect(waking.isError).toBe(false)
  249. await waitNoActivation(ctx, started.childId)
  250. const loaded = await ctx.sessionPersistence.load(started.childId)
  251. const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
  252. ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
  253. : [])
  254. expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up'])
  255. })
  256. it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => {
  257. const releaseChild = Promise.withResolvers<undefined>()
  258. const releaseGrandchild = Promise.withResolvers<undefined>()
  259. const adapter = new GatedAdapter([
  260. { chunks: textResponse('child'), gate: releaseChild.promise },
  261. { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
  262. ])
  263. const { ctx, parent } = await setupWith(adapter)
  264. const started = await ctx.subagents.startContinuable({
  265. provider: 'spawn',
  266. label: 'child',
  267. request: { prompt: [{ type: 'text', text: 'child work' }], parent },
  268. signal: testToolSignal,
  269. })
  270. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  271. const child = ctx.agents.get(started.childId)!
  272. const grandchild = await ctx.subagents.startContinuable({
  273. provider: 'spawn',
  274. label: 'grandchild',
  275. request: { prompt: [{ type: 'text', text: 'grandchild work' }], parent: child },
  276. signal: testToolSignal,
  277. })
  278. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
  279. const grandchildAgent = ctx.agents.get(grandchild.childId)!
  280. const cancelSpy = vi.spyOn(grandchildAgent, 'cancel')
  281. const result = await callTool(ctx, 'interrupt_agent', { agent_id: grandchild.childId }, parent)
  282. expect(result.isError).toBe(false)
  283. expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
  284. releaseChild.resolve(undefined)
  285. releaseGrandchild.resolve(undefined)
  286. await waitNoActivation(ctx, grandchild.childId)
  287. await waitNoActivation(ctx, started.childId)
  288. })
  289. it('rejects self, sibling, and unrelated callers without touching the target', async () => {
  290. const releaseA = Promise.withResolvers<undefined>()
  291. const releaseB = Promise.withResolvers<undefined>()
  292. const adapter = new GatedAdapter([
  293. { chunks: textResponse('a'), gate: releaseA.promise },
  294. { chunks: textResponse('b'), gate: releaseB.promise },
  295. ])
  296. const { ctx, parent } = await setupWith(adapter)
  297. const target = await ctx.subagents.startContinuable({
  298. provider: 'spawn',
  299. label: 'target',
  300. request: { prompt: [{ type: 'text', text: 'a' }], parent },
  301. signal: testToolSignal,
  302. })
  303. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  304. const sibling = await ctx.subagents.startContinuable({
  305. provider: 'spawn',
  306. label: 'sibling',
  307. request: { prompt: [{ type: 'text', text: 'b' }], parent },
  308. signal: testToolSignal,
  309. })
  310. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
  311. const targetAgent = ctx.agents.get(target.childId)!
  312. const siblingAgent = ctx.agents.get(sibling.childId)!
  313. const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
  314. const cancelSpy = vi.spyOn(targetAgent, 'cancel')
  315. const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent)
  316. expect(self.isError).toBe(true)
  317. expect(text(self)).toContain('cannot interrupt itself')
  318. const fromSibling = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, siblingAgent)
  319. expect(fromSibling.isError).toBe(true)
  320. expect(text(fromSibling)).toContain('not a live descendant')
  321. const fromStranger = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, stranger)
  322. expect(fromStranger.isError).toBe(true)
  323. expect(text(fromStranger)).toContain('not a live descendant')
  324. expect(cancelSpy).not.toHaveBeenCalled()
  325. releaseA.resolve(undefined)
  326. releaseB.resolve(undefined)
  327. await waitNoActivation(ctx, target.childId)
  328. await waitNoActivation(ctx, sibling.childId)
  329. })
  330. it('accepts an absent target as a no-op without cold-resuming it', async () => {
  331. const { ctx, parent } = await setup([textResponse('done')])
  332. const started = await ctx.subagents.startContinuable({
  333. provider: 'spawn',
  334. label: 'settled child',
  335. request: { prompt: [{ type: 'text', text: 'child work' }], parent },
  336. signal: testToolSignal,
  337. })
  338. await waitNoActivation(ctx, started.childId)
  339. const settled = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
  340. expect(settled.isError).toBe(false)
  341. expect(text(settled)).toBe(`interrupt requested for agent ${started.childId}`)
  342. const unknown = await callTool(ctx, 'interrupt_agent', { agent_id: 'no-such-agent' }, parent)
  343. expect(unknown.isError).toBe(false)
  344. // No cold resume: the settled target never rematerialized.
  345. expect(ctx.agents.get(started.childId)).toBeUndefined()
  346. })
  347. it('fails loud when invoked without a calling agent', async () => {
  348. const { ctx } = await setup([])
  349. const result = await callTool(ctx, 'interrupt_agent', { agent_id: 'x' })
  350. expect(result.isError).toBe(true)
  351. expect(text(result)).toContain('requires a calling agent')
  352. })
  353. })