tool-subagent-control.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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 type { Agent } from '@deepseek-ai/dsh-agent'
  7. import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
  8. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  9. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  10. import { SessionId } from '@deepseek-ai/dsh-session'
  11. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  12. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  13. import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
  14. import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
  15. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  16. import { LlmAdapter } from '@deepseek-ai/dsh-llm'
  17. import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  18. import * as tool from '../src/index.ts'
  19. import { parkParent } from './park-parent.ts'
  20. import { TestSessionQuery } from './test-session-query.ts'
  21. import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts'
  22. /** One scripted response that may wait on a caller-released gate before streaming. */
  23. interface GatedEntry {
  24. chunks: StreamChunk[]
  25. gate?: Promise<undefined>
  26. }
  27. /** Adapter whose entries can hold a model call open until the test releases it. */
  28. class GatedAdapter extends LlmAdapter {
  29. readonly requests: GenerateOptions[] = []
  30. constructor(private script: GatedEntry[]) {
  31. super()
  32. }
  33. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  34. this.requests.push(options)
  35. const entry = this.script.shift()
  36. if (!entry) throw new Error('GatedAdapter: script exhausted')
  37. if (entry.gate) await entry.gate
  38. for (const chunk of entry.chunks) {
  39. if (options.signal?.aborted) throw new Error('aborted')
  40. yield chunk
  41. }
  42. }
  43. }
  44. const testToolSignal = new AbortController().signal
  45. const roots: string[] = []
  46. afterEach(() => {
  47. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
  48. })
  49. async function setupWith(adapter: MockAdapter | GatedAdapter, park = true) {
  50. const ctx = new Context()
  51. await mountAgentLoopTestDependencies(ctx)
  52. const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
  53. roots.push(root)
  54. await ctx.plugin(JsonlSessionPersistence, { root })
  55. await ctx.plugin(TestSessionQuery)
  56. await ctx.plugin(AgentLoop, { agents: [] })
  57. await ctx.plugin(SubagentRuntime)
  58. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  59. await ctx.plugin(SubagentFork, { providerName: 'fork' })
  60. await ctx.plugin(tool)
  61. ctx.llm.registerAdapter(['mock'], adapter)
  62. const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
  63. if (park) parkParent(ctx, parent)
  64. return { ctx, parent, adapter }
  65. }
  66. async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
  67. return setupWith(new MockAdapter(script))
  68. }
  69. function text(result: { content: { type: string; text?: string }[] }): string {
  70. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  71. }
  72. let calls = 0
  73. function callTool(
  74. ctx: Context,
  75. name: string,
  76. args: unknown,
  77. agent?: unknown,
  78. signal: AbortSignal = testToolSignal,
  79. ) {
  80. return ctx.tools.execute({
  81. signal,
  82. callId: ToolCallId(`call-${++calls}`),
  83. name,
  84. arguments: args,
  85. ...agent !== undefined ? { agent: agent as never } : {},
  86. })
  87. }
  88. /** Wait until a child's Activation released its handle. */
  89. async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
  90. await vi.waitFor(() => {
  91. expect(ctx.agents.get(childId)).toBeUndefined()
  92. }, { timeout: 5_000 })
  93. }
  94. describe('dsh-tool-subagent-control', () => {
  95. it('registers send_message once, globally, with the two required parameters', async () => {
  96. const { ctx } = await setup([])
  97. const schemas = ctx.tools.schemas().filter(schema => schema.name === 'send_message')
  98. expect(schemas).toHaveLength(1)
  99. const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  100. expect(Object.keys(props).sort()).toEqual(['agent_id', 'message'])
  101. // The continuable path has no Task, so the schema must not promise one.
  102. expect(schemas[0]!.description).not.toContain('job_output')
  103. expect(schemas[0]!.description).not.toContain('job id')
  104. expect(schemas[0]!.description).toContain('nearest step')
  105. expect(schemas[0]!.description).toContain('direct continuable child')
  106. expect(schemas[0]!.description).toContain('If you are a resident continuable child')
  107. expect(props.agent_id).toMatchObject({
  108. description: 'The agent id of your direct continuable child, or your direct parent when you are a resident continuable child.',
  109. })
  110. })
  111. it('keeps the send_message definition and ordering byte-identical in a fork child', async () => {
  112. const release = Promise.withResolvers<undefined>()
  113. const { ctx, parent, adapter } = await setupWith(new GatedAdapter([
  114. { chunks: textResponse('parent done') },
  115. { chunks: textResponse('child done'), gate: release.promise },
  116. ]), false)
  117. parent.followup(createUserMessage({
  118. content: [{ type: 'text', text: 'parent work' }],
  119. source: { kind: 'user' },
  120. }))
  121. await parent.whenIdle()
  122. parkParent(ctx, parent)
  123. const started = await ctx.subagents.startContinuable({
  124. provider: 'fork',
  125. label: 'fork child',
  126. request: { prompt: [{ type: 'text', text: 'fork task' }], parent },
  127. signal: testToolSignal,
  128. })
  129. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
  130. const child = ctx.agents.get(started.childId)
  131. if (child === undefined) throw new Error('expected a live fork child')
  132. const parentSchemas = ctx.tools.schemas(parent)
  133. const childSchemas = ctx.tools.schemas(child)
  134. expect(JSON.stringify(childSchemas)).toBe(JSON.stringify(parentSchemas))
  135. expect(childSchemas.map(schema => schema.name)).not.toContain('report')
  136. release.resolve(undefined)
  137. await waitNoActivation(ctx, started.childId)
  138. const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
  139. const promptIndex = loaded.events.findIndex(event => event.type === 'user/message'
  140. && event.data.content.some(block => block.type === 'text' && block.text === 'fork task'))
  141. expect(loaded.meta.isSeeded).toBe(true)
  142. expect(promptIndex).toBeGreaterThanOrEqual(loaded.inheritedEventCount)
  143. const prompt = loaded.events[promptIndex]
  144. if (prompt?.type !== 'user/message') throw new Error('expected the initial fork task')
  145. const texts = prompt.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
  146. expect(texts[0]).toBe('fork task')
  147. expect(texts[1]).toContain(`Your parent agent id is ${JSON.stringify(parent.id)}`)
  148. expect(texts[1]).toContain(`send_message({ agent_id: ${JSON.stringify(parent.id)}`)
  149. expect(texts[1]).not.toContain('report tool')
  150. })
  151. it('JSON-encodes a caller-supplied parent id in the initial return instruction', async () => {
  152. const { ctx } = await setup([textResponse('child done')])
  153. const parent = await ctx.agentLoop.create(SessionId('parent"\nagent'), { provider: 'mock', model: 'mock' })
  154. parkParent(ctx, parent)
  155. const started = await ctx.subagents.startContinuable({
  156. provider: 'spawn',
  157. label: 'encoded parent',
  158. request: { prompt: [{ type: 'text', text: 'encoded task' }], parent },
  159. signal: testToolSignal,
  160. })
  161. await waitNoActivation(ctx, started.childId)
  162. const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
  163. const prompt = loaded.events.find(event => event.type === 'user/message'
  164. && event.data.content.some(block => block.type === 'text' && block.text === 'encoded task'))
  165. if (prompt?.type !== 'user/message') throw new Error('expected the encoded initial task')
  166. const guidance = prompt.data.content.findLast(block => block.type === 'text')?.text ?? ''
  167. expect(guidance).toContain(`Your parent agent id is ${JSON.stringify(parent.id)}`)
  168. expect(guidance).toContain(`agent_id: ${JSON.stringify(parent.id)}`)
  169. expect(guidance).not.toContain(parent.id)
  170. })
  171. it('lets a continuable child steer its direct parent with send_message', async () => {
  172. const release = Promise.withResolvers<undefined>()
  173. const { ctx, parent, adapter } = await setupWith(new GatedAdapter([
  174. { chunks: textResponse('child done'), gate: release.promise },
  175. ]))
  176. const started = await ctx.subagents.startContinuable({
  177. provider: 'spawn',
  178. label: 'child task',
  179. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  180. signal: testToolSignal,
  181. })
  182. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  183. const child = ctx.agents.get(started.childId)
  184. if (child === undefined) throw new Error('expected a live child')
  185. const delivered: Array<{ agent: Agent; message: ReturnType<typeof createUserMessage> }> = []
  186. ctx.on('agent/inbox/inserted', ({ agent, message }) => {
  187. if (agent === parent && message.source.kind === 'agent-message') delivered.push({ agent, message })
  188. })
  189. const result = await callTool(ctx, 'send_message', {
  190. agent_id: parent.id,
  191. message: 'CHILD_FINDING',
  192. }, child)
  193. expect(result.isError).toBe(false)
  194. expect(text(result)).toBe(`message delivered to agent ${parent.id}`)
  195. expect(delivered).toHaveLength(1)
  196. expect(delivered[0]?.message.source).toEqual({
  197. kind: 'agent-message',
  198. form: 'relay',
  199. senderSessionId: started.childId,
  200. })
  201. expect(delivered[0]?.message.content).toEqual([
  202. { type: 'text', text: `Agent ${started.childId} sent a message: ` },
  203. { type: 'text', text: 'CHILD_FINDING' },
  204. ])
  205. release.resolve(undefined)
  206. await waitNoActivation(ctx, started.childId)
  207. })
  208. it('cold-resumes a settled child and reports delivery', async () => {
  209. const { ctx, parent } = await setup([textResponse('first answer'), textResponse('second answer')])
  210. const started = await ctx.subagents.startContinuable({
  211. provider: 'spawn',
  212. label: 'child task',
  213. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  214. signal: testToolSignal,
  215. })
  216. await waitNoActivation(ctx, started.childId)
  217. const result = await callTool(ctx, 'send_message', {
  218. agent_id: started.childId,
  219. message: 'and then?',
  220. }, parent)
  221. expect(result.isError).toBe(false)
  222. expect(text(result)).toBe(`message delivered to agent ${started.childId}`)
  223. await waitNoActivation(ctx, started.childId)
  224. const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
  225. const followUp = loaded.events.findLast(event => event.type === 'user/message')
  226. // The durable message source records the calling agent without granting authority.
  227. expect(followUp?.type === 'user/message' && followUp.data.source).toEqual({
  228. kind: 'agent-message',
  229. form: 'relay',
  230. senderSessionId: parent.id,
  231. })
  232. expect(followUp?.type === 'user/message' && followUp.data.content).toEqual([
  233. { type: 'text', text: `Agent ${parent.id} sent a message: ` },
  234. { type: 'text', text: 'and then?' },
  235. ])
  236. })
  237. it('steers the nearest step of an open turn', async () => {
  238. const { ctx, parent, adapter } = await setup([textResponse('first'), textResponse('second')])
  239. const started = await ctx.subagents.startContinuable({
  240. provider: 'spawn',
  241. label: 'long work',
  242. request: { prompt: [{ type: 'text', text: 'long work' }], parent },
  243. signal: testToolSignal,
  244. })
  245. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  246. const result = await callTool(ctx, 'send_message', {
  247. agent_id: started.childId,
  248. message: 'also consider Y',
  249. }, parent)
  250. expect(result.isError).toBe(false)
  251. await waitNoActivation(ctx, started.childId)
  252. const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
  253. const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
  254. ? event.data.content.flatMap(block => block.type === 'text'
  255. && !block.text.startsWith('Your parent agent id is ')
  256. ? [block.text]
  257. : [])
  258. : [])
  259. expect(prompts).toEqual([
  260. 'long work',
  261. `Agent ${parent.id} sent a message: `,
  262. 'also consider Y',
  263. ])
  264. })
  265. it('reports a delivery failure as an errored, not-delivered result', async () => {
  266. const { ctx, parent } = await setup([])
  267. const result = await callTool(ctx, 'send_message', {
  268. agent_id: 'no-such-child',
  269. message: 'hello?',
  270. }, parent)
  271. expect(result.isError).toBe(true)
  272. expect(text(result)).toContain('unavailable')
  273. })
  274. it('rejects a caller that is not the child\'s durable direct parent', async () => {
  275. const { ctx, parent } = await setup([textResponse('first')])
  276. const started = await ctx.subagents.startContinuable({
  277. provider: 'spawn',
  278. label: 'child task',
  279. request: { prompt: [{ type: 'text', text: 'child task' }], parent },
  280. signal: testToolSignal,
  281. })
  282. await waitNoActivation(ctx, started.childId)
  283. const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
  284. const result = await callTool(ctx, 'send_message', {
  285. agent_id: started.childId,
  286. message: 'mine now',
  287. }, stranger)
  288. expect(result.isError).toBe(true)
  289. expect(text(result)).toContain('another parent session')
  290. })
  291. it('fails loud when invoked without a calling agent', async () => {
  292. const { ctx } = await setup([])
  293. const result = await callTool(ctx, 'send_message', { agent_id: 'x', message: 'y' })
  294. expect(result.isError).toBe(true)
  295. expect(text(result)).toContain('requires a calling agent')
  296. })
  297. it('unregisters with its plugin fiber (HMR safety)', async () => {
  298. const ctx = new Context()
  299. await mountAgentLoopTestDependencies(ctx)
  300. await ctx.plugin(AgentLoop, { agents: [] })
  301. await ctx.plugin(SubagentRuntime)
  302. const fiber = await ctx.plugin(tool)
  303. expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
  304. expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(true)
  305. await fiber.dispose()
  306. expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(false)
  307. expect(ctx.tools.schemas().some(schema => schema.name === 'interrupt_agent')).toBe(false)
  308. })
  309. it('has the namespace-plugin export shape (no stray default)', () => {
  310. expect('default' in tool).toBe(false)
  311. expect(tool.name).toBe('tool-subagent-control')
  312. expect(tool.inject).toEqual(['tools', 'subagents'])
  313. expect(typeof tool.apply).toBe('function')
  314. })
  315. })
  316. describe('dsh-tool-subagent-control interrupt_agent', () => {
  317. it('registers interrupt_agent with the single agent_id parameter and current-turn wording', async () => {
  318. const { ctx } = await setup([])
  319. const schemas = ctx.tools.schemas().filter(schema => schema.name === 'interrupt_agent')
  320. expect(schemas).toHaveLength(1)
  321. const props = (schemas[0]!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  322. expect(Object.keys(props)).toEqual(['agent_id'])
  323. expect(schemas[0]!.description).toContain('current turn')
  324. expect(schemas[0]!.description).toContain('send_message')
  325. })
  326. it('interrupts a running direct child with the parent cause, parking its queue', async () => {
  327. const releaseFirst = Promise.withResolvers<undefined>()
  328. const adapter = new GatedAdapter([
  329. { chunks: textResponse('held'), gate: releaseFirst.promise },
  330. { chunks: textResponse('parked answer') },
  331. { chunks: textResponse('waking answer') },
  332. ])
  333. const { ctx, parent } = await setupWith(adapter)
  334. const started = await ctx.subagents.startContinuable({
  335. provider: 'spawn',
  336. label: 'long work',
  337. request: { prompt: [{ type: 'text', text: 'long work' }], parent },
  338. signal: testToolSignal,
  339. })
  340. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  341. const child = ctx.agents.get(started.childId)!
  342. const queued = await callTool(ctx, 'send_message', {
  343. agent_id: started.childId,
  344. message: 'parked follow-up',
  345. }, parent)
  346. expect(queued.isError).toBe(false)
  347. const cancelSpy = vi.spyOn(child, 'cancel')
  348. const result = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
  349. expect(result.isError).toBe(false)
  350. expect(text(result)).toBe(`interrupt requested for agent ${started.childId}`)
  351. expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
  352. releaseFirst.resolve(undefined)
  353. await child.whenIdle()
  354. // Parked, not resumed: the steering waits for another waking send.
  355. expect(adapter.requests).toHaveLength(1)
  356. expect(child.inbox.nextStep).toHaveLength(1)
  357. const waking = await callTool(ctx, 'send_message', {
  358. agent_id: started.childId,
  359. message: 'wake up',
  360. }, parent)
  361. expect(waking.isError).toBe(false)
  362. await waitNoActivation(ctx, started.childId)
  363. const loaded = await loadStoredSession(ctx.sessionPersistence, started.childId)
  364. const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin'
  365. ? event.data.content.flatMap(block => block.type === 'text'
  366. && !block.text.startsWith('Your parent agent id is ')
  367. ? [block.text]
  368. : [])
  369. : [])
  370. expect(prompts).toEqual([
  371. 'long work',
  372. `Agent ${parent.id} sent a message: `,
  373. 'parked follow-up',
  374. `Agent ${parent.id} sent a message: `,
  375. 'wake up',
  376. ])
  377. })
  378. it('lets a deep live ancestor interrupt a descendant it did not directly create', async () => {
  379. const releaseChild = Promise.withResolvers<undefined>()
  380. const releaseGrandchild = Promise.withResolvers<undefined>()
  381. const adapter = new GatedAdapter([
  382. { chunks: textResponse('child'), gate: releaseChild.promise },
  383. { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise },
  384. ])
  385. const { ctx, parent } = await setupWith(adapter)
  386. const started = await ctx.subagents.startContinuable({
  387. provider: 'spawn',
  388. label: 'child',
  389. request: { prompt: [{ type: 'text', text: 'child work' }], parent },
  390. signal: testToolSignal,
  391. })
  392. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  393. const child = ctx.agents.get(started.childId)!
  394. const grandchild = await ctx.subagents.startContinuable({
  395. provider: 'spawn',
  396. label: 'grandchild',
  397. request: { prompt: [{ type: 'text', text: 'grandchild work' }], parent: child },
  398. signal: testToolSignal,
  399. })
  400. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
  401. const grandchildAgent = ctx.agents.get(grandchild.childId)!
  402. const cancelSpy = vi.spyOn(grandchildAgent, 'cancel')
  403. const result = await callTool(ctx, 'interrupt_agent', { agent_id: grandchild.childId }, parent)
  404. expect(result.isError).toBe(false)
  405. expect(cancelSpy).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }, { keepInbox: true })
  406. releaseChild.resolve(undefined)
  407. releaseGrandchild.resolve(undefined)
  408. await waitNoActivation(ctx, grandchild.childId)
  409. await waitNoActivation(ctx, started.childId)
  410. })
  411. it('rejects self, sibling, and unrelated callers without touching the target', async () => {
  412. const releaseA = Promise.withResolvers<undefined>()
  413. const releaseB = Promise.withResolvers<undefined>()
  414. const adapter = new GatedAdapter([
  415. { chunks: textResponse('a'), gate: releaseA.promise },
  416. { chunks: textResponse('b'), gate: releaseB.promise },
  417. ])
  418. const { ctx, parent } = await setupWith(adapter)
  419. const target = await ctx.subagents.startContinuable({
  420. provider: 'spawn',
  421. label: 'target',
  422. request: { prompt: [{ type: 'text', text: 'a' }], parent },
  423. signal: testToolSignal,
  424. })
  425. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
  426. const sibling = await ctx.subagents.startContinuable({
  427. provider: 'spawn',
  428. label: 'sibling',
  429. request: { prompt: [{ type: 'text', text: 'b' }], parent },
  430. signal: testToolSignal,
  431. })
  432. await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
  433. const targetAgent = ctx.agents.get(target.childId)!
  434. const siblingAgent = ctx.agents.get(sibling.childId)!
  435. const stranger = await ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' })
  436. const cancelSpy = vi.spyOn(targetAgent, 'cancel')
  437. const self = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, targetAgent)
  438. expect(self.isError).toBe(true)
  439. expect(text(self)).toContain('cannot interrupt itself')
  440. const fromSibling = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, siblingAgent)
  441. expect(fromSibling.isError).toBe(true)
  442. expect(text(fromSibling)).toContain('not a live descendant')
  443. const fromStranger = await callTool(ctx, 'interrupt_agent', { agent_id: target.childId }, stranger)
  444. expect(fromStranger.isError).toBe(true)
  445. expect(text(fromStranger)).toContain('not a live descendant')
  446. expect(cancelSpy).not.toHaveBeenCalled()
  447. releaseA.resolve(undefined)
  448. releaseB.resolve(undefined)
  449. await waitNoActivation(ctx, target.childId)
  450. await waitNoActivation(ctx, sibling.childId)
  451. })
  452. it('accepts an absent target as a no-op without cold-resuming it', async () => {
  453. const { ctx, parent } = await setup([textResponse('done')])
  454. const started = await ctx.subagents.startContinuable({
  455. provider: 'spawn',
  456. label: 'settled child',
  457. request: { prompt: [{ type: 'text', text: 'child work' }], parent },
  458. signal: testToolSignal,
  459. })
  460. await waitNoActivation(ctx, started.childId)
  461. const settled = await callTool(ctx, 'interrupt_agent', { agent_id: started.childId }, parent)
  462. expect(settled.isError).toBe(false)
  463. expect(text(settled)).toBe(`interrupt requested for agent ${started.childId}`)
  464. const unknown = await callTool(ctx, 'interrupt_agent', { agent_id: 'no-such-agent' }, parent)
  465. expect(unknown.isError).toBe(false)
  466. // No cold resume: the settled target never rematerialized.
  467. expect(ctx.agents.get(started.childId)).toBeUndefined()
  468. })
  469. it('fails loud when invoked without a calling agent', async () => {
  470. const { ctx } = await setup([])
  471. const result = await callTool(ctx, 'interrupt_agent', { agent_id: 'x' })
  472. expect(result.isError).toBe(true)
  473. expect(text(result)).toContain('requires a calling agent')
  474. })
  475. })