tool-tasks.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRegistry from '@deepseek-ai/dsh-tools'
  6. import AgentRegistry from '@deepseek-ai/dsh-agent'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import { SessionId } from '@deepseek-ai/dsh-session'
  9. import TaskService from '@deepseek-ai/dsh-tasks'
  10. import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
  11. import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
  12. import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
  13. const agentRegistryDisposers = new WeakMap<Agent, () => void>()
  14. async function setup(config: ToolTasks.Config = {}) {
  15. const ctx = new Context()
  16. await ctx.plugin(SystemPrompt)
  17. await ctx.plugin(ToolRegistry)
  18. const agentsFiber = await ctx.plugin(AgentRegistry)
  19. await ctx.plugin(TaskService)
  20. const toolsFiber = await ctx.plugin(ToolTasks, config)
  21. return { ctx, agentsFiber, toolsFiber }
  22. }
  23. /**
  24. * A fake agent with the shared agent/session identity, registered in
  25. * `ctx.agents` with a dedicated lifecycle scope.
  26. */
  27. function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
  28. const scopeFiber = ctx.plugin(() => {})
  29. const id = SessionId(sessionId)
  30. const agent = {
  31. id,
  32. ctx: scopeFiber.ctx,
  33. inject,
  34. session: { id, header: { version: 0, id, createdAt: 0 } },
  35. } as unknown as Agent
  36. agentRegistryDisposers.set(agent, ctx.agents.register(agent))
  37. return agent
  38. }
  39. function detachAgent(agent: Agent): void {
  40. const dispose = agentRegistryDisposers.get(agent)
  41. if (dispose === undefined) throw new Error(`missing registry disposer for agent "${agent.id}"`)
  42. dispose()
  43. }
  44. /** A controllable producer start-spec (settle `done` on demand, record cancels). */
  45. function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
  46. let settle!: (outcome: TaskOutcome) => void
  47. const cancels: (string | undefined)[] = []
  48. const { kind = 'bash', label = 'sleep 60', owner, ...hookOverrides } = overrides
  49. const hooks: TaskHooks = {
  50. cancel(reason) { cancels.push(reason) },
  51. done: new Promise<TaskOutcome>((res) => { settle = res }),
  52. ...hookOverrides,
  53. }
  54. const spec: TaskStart = { kind, label, ...owner !== undefined ? { owner } : {}, run: () => hooks }
  55. return { spec, settle, cancels }
  56. }
  57. let callCounter = 0
  58. function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
  59. return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
  60. }
  61. function text(result: { content: { type: string; text?: string }[] }): string {
  62. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  63. }
  64. const tick = () => new Promise<void>(r => setTimeout(r, 0))
  65. describe('tool-tasks setup', () => {
  66. it('attaches the control surface on load and detaches it with the fiber', async () => {
  67. const { ctx, toolsFiber } = await setup()
  68. expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
  69. await toolsFiber.dispose()
  70. expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
  71. })
  72. it('rejects a config whose default wait exceeds the cap', async () => {
  73. const ctx = new Context()
  74. await ctx.plugin(SystemPrompt)
  75. await ctx.plugin(ToolRegistry)
  76. await ctx.plugin(TaskService)
  77. await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
  78. .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
  79. })
  80. it('renders status lines with and without producer detail', () => {
  81. const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot
  82. expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
  83. expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]')
  84. })
  85. it('applies the built-in wait bounds when apply() receives a bare config', async () => {
  86. // Bypasses the schemastery defaults on purpose: apply() must stand on its
  87. // own `??` fallbacks when embedded programmatically without the schema.
  88. const ctx = new Context()
  89. await ctx.plugin(SystemPrompt)
  90. await ctx.plugin(ToolRegistry)
  91. await ctx.plugin(TaskService)
  92. ToolTasks.apply(ctx, {})
  93. expect(ctx.tools.get('task_output')).toBeDefined()
  94. expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
  95. })
  96. })
  97. describe('task_output', () => {
  98. it('reads a consuming delta with a trailing status line', async () => {
  99. const { ctx } = await setup()
  100. const chunks = ['line one\n', '']
  101. ctx.tasks.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
  102. // A body already ending in a newline gets no doubled separator.
  103. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('line one\n[status: running]')
  104. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
  105. })
  106. it('returns the final output of a settled final-output task', async () => {
  107. const { ctx } = await setup()
  108. const p = producer({ kind: 'subagent', label: 'research' })
  109. ctx.tasks.start(p.spec)
  110. expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
  111. p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
  112. await tick()
  113. expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
  114. })
  115. it('wait: true blocks until settlement and reports the terminal state', async () => {
  116. const { ctx } = await setup()
  117. const p = producer({ kind: 'subagent', label: 'research' })
  118. ctx.tasks.start(p.spec)
  119. const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true })
  120. p.settle({ status: 'completed', output: 'done deal' })
  121. expect(text(await pending)).toBe('done deal\n[status: completed]')
  122. })
  123. it('wait: true times out against the configured cap and leaves the task alive', async () => {
  124. const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
  125. ctx.tasks.start(producer().spec)
  126. // A model-supplied timeout far above the cap is clamped: this returns
  127. // promptly (≤ the 20ms cap), not after ten minutes.
  128. const result = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true, timeout_ms: 600_000 })
  129. expect(text(result)).toBe('(no new output)\n[status: running]')
  130. })
  131. it('rejects an empty or unknown task id as an errored result', async () => {
  132. const { ctx } = await setup()
  133. expect((await call(ctx, 'task_output', { task_id: '' })).isError).toBe(true)
  134. const unknown = await call(ctx, 'task_output', { task_id: 'bash-99' })
  135. expect(unknown.isError).toBe(true)
  136. expect(text(unknown)).toContain('unknown task bash-99')
  137. })
  138. })
  139. describe('task_list', () => {
  140. it('lists caller-visible tasks and renders the empty case', async () => {
  141. const { ctx } = await setup()
  142. expect(text(await call(ctx, 'task_list', {}))).toBe('(no background tasks)')
  143. const alice = fakeAgent(ctx, 'sess-alice')
  144. ctx.tasks.start(producer({ owner: alice, label: 'pnpm test' }).spec)
  145. ctx.tasks.start(producer({ kind: 'subagent', label: 'open research' }).spec)
  146. const p = producer({ owner: alice, label: 'build' })
  147. ctx.tasks.start(p.spec)
  148. p.settle({ status: 'completed', detail: 'exit code: 0' })
  149. await tick()
  150. expect(text(await call(ctx, 'task_list', {}, alice))).toBe([
  151. 'bash-1 [bash] running — pnpm test',
  152. 'subagent-1 [subagent] running — open research',
  153. 'bash-2 [bash] completed — build',
  154. ].join('\n'))
  155. // A different caller sees only the unowned task.
  156. const bob = fakeAgent(ctx, 'sess-bob')
  157. expect(text(await call(ctx, 'task_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
  158. })
  159. })
  160. describe('task_kill', () => {
  161. it('requests cancellation with the forwarded reason', async () => {
  162. const { ctx } = await setup()
  163. const p = producer()
  164. ctx.tasks.start(p.spec)
  165. const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
  166. expect(text(result)).toBe('requested cancellation of task bash-1')
  167. expect(p.cancels).toEqual(['superseded'])
  168. })
  169. it('reports an already-finished task without consuming its pending delta', async () => {
  170. const { ctx } = await setup()
  171. let delta = 'unread tail'
  172. const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
  173. ctx.tasks.start(p.spec)
  174. p.settle({ status: 'completed', detail: 'exit code: 0' })
  175. await tick()
  176. expect(text(await call(ctx, 'task_kill', { task_id: 'bash-1' })))
  177. .toBe('task bash-1 had already finished [status: completed, exit code: 0]')
  178. // The kill described the task via a non-consuming snapshot: the delta is intact.
  179. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
  180. })
  181. it('rejects an empty task id as an errored result', async () => {
  182. const { ctx } = await setup()
  183. expect((await call(ctx, 'task_kill', { task_id: '' })).isError).toBe(true)
  184. })
  185. })
  186. describe('tool-owned UI presentation (presentCall)', () => {
  187. it('renders generic cards for all three control tools', async () => {
  188. const { ctx } = await setup()
  189. expect(ctx.tools.get('task_output')?.presentCall?.({ task_id: 'bash-1' }))
  190. .toEqual({ card: 'generic', title: 'Read output from background task bash-1', kind: 'read', rawInput: 'bash-1' })
  191. expect(ctx.tools.get('task_list')?.presentCall?.({}))
  192. .toEqual({ card: 'generic', title: 'List background tasks', kind: 'read' })
  193. expect(ctx.tools.get('task_kill')?.presentCall?.({ task_id: 'subagent-2' }))
  194. .toEqual({ card: 'generic', title: 'Kill background task subagent-2', kind: 'execute', rawInput: 'subagent-2' })
  195. })
  196. })
  197. describe('completion notices', () => {
  198. it('injects a notice into the owning agent when an unreported task settles', async () => {
  199. const { ctx } = await setup()
  200. const inject = vi.fn()
  201. const owner = fakeAgent(ctx, 'sess-1', inject)
  202. const p = producer({ owner, label: 'pnpm test' })
  203. ctx.tasks.start(p.spec)
  204. p.settle({ status: 'completed', detail: 'exit code: 0' })
  205. await tick()
  206. expect(inject).toHaveBeenCalledTimes(1)
  207. expect(inject).toHaveBeenCalledWith(
  208. [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
  209. { source: { kind: 'plugin', plugin: 'tool-tasks' } },
  210. )
  211. })
  212. it('suppresses the notice for a task the model already killed', async () => {
  213. const { ctx } = await setup()
  214. const inject = vi.fn()
  215. const owner = fakeAgent(ctx, 'sess-1', inject)
  216. const p = producer({ owner })
  217. ctx.tasks.start(p.spec)
  218. await call(ctx, 'task_kill', { task_id: 'bash-1' }, owner)
  219. p.settle({ status: 'killed' })
  220. await tick()
  221. expect(inject).not.toHaveBeenCalled()
  222. })
  223. it('suppresses the notice when a wait returned the terminal state', async () => {
  224. const { ctx } = await setup()
  225. const inject = vi.fn()
  226. const owner = fakeAgent(ctx, 'sess-1', inject)
  227. const p = producer({ owner, kind: 'subagent' })
  228. ctx.tasks.start(p.spec)
  229. const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true }, owner)
  230. p.settle({ status: 'completed', output: 'answer' })
  231. expect(text(await pending)).toContain('answer')
  232. expect(inject).not.toHaveBeenCalled()
  233. })
  234. it('drops the notice for unowned tasks and for a disposed owner (benign race)', async () => {
  235. const { ctx } = await setup()
  236. // Unowned: settles with nobody to notify — nothing throws.
  237. const unowned = producer()
  238. ctx.tasks.start(unowned.spec)
  239. unowned.settle({ status: 'completed' })
  240. await tick()
  241. // Disposed owner: inject throws the disposed message — contained.
  242. const inject = vi.fn(() => { throw new Error('agent "sess-1" is disposed') })
  243. const owner = fakeAgent(ctx, 'sess-1', inject)
  244. const p = producer({ owner })
  245. ctx.tasks.start(p.spec)
  246. p.settle({ status: 'completed' })
  247. await tick()
  248. expect(inject).toHaveBeenCalledTimes(1)
  249. })
  250. it('does not route an old owner completion notice to a same-session replacement', async () => {
  251. const { ctx } = await setup()
  252. const oldInject = vi.fn(() => { throw new Error('agent "shared" is disposed') })
  253. const oldOwner = fakeAgent(ctx, 'shared', oldInject)
  254. const p = producer({ owner: oldOwner })
  255. ctx.tasks.start(p.spec)
  256. detachAgent(oldOwner)
  257. const replacementInject = vi.fn()
  258. fakeAgent(ctx, 'shared', replacementInject)
  259. p.settle({ status: 'completed' })
  260. await tick()
  261. expect(oldInject).toHaveBeenCalledTimes(1)
  262. expect(replacementInject).not.toHaveBeenCalled()
  263. })
  264. it('propagates a non-disposed inject failure (a real bug must surface)', async () => {
  265. const { ctx } = await setup()
  266. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  267. const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
  268. const p = producer({ owner })
  269. ctx.tasks.start(p.spec)
  270. p.settle({ status: 'completed' })
  271. await tick()
  272. // The throw escapes the notice listener and is contained (logged) by the
  273. // registry's per-listener containment — visible, not swallowed.
  274. expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
  275. })
  276. it('keeps using the exact owner after the agent registry is gone', async () => {
  277. const { ctx, agentsFiber } = await setup()
  278. const inject = vi.fn()
  279. const owner = fakeAgent(ctx, 'sess-1', inject)
  280. // Settlement must not depend on a later registry lookup: the exact owner
  281. // supplied at start remains the destination while its own scope is live.
  282. const p1 = producer({ owner })
  283. ctx.tasks.start(p1.spec)
  284. const p2 = producer({ owner })
  285. ctx.tasks.start(p2.spec)
  286. await agentsFiber.dispose()
  287. p1.settle({ status: 'completed' })
  288. p2.settle({ status: 'failed' })
  289. await tick()
  290. expect(inject).toHaveBeenCalledTimes(2)
  291. })
  292. })