tool-workflow.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/plugin-loader'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
  6. import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
  9. import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
  10. import { CallId } from '@deepseek-ai/dsh-llm'
  11. import SubagentService from '@deepseek-ai/dsh-subagent'
  12. import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
  13. import * as toolWorkflow from '../src/index.ts'
  14. import { SessionId } from '@deepseek-ai/dsh-session'
  15. const testToolSignal = new AbortController().signal
  16. /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
  17. class StubEngine extends WorkflowService {
  18. requests: WorkflowStartRequest[] = []
  19. cancels: string[] = []
  20. disposed = 0
  21. settle!: (result: WorkflowResult) => void
  22. startError: Error | undefined
  23. start(request: WorkflowStartRequest): WorkflowRun {
  24. if (this.startError) throw this.startError
  25. this.requests.push(request)
  26. const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
  27. request.signal?.addEventListener('abort', () => {
  28. this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
  29. }, { once: true })
  30. return {
  31. id: WorkflowRunId('run-1'),
  32. meta: { name: 'stub-flow', description: 'd' },
  33. result,
  34. cancel: (reason?: string) => {
  35. this.cancels.push(reason ?? 'cancelled')
  36. this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
  37. },
  38. dispose: () => {
  39. this.disposed += 1
  40. return Promise.resolve()
  41. },
  42. }
  43. }
  44. }
  45. async function setup(config?: { toolName?: string; maxResultChars?: number }) {
  46. const ctx = new Context()
  47. await ctx.plugin(SystemPrompt)
  48. await ctx.plugin(ToolRegistry)
  49. await ctx.plugin(StubEngine)
  50. await ctx.plugin(toolWorkflow, config ?? {})
  51. const engine = ctx.workflows as StubEngine
  52. const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
  53. return { ctx, engine, parent }
  54. }
  55. const SCRIPT = 'return 1'
  56. const META = { name: 'audit', description: 'd' }
  57. function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
  58. return ctx.tools.execute({
  59. signal: testToolSignal,
  60. callId: CallId('call-1'),
  61. name: 'workflow',
  62. arguments: args,
  63. ...extra?.agent ? { agent: extra.agent } : {},
  64. ...extra?.signal ? { signal: extra.signal } : {},
  65. })
  66. }
  67. describe('dsh-tool-workflow', () => {
  68. it('starts a run with the script/args/parent/signal and renders the completed value', async () => {
  69. const { ctx, engine, parent } = await setup()
  70. const controller = new AbortController()
  71. const pending = execute(ctx, { script: SCRIPT, meta: META, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal })
  72. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  73. expect(engine.requests[0]).toMatchObject({ script: SCRIPT, meta: META, args: { files: ['a.ts'] }, parent })
  74. expect(engine.requests[0]!.signal).toBe(controller.signal)
  75. engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
  76. const result = await pending
  77. expect(result.isError).toBe(false)
  78. if (result.isError) throw new Error('expected workflow success')
  79. expect(result.value).toEqual({ runId: 'run-1', agentsStarted: 7, result: { findings: [1, 2] } })
  80. const rendered = (result.content[0] as { text: string }).text
  81. expect(rendered).toContain('workflow "audit" completed (7 agents)')
  82. expect(rendered).toContain('"findings"')
  83. expect(engine.disposed).toBe(1)
  84. })
  85. it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
  86. const { ctx, engine, parent } = await setup()
  87. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  88. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  89. engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 })
  90. const result = await pending
  91. expect(result.isError).toBe(true)
  92. expect((result.content[0] as { text: string }).text).toContain('workflow run failed: script threw: boom')
  93. expect(engine.disposed).toBe(1)
  94. })
  95. it('reports a cancelled run distinctly (with and without a reason)', async () => {
  96. const { ctx, engine, parent } = await setup()
  97. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  98. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  99. engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 })
  100. const result = await pending
  101. expect(result.isError).toBe(true)
  102. expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)')
  103. const bare = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  104. await vi.waitFor(() => { expect(engine.requests.length).toBe(2) })
  105. engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
  106. expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true)
  107. })
  108. it('an error result without a message renders the unknown-error fallback', async () => {
  109. const { ctx, engine, parent } = await setup()
  110. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  111. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  112. engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
  113. expect(((await pending).content[0] as { text: string }).text).toContain('unknown error')
  114. })
  115. it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => {
  116. const { ctx, engine, parent } = await setup()
  117. const controller = new AbortController()
  118. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
  119. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  120. controller.abort()
  121. const result = await pending
  122. expect(result.isError).toBe(true)
  123. expect(engine.cancels).toContain('parent step aborted')
  124. expect(engine.disposed).toBe(1)
  125. })
  126. it('a synchronous engine start throw (meta/parse failure) becomes an isError result', async () => {
  127. const { ctx, engine, parent } = await setup()
  128. engine.startError = new Error('invalid meta: meta.name must be a non-empty string')
  129. const result = await execute(ctx, { script: 'nope', meta: { name: '', description: 'd' } }, { agent: parent })
  130. expect(result.isError).toBe(true)
  131. expect((result.content[0] as { text: string }).text).toContain('meta.name must be a non-empty string')
  132. })
  133. it('requires a calling agent (fails loud without exec.agent)', async () => {
  134. const { ctx, engine } = await setup()
  135. const result = await execute(ctx, { script: SCRIPT, meta: META })
  136. expect(result.isError).toBe(true)
  137. expect((result.content[0] as { text: string }).text).toContain('requires a calling agent')
  138. expect(engine.requests.length).toBe(0)
  139. })
  140. it('validates its own arguments via the schema DSL (missing script)', async () => {
  141. const { ctx, parent } = await setup()
  142. const result = await execute(ctx, {}, { agent: parent })
  143. expect(result.isError).toBe(true)
  144. expect(result.error?.info?.code).toBe('INVALID_ARGS')
  145. })
  146. it('skips workflow startup when exec.signal is already aborted', async () => {
  147. const { ctx, engine, parent } = await setup()
  148. const controller = new AbortController()
  149. controller.abort()
  150. const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
  151. expect(result.isError).toBe(true)
  152. expect(result.error).toEqual({
  153. message: 'tool call aborted before dispatch',
  154. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  155. })
  156. expect(engine.requests).toHaveLength(0)
  157. expect(engine.cancels).toHaveLength(0)
  158. expect(engine.disposed).toBe(0)
  159. })
  160. it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
  161. const { ctx, engine, parent } = await setup({ maxResultChars: 40 })
  162. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  163. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  164. engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
  165. const result = await pending
  166. if (result.isError) throw new Error('expected workflow success')
  167. expect(result.value).toEqual({ runId: 'run-1', agentsStarted: 1, result: { blob: 'x'.repeat(500) } })
  168. const rendered = (result.content[0] as { text: string }).text
  169. expect(rendered).toContain('[truncated:')
  170. expect(rendered.length).toBeLessThan(400)
  171. })
  172. it('registers under a configured toolName and unregisters on fiber dispose (HMR safety)', async () => {
  173. const ctx = new Context()
  174. await ctx.plugin(SystemPrompt)
  175. await ctx.plugin(ToolRegistry)
  176. await ctx.plugin(StubEngine)
  177. const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' })
  178. expect(ctx.tools.get('orchestrate')).toBeDefined()
  179. expect(ctx.tools.get('workflow')).toBeUndefined()
  180. // The usage-policy prompt section rides the same registration: present
  181. // under the CONFIGURED name (its guidance names the tool it describes)…
  182. const sections = (await ctx.systemPrompt.assemble()).sections
  183. const section = sections.find(s => s.name === 'tool:orchestrate')
  184. expect(section?.text).toContain('orchestrate')
  185. expect(sections.some(s => s.name === 'tool:workflow')).toBe(false)
  186. await fiber.dispose()
  187. expect(ctx.tools.get('orchestrate')).toBeUndefined()
  188. // …and gone with the fiber — a reload must not leak a stale section.
  189. expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:orchestrate')).toBe(false)
  190. })
  191. it('presents a generic pending card titled by the meta name, with the script as rawInput', async () => {
  192. const { ctx } = await setup()
  193. const tool = ctx.tools.get('workflow')!
  194. const view = tool.presentCall!({ script: SCRIPT, meta: META })
  195. expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT })
  196. })
  197. it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => {
  198. const { ctx } = await setup()
  199. const tool = ctx.tools.get('workflow')!
  200. expect(tool.presentResult!({ script: SCRIPT, meta: META }, { content: [], isError: false })).toEqual({ card: 'generic' })
  201. // defineTool soft-validates presentation args: a malformed logged shape
  202. // (wrong fields entirely, or a call missing its meta) falls back to
  203. // undefined instead of throwing mid-replay.
  204. expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined()
  205. expect(tool.presentCall!({ script: SCRIPT })).toBeUndefined()
  206. })
  207. it('has the namespace-plugin export shape (no stray default)', () => {
  208. expect('default' in toolWorkflow).toBe(false)
  209. expect(toolWorkflow.name).toBe('tool-workflow')
  210. expect(toolWorkflow.inject).toEqual(['tools', 'workflows', 'systemPrompt'])
  211. const loader = Object.create(Loader.prototype) as Loader
  212. const unwrapped = loader.unwrapExports(toolWorkflow) as Record<string, unknown>
  213. expect(unwrapped).toBe(toolWorkflow)
  214. expect(typeof unwrapped.apply).toBe('function')
  215. })
  216. describe('composition with the REAL worker-thread engine (the mock above must stay honest)', () => {
  217. it('an abort releases the tool even when the script parks on a promise no hook owns', async () => {
  218. // The tool and loop await run.result before cleanup, so cancellation must settle a script
  219. // parked on an unowned promise. Exercise that guarantee through the real registry and worker.
  220. const ctx = new Context()
  221. await ctx.plugin(SystemPrompt)
  222. await ctx.plugin(ToolRegistry)
  223. await ctx.plugin(SubagentService)
  224. ctx.subagents.registerProvider({
  225. name: 'spawn',
  226. capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
  227. inheritsParentContext: false,
  228. start: () => Promise.reject(new Error('the parked-script fixture must not start a child')),
  229. })
  230. await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
  231. await ctx.plugin(toolWorkflow, {})
  232. const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
  233. const controller = new AbortController()
  234. const pending = execute(ctx, {
  235. script: 'await new Promise(() => {})\nreturn 1',
  236. meta: { name: 'stuck', description: 'parks forever' },
  237. }, { agent: parent, signal: controller.signal })
  238. // Give the run a beat to start (past its synchronous slice), then abort.
  239. await new Promise(resolve => setTimeout(resolve, 20))
  240. controller.abort('user abort')
  241. const result = await pending
  242. expect(result.isError).toBe(true)
  243. expect((result.content[0] as { text: string }).text).toContain('cancelled')
  244. })
  245. })
  246. })