tool-subagent.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/plugin-loader'
  4. import { CallId } from '@deepseek-ai/dsh-llm'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry from '@deepseek-ai/dsh-tools'
  7. import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
  8. import SubagentService from '@deepseek-ai/dsh-subagent'
  9. import * as mock from '@deepseek-ai/dsh-subagent-mock'
  10. import * as tool from '../src/index.ts'
  11. /**
  12. * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
  13. * `ToolRegistry` + `SubagentService`, with the real `dsh-subagent-mock` as the
  14. * backend, and invokes the registered `subagent` tool through
  15. * `ctx.tools.execute`. The mock is the genuine collaborator (we mock only the
  16. * "child agent", the expensive/non-deterministic boundary) — everything
  17. * downstream of the tool is the shipping code path.
  18. */
  19. /** A minimal parent Agent — the tool reads `agent.id` for `parent`. */
  20. function fakeAgent(id = 'parent-1'): Agent {
  21. return { id: AgentId(id) } as unknown as Agent
  22. }
  23. async function setup(toolConfig: tool.Config, mockConfig: Partial<mock.Config> = {}) {
  24. const ctx = new Context()
  25. await ctx.plugin(SystemPrompt)
  26. await ctx.plugin(ToolRegistry)
  27. await ctx.plugin(SubagentService)
  28. await ctx.plugin(mock, { name: 'mock', ...mockConfig })
  29. await ctx.plugin(tool, toolConfig)
  30. return ctx
  31. }
  32. let callCounter = 0
  33. function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undefined; signal?: AbortSignal } = {}) {
  34. // Distinguish "no override" (use a default agent) from an explicit
  35. // `{ agent: undefined }` (test the no-agent path). Under
  36. // exactOptionalPropertyTypes the key is omitted rather than set to undefined.
  37. const agent = 'agent' in over ? over.agent : fakeAgent()
  38. return ctx.tools.execute({
  39. callId: CallId(`call-${++callCounter}`),
  40. name: 'subagent',
  41. arguments: args,
  42. ...agent ? { agent } : {},
  43. ...over.signal ? { signal: over.signal } : {},
  44. })
  45. }
  46. function text(result: { content: { type: string; text?: string }[] }): string {
  47. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  48. }
  49. describe('dsh-tool-subagent', () => {
  50. it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => {
  51. const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' })
  52. const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' })
  53. expect(result.isError).toBe(false)
  54. expect(text(result)).toBe('child says hi')
  55. })
  56. it('exposes only description + prompt to the model (no provider/type parameter)', async () => {
  57. const ctx = await setup({ provider: 'mock' })
  58. const schema = ctx.tools.schemas().find(s => s.name === 'subagent')
  59. expect(schema).toBeDefined()
  60. const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
  61. expect(Object.keys(props).sort()).toEqual(['description', 'prompt'])
  62. })
  63. it.each([
  64. { stopReason: 'aborted' as const, fragment: 'cancelled' },
  65. { stopReason: 'error' as const, fragment: 'failed' },
  66. { stopReason: 'max-tokens' as const, fragment: 'token limit' },
  67. { stopReason: 'refusal' as const, fragment: 'declined' },
  68. ])('maps stop reason $stopReason to an isError result (not partial success)', async ({ stopReason, fragment }) => {
  69. const ctx = await setup({ provider: 'mock' }, { stopReason })
  70. const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
  71. expect(result.isError).toBe(true)
  72. expect(text(result)).toContain(fragment)
  73. })
  74. it('registers under a configurable toolName so multiple providers can coexist', async () => {
  75. // The defining multi-provider use case: two loads, two distinct tool names,
  76. // each bound to a different provider — the tool registry rejects duplicate
  77. // names, so a configurable name is what makes this work.
  78. const ctx = new Context()
  79. await ctx.plugin(SystemPrompt)
  80. await ctx.plugin(ToolRegistry)
  81. await ctx.plugin(SubagentService)
  82. await ctx.plugin(mock, { name: 'spawn', reply: 'from spawn' })
  83. await ctx.plugin(mock, { name: 'acp', reply: 'from acp' })
  84. await ctx.plugin(tool, { provider: 'spawn', toolName: 'subagent' })
  85. await ctx.plugin(tool, { provider: 'acp', toolName: 'subagent_acp' })
  86. const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort()
  87. expect(names).toEqual(['subagent', 'subagent_acp'])
  88. const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
  89. const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
  90. expect(text(viaSpawn)).toBe('from spawn')
  91. expect(text(viaAcp)).toBe('from acp')
  92. })
  93. it('treats an unknown (plugin-added) stop reason as an isError result', async () => {
  94. // SubagentStopReason is merge-extensible; the tool's stopReasonError default
  95. // arm must treat an unrecognized terminal reason as a failure, not success.
  96. const ctx = new Context()
  97. await ctx.plugin(SystemPrompt)
  98. await ctx.plugin(ToolRegistry)
  99. await ctx.plugin(SubagentService)
  100. ctx.subagents.registerProvider({
  101. name: 'weird',
  102. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
  103. start: () => ({
  104. id: AgentId('weird-child'),
  105. result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
  106. cancel() {},
  107. dispose: async () => {},
  108. }),
  109. })
  110. await ctx.plugin(tool, { provider: 'weird' })
  111. const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
  112. expect(result.isError).toBe(true)
  113. expect(text(result)).toContain('abnormally')
  114. })
  115. it('forwards configured agentOptions into the start request', async () => {
  116. // Cover the `config.agentOptions ? … : {}` spread: a provider that captures
  117. // the request lets us assert the agentOptions reached it.
  118. let seen: { agentOptions?: { model?: string } } | undefined
  119. const ctx = new Context()
  120. await ctx.plugin(SystemPrompt)
  121. await ctx.plugin(ToolRegistry)
  122. await ctx.plugin(SubagentService)
  123. ctx.subagents.registerProvider({
  124. name: 'capture',
  125. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
  126. start: (request) => {
  127. seen = request
  128. return {
  129. id: AgentId('capture-child'),
  130. result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
  131. cancel() {},
  132. dispose: async () => {},
  133. }
  134. },
  135. })
  136. await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model', systemPrompt: 'be terse' } })
  137. await callSubagent(ctx, { description: 'd', prompt: 'p' })
  138. expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' })
  139. })
  140. it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => {
  141. // `ctx.plugin` validates+defaults config first (toolName→'subagent', the
  142. // agentOptions object→{}), so the runtime `?? 'subagent'` fallback and the
  143. // no-agentOptions branch are only reachable via a direct apply() that
  144. // bypasses schemastery — the same pattern acp-agent uses for its defaults.
  145. let seen: { agentOptions?: unknown } | undefined
  146. const ctx = new Context()
  147. await ctx.plugin(SystemPrompt)
  148. await ctx.plugin(ToolRegistry)
  149. await ctx.plugin(SubagentService)
  150. ctx.subagents.registerProvider({
  151. name: 'bare',
  152. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
  153. start: (request) => {
  154. seen = request
  155. return {
  156. id: AgentId('bare-child'),
  157. result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
  158. cancel() {},
  159. dispose: async () => {},
  160. }
  161. },
  162. })
  163. // Direct apply with only `provider` — no toolName, no agentOptions.
  164. tool.apply(ctx, { provider: 'bare' })
  165. await new Promise(r => setTimeout(r, 10))
  166. expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
  167. await callSubagent(ctx, { description: 'd', prompt: 'p' })
  168. expect(seen?.agentOptions).toBeUndefined()
  169. })
  170. it('fails loud when invoked without a calling agent', async () => {
  171. const ctx = await setup({ provider: 'mock' })
  172. const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: undefined })
  173. expect(result.isError).toBe(true)
  174. expect(text(result)).toContain('requires a calling agent')
  175. })
  176. it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here '
  177. + '(the tool requests no capabilities) — a missing provider IS surfaced', async () => {
  178. // Bind the tool to a provider name that is not registered: the service throws
  179. // NO_PROVIDER, the registry turns it into an isError result.
  180. const ctx = await setup({ provider: 'does-not-exist' })
  181. const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
  182. expect(result.isError).toBe(true)
  183. expect(text(result)).toContain('no subagent provider')
  184. })
  185. it('disposes the run on the success path (no leaked child)', async () => {
  186. // Spy on the provider's run.dispose via a wrapping provider registered
  187. // directly on the service, then point the tool at it.
  188. const disposed = vi.fn()
  189. const ctx = new Context()
  190. await ctx.plugin(SystemPrompt)
  191. await ctx.plugin(ToolRegistry)
  192. await ctx.plugin(SubagentService)
  193. ctx.subagents.registerProvider({
  194. name: 'spy',
  195. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
  196. start: () => ({
  197. id: AgentId('spy-child'),
  198. result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
  199. cancel() {},
  200. dispose: async () => void disposed(),
  201. }),
  202. })
  203. await ctx.plugin(tool, { provider: 'spy' })
  204. await callSubagent(ctx, { description: 'd', prompt: 'p' })
  205. expect(disposed).toHaveBeenCalledTimes(1)
  206. })
  207. it('disposes the run on the error path too', async () => {
  208. const disposed = vi.fn()
  209. const ctx = new Context()
  210. await ctx.plugin(SystemPrompt)
  211. await ctx.plugin(ToolRegistry)
  212. await ctx.plugin(SubagentService)
  213. ctx.subagents.registerProvider({
  214. name: 'spy',
  215. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
  216. start: () => ({
  217. id: AgentId('spy-child'),
  218. result: Promise.resolve({ output: [], stopReason: 'error' as const }),
  219. cancel() {},
  220. dispose: async () => void disposed(),
  221. }),
  222. })
  223. await ctx.plugin(tool, { provider: 'spy' })
  224. const result = await callSubagent(ctx, { description: 'd', prompt: 'p' })
  225. expect(result.isError).toBe(true)
  226. expect(disposed).toHaveBeenCalledTimes(1)
  227. })
  228. it('bridges the tool abort signal to run.cancel()', async () => {
  229. const cancelled = vi.fn()
  230. const ctx = new Context()
  231. await ctx.plugin(SystemPrompt)
  232. await ctx.plugin(ToolRegistry)
  233. await ctx.plugin(SubagentService)
  234. ctx.subagents.registerProvider({
  235. name: 'spy',
  236. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
  237. start: () => {
  238. let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
  239. const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
  240. return {
  241. id: AgentId('spy-child'),
  242. result,
  243. cancel: () => {
  244. cancelled()
  245. resolveResult({ output: [], stopReason: 'aborted' })
  246. },
  247. dispose: async () => {},
  248. }
  249. },
  250. })
  251. await ctx.plugin(tool, { provider: 'spy' })
  252. const controller = new AbortController()
  253. const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
  254. // Abort AFTER the tool body has had a chance to register its abort listener
  255. // (ctx.tools.execute now awaits the tools/pre-execute waterfall before the
  256. // body runs, so the listener is not registered synchronously). A few
  257. // microtask turns let execute() reach `addEventListener('abort')`, so this
  258. // exercises the LIVE onAbort bridge — distinct from the already-aborted
  259. // sync path the next test covers.
  260. await Promise.resolve()
  261. await Promise.resolve()
  262. controller.abort()
  263. const result = await pending
  264. expect(cancelled).toHaveBeenCalledTimes(1)
  265. expect(result.isError).toBe(true)
  266. })
  267. it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => {
  268. // `addEventListener('abort')` does not fire for a signal already aborted
  269. // before the listener is added, so a step cancelled before the tool ran
  270. // would never reach the child unless the bridge re-checks `signal.aborted`.
  271. // A provider that leans only on the abort EVENT (this spy never inspects
  272. // request.signal) proves the bridge itself must cancel.
  273. const cancelled = vi.fn()
  274. const ctx = new Context()
  275. await ctx.plugin(SystemPrompt)
  276. await ctx.plugin(ToolRegistry)
  277. await ctx.plugin(SubagentService)
  278. ctx.subagents.registerProvider({
  279. name: 'spy',
  280. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false },
  281. start: () => {
  282. let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void
  283. const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res })
  284. return {
  285. id: AgentId('spy-child'),
  286. result,
  287. cancel: () => {
  288. cancelled()
  289. resolveResult({ output: [], stopReason: 'aborted' })
  290. },
  291. dispose: async () => {},
  292. }
  293. },
  294. })
  295. await ctx.plugin(tool, { provider: 'spy' })
  296. const controller = new AbortController()
  297. controller.abort() // already aborted BEFORE the tool runs
  298. const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
  299. expect(cancelled).toHaveBeenCalledTimes(1)
  300. expect(result.isError).toBe(true)
  301. })
  302. it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => {
  303. const ctx = new Context()
  304. await ctx.plugin(SystemPrompt)
  305. await ctx.plugin(ToolRegistry)
  306. // No SubagentService mounted. The tool injects ['tools','subagents'] so its
  307. // apply never runs; the tool is absent rather than half-registered.
  308. let booted = true
  309. try {
  310. await ctx.plugin(tool, { provider: 'mock' })
  311. await new Promise(r => setTimeout(r, 20))
  312. } catch {
  313. booted = false
  314. }
  315. // Either it never booted, or it booted but registered no tool.
  316. const present = ctx.get('tools')?.schemas().some(s => s.name === 'subagent') ?? false
  317. expect(booted && present).toBe(false)
  318. })
  319. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
  320. // Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so
  321. // a stray `export default apply` would collapse the module via
  322. // `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at
  323. // load with "cannot get property … without inject". Guard the shape directly.
  324. expect('default' in tool).toBe(false)
  325. expect(tool.name).toBe('tool-subagent')
  326. expect(tool.inject).toEqual(['tools', 'subagents'])
  327. const loader = Object.create(Loader.prototype) as Loader
  328. const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
  329. expect(unwrapped).toBe(tool)
  330. expect(unwrapped.name).toBe('tool-subagent')
  331. expect(unwrapped.inject).toEqual(['tools', 'subagents'])
  332. expect(typeof unwrapped.apply).toBe('function')
  333. expect(unwrapped.Config).toBeDefined()
  334. })
  335. })