python-snapshot-workflow-order.spec.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import { getEventListeners } from 'node:events'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
  4. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  6. import { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
  7. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  8. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  9. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  10. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  11. import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
  12. import { MockAdapter, textResponse } from '../packages/core/agent-loop/tests/mock-adapter.ts'
  13. import type {} from '@deepseek-ai/dsh-tool-workflow'
  14. import { afterEach, describe, expect, it, vi } from 'vitest'
  15. // @ts-expect-error Scenario plugins are runtime JavaScript without declaration artifacts.
  16. import * as fixtureModule from './fixtures/python-snapshot-workflow-order.mjs'
  17. const config = { parentSessionId: 'advanced-parent', prompt: 'workflow child prompt' }
  18. const fixture = fixtureModule as unknown as {
  19. name: string
  20. apply(ctx: Context, config: { parentSessionId: string; prompt: string }): void
  21. }
  22. const cleanups: (() => Promise<unknown>)[] = []
  23. afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
  24. async function harness() {
  25. const ctx = new Context()
  26. const store = ctx.plugin(SessionStore)
  27. await store
  28. cleanups.push(() => store.dispose())
  29. const fiber = ctx.plugin(fixture, config)
  30. await fiber
  31. cleanups.push(() => fiber.dispose())
  32. const parent = ctx.sessions.create(SessionId(config.parentSessionId))
  33. const other = ctx.sessions.create(SessionId('other-parent'))
  34. const session = ctx.sessions.create(SessionId('workflow-child'), { meta: { parentSession: parent.id } })
  35. // The dispatcher needs only the subject identity; the fixture reads its Session.
  36. const agent = { id: session.id, session } as Agent
  37. const controller = new AbortController()
  38. const messages = [createUserMessage({ content: [{ type: 'text', text: config.prompt }], source: { kind: 'user' } })]
  39. const decision: PreStepDecision = { kind: 'enter', messages }
  40. const next = vi.fn(async () => decision)
  41. const start = (owner = parent, childId = agent.id) => owner.append('tool-workflow/agent-start', {
  42. runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId,
  43. })
  44. const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
  45. turn: 1, step: 1, messages, signal: controller.signal, ...overrides,
  46. }, next)
  47. return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step }
  48. }
  49. describe('advanced Python snapshot workflow ordering', () => {
  50. it('blocks a real spawned child before its descriptor and first model request', async () => {
  51. const ctx = new Context()
  52. const entered = Promise.withResolvers<Agent>()
  53. const order: string[] = []
  54. const adapter = new MockAdapter([textResponse('child complete')])
  55. const assembly = ctx.plugin({
  56. name: 'workflow-order-driver-test',
  57. async apply(inner: Context) {
  58. await mountAgentLoopTestDependencies(inner)
  59. await inner.plugin(AgentLoop, { agents: [] })
  60. await inner.plugin(SessionProjectionRegistry)
  61. await inner.plugin(SubagentRuntime)
  62. await inner.plugin(spawn, { providerName: 'spawn' })
  63. inner.on('agent/pre-step', ({ agent }, next) => {
  64. if (agent.session.header.parentSession === config.parentSessionId) entered.resolve(agent)
  65. return next()
  66. })
  67. inner.on('session/event', (_session, event) => {
  68. if (event.type === 'tool-workflow/agent-start' || event.type === 'subagent/descriptor') order.push(event.type)
  69. })
  70. await inner.plugin(fixture, config)
  71. },
  72. })
  73. cleanups.push(() => assembly.dispose())
  74. await assembly
  75. ctx.llm.registerAdapter(['mock'], adapter)
  76. const parent = await ctx.agentLoop.create(SessionId(config.parentSessionId), { provider: 'mock', model: 'mock' })
  77. const run = await ctx.subagents.start('spawn', {
  78. parent, prompt: [{ type: 'text', text: config.prompt }], signal: new AbortController().signal,
  79. })
  80. cleanups.push(() => run.dispose())
  81. const child = await entered.promise
  82. expect(child.id).toBe(run.id)
  83. expect(adapter.requests).toHaveLength(0)
  84. expect(child.session.snapshotEvents().some(event => event.type === 'subagent/descriptor')).toBe(false)
  85. parent.session.append('tool-workflow/agent-start', {
  86. runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId: child.id,
  87. })
  88. expect((await run.result).output).toEqual([{ type: 'text', text: 'child complete' }])
  89. expect(adapter.requests).toHaveLength(1)
  90. expect(order).toEqual(['tool-workflow/agent-start', 'subagent/descriptor'])
  91. })
  92. it('holds the child until the exact parent records the exact member', async () => {
  93. const h = await harness()
  94. const pending = h.step()
  95. expect(h.next).not.toHaveBeenCalled()
  96. expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(1)
  97. h.start(h.other)
  98. h.start(h.parent, SessionId('other-child'))
  99. h.parent.append('tool-workflow/run-start', { runId: WorkflowRunId('run'), name: 'workflow' })
  100. await Promise.resolve()
  101. expect(h.next).not.toHaveBeenCalled()
  102. h.start()
  103. expect(await pending).toBe(h.decision)
  104. expect(h.next).toHaveBeenCalledOnce()
  105. expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
  106. })
  107. it('retains a start recorded before the child reaches its first step', async () => {
  108. const h = await harness()
  109. h.start()
  110. expect(await h.step()).toBe(h.decision)
  111. expect(h.next).toHaveBeenCalledOnce()
  112. expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
  113. })
  114. it.each(['prompt', 'parent', 'turn', 'step'])('does not hold an unrelated %s', async (difference) => {
  115. const h = await harness()
  116. const overrides = difference === 'prompt' ? { messages: [] }
  117. : difference === 'turn' ? { turn: 2 }
  118. : difference === 'step' ? { step: 2 } : {}
  119. if (difference === 'parent') {
  120. const session = h.ctx.sessions.create(SessionId('unrelated-child'), { meta: { parentSession: h.other.id } })
  121. Object.assign(h.agent, { session })
  122. }
  123. expect(await h.step(overrides)).toBe(h.decision)
  124. expect(h.next).toHaveBeenCalledOnce()
  125. })
  126. it.each([false, true])('rejects cancellation and detaches the waiter (already aborted: %s)', async (alreadyAborted) => {
  127. const h = await harness()
  128. const reason = new Error('cancelled child')
  129. if (alreadyAborted) h.controller.abort(reason)
  130. const pending = h.step()
  131. const rejected = expect(pending).rejects.toBe(reason)
  132. h.controller.abort(reason)
  133. await rejected
  134. h.start()
  135. expect(h.next).not.toHaveBeenCalled()
  136. expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
  137. })
  138. it('does not admit a cancelled child when start and cancellation share a tick', async () => {
  139. const h = await harness()
  140. const reason = new Error('cancelled after membership')
  141. const pending = h.step()
  142. const rejected = expect(pending).rejects.toBe(reason)
  143. h.start()
  144. h.controller.abort(reason)
  145. await rejected
  146. expect(h.next).not.toHaveBeenCalled()
  147. expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
  148. })
  149. it('settles pending waits before disposal completes and removes both listeners', async () => {
  150. const h = await harness()
  151. const pending = h.step()
  152. const rejected = expect(pending).rejects.toThrow('workflow snapshot barrier disposed')
  153. await h.fiber.dispose()
  154. await rejected
  155. h.start()
  156. expect(h.next).not.toHaveBeenCalled()
  157. expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
  158. expect(await h.step()).toBe(h.decision)
  159. })
  160. })