host.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
  3. import type { PtcBindingFunction, PtcRunRequest, PtcRunResult, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
  4. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  5. import SessionProjections from '@deepseek-ai/dsh-session-projection'
  6. import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
  7. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  8. import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
  9. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  10. import PtcWorkflowEngine from '../src/index.ts'
  11. import { fakeParent } from './setup.ts'
  12. const completed: PtcRunResult = { logs: [], value: { value: null, stopReason: 'completed', agentsStarted: 0 } }
  13. type HostBindings = Record<string, PtcBindingFunction>
  14. class ControlledRuntime extends PtcRuntime {
  15. language = 'typescript'
  16. readonly isolation = 'process'
  17. execute: (spec: PtcRunSpec) => Promise<PtcRunResult> = () => Promise.resolve(completed)
  18. resolve(request: PtcRunRequest): PtcRunSpec {
  19. return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs === undefined ? 120_000 : request.timeoutMs }
  20. }
  21. run(spec: PtcRunSpec): Promise<PtcRunResult> { return this.execute(spec) }
  22. }
  23. async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Promise<PtcRunResult>, language = 'typescript') {
  24. const ctx = new Context()
  25. onTestFinished(async () => { await ctx.fiber.dispose() })
  26. await ctx.plugin(SessionStore)
  27. await ctx.plugin(SessionProjections)
  28. await ctx.plugin(SandboxPolicy, { mode: 'read-only' })
  29. await ctx.plugin(SubagentRuntime)
  30. ctx.subagents.registerProvider({
  31. name: 'stub',
  32. capabilities: { agentOptions: true, outputSchema: true, depthLimit: false, toolFilter: false, persona: false },
  33. inheritsParentContext: false,
  34. start: async () => ({
  35. id: SessionId('host-child'),
  36. localAgent: undefined,
  37. result: Promise.resolve({ output: [], stopReason: 'completed' }),
  38. dispose: () => Promise.resolve(),
  39. }),
  40. })
  41. await ctx.plugin(ControlledRuntime)
  42. const runtime = ctx.ptcRuntime as ControlledRuntime
  43. runtime.language = language
  44. if (execute !== undefined) runtime.execute = spec => execute(spec.bindings[0]!.functions, spec)
  45. await ctx.plugin(PtcWorkflowEngine, { provider: 'stub' })
  46. const parent = fakeParent(ctx)
  47. const start = () => ctx.workflowEngine.start({
  48. script: 'return null', meta: { name: 'host-test', description: 'workflow callbacks' }, parent,
  49. })
  50. return { ctx, runtime, start, parent }
  51. }
  52. describe('workflow host callback validation', () => {
  53. it.each([
  54. ['startChild', null, 'requires an object'],
  55. ['startChild', [], 'requires an object'],
  56. ['startChild', { prompt: 7 }, 'prompt must be a string'],
  57. ['startChild', { prompt: 'p', provider: 7 }, 'provider must be a string'],
  58. ['startChild', { prompt: 'p', model: false }, 'model must be a string'],
  59. ['progress', {}, 'requires an array of events'],
  60. ['progress', [{ type: 'phase', title: null }], 'phase must be a string'],
  61. ['progress', [{ type: 'agent-start', info: { seq: 0 } }], 'sequence must be a positive integer'],
  62. ['progress', [{ type: 'agent-end', info: { outcome: 'unknown' } }], 'invalid workflow agent outcome'],
  63. ['progress', [{ type: 'unknown' }], 'invalid workflow progress event'],
  64. ['childResult', { callId: '1' }, 'call id must be an integer'],
  65. ['disposeChild', { callId: 99 }, 'child call is not active'],
  66. ] as const)('rejects malformed %s callback data %j', async (name, input, message) => {
  67. const { start } = await setup(async (bindings) => {
  68. await expect(Promise.resolve().then(() => bindings[name]!(input))).rejects.toThrow(message)
  69. return completed
  70. })
  71. const handle = start()
  72. try { expect((await handle.result).stopReason).toBe('completed') }
  73. finally { await handle.dispose() }
  74. })
  75. it('does not emit duplicate agent-end notifications', async () => {
  76. const info = { seq: 1, label: 'child', childId: 'host-child' }
  77. const { ctx, start } = await setup(async (bindings) => {
  78. await bindings.progress!([{ type: 'agent-start', info }])
  79. await bindings.progress!([{ type: 'agent-end', info: { ...info, outcome: 'failed' } }])
  80. await bindings.progress!([{ type: 'agent-end', info: { ...info, outcome: 'cancelled' } }])
  81. return completed
  82. })
  83. const ended = vi.fn()
  84. ctx.on('workflow/agent-end', ended)
  85. const handle = start()
  86. try {
  87. expect((await handle.result).stopReason).toBe('completed')
  88. expect(ended).toHaveBeenCalledOnce()
  89. } finally { await handle.dispose() }
  90. })
  91. it('carries Session authority and an explicit unlimited deadline to the runtime', async () => {
  92. const { ctx, parent, start } = await setup(async (bindings, spec) => {
  93. expect(spec.timeoutMs).toBeNull()
  94. expect(spec.cwd).toBe(parent.session.header.cwd)
  95. expect(spec.sandboxPolicy).toEqual(ctx.sandboxPolicy.resolve({ session: parent.session }))
  96. expect(await bindings.begin!({})).toMatchObject({ body: 'return null', meta: { name: 'host-test' } })
  97. return completed
  98. })
  99. const handle = start()
  100. try { expect((await handle.result).stopReason).toBe('completed') }
  101. finally { await handle.dispose() }
  102. })
  103. })
  104. describe('workflow runtime outcomes', () => {
  105. it.each([
  106. [null, 'requires an object'],
  107. [{ value: null, stopReason: 'unknown', agentsStarted: 0 }, 'invalid workflow stop reason'],
  108. [{ value: null, stopReason: 'cancelled', agentsStarted: -1 }, 'invalid workflow agent count'],
  109. [{ stopReason: 'completed', agentsStarted: 0 }, 'missing its value'],
  110. [{ value: null, stopReason: 'error', agentsStarted: 0, error: 5 }, 'error must be a string'],
  111. ] as const)('maps an invalid terminal result %j to a workflow error', async (value, message) => {
  112. const { start } = await setup(() => Promise.resolve({ logs: [], value }))
  113. const handle = start()
  114. try {
  115. const result = await handle.result
  116. expect(result.stopReason).toBe('error')
  117. expect(result.error).toContain(message)
  118. } finally { await handle.dispose() }
  119. })
  120. it('preserves cancellation when the runtime rejects during cancellation', async () => {
  121. const entered = Promise.withResolvers<undefined>()
  122. const failure = Promise.withResolvers<PtcRunResult>()
  123. const { start } = await setup(() => { entered.resolve(undefined); return failure.promise })
  124. const handle = start()
  125. try {
  126. await entered.promise
  127. handle.cancel('stop requested')
  128. failure.reject(new Error('execution provider failed while stopping'))
  129. const result = await handle.result
  130. expect(result.stopReason).toBe('cancelled')
  131. expect(result.error).toContain('stop requested')
  132. } finally {
  133. failure.reject(new Error('test cleanup'))
  134. await handle.dispose()
  135. }
  136. })
  137. it('rejects a non-TypeScript runtime while loading the workflow provider', async () => {
  138. await expect(setup(undefined, 'python')).rejects.toThrow('requires the Node TypeScript PTC runtime')
  139. })
  140. it('stops waiting for child output after disposal releases the child resources', async () => {
  141. const childResult = Promise.withResolvers<SubagentResult>()
  142. const disposed = vi.fn(() => Promise.resolve())
  143. let outputWait: Promise<unknown> | undefined
  144. const { ctx, start } = await setup(async (bindings) => {
  145. const child = await bindings.startChild!({ prompt: 'child' })
  146. outputWait = bindings.childResult!(child)
  147. void outputWait.catch(() => {})
  148. return completed
  149. })
  150. vi.spyOn(ctx.subagents.getProvider('stub')!, 'start').mockResolvedValue({
  151. id: SessionId('output-pending'), localAgent: undefined, result: childResult.promise, dispose: disposed,
  152. })
  153. const handle = start()
  154. let settled = false
  155. void handle.result.then(() => { settled = true })
  156. try {
  157. await new Promise(resolve => setImmediate(resolve))
  158. expect(settled).toBe(true)
  159. expect(disposed).toHaveBeenCalledOnce()
  160. await expect(outputWait).rejects.toBe('workflow settled')
  161. expect((await handle.result).stopReason).toBe('completed')
  162. } finally {
  163. childResult.resolve({ output: [], stopReason: 'aborted' })
  164. await handle.dispose()
  165. }
  166. })
  167. })