integration.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import type { Agent } from '@deepseek-ai/dsh-agent'
  4. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  5. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  6. import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
  7. import { SessionId } from '@deepseek-ai/dsh-session'
  8. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  9. import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver'
  10. import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
  11. import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
  12. import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  13. import * as toolRalph from '../src/index.ts'
  14. type MockScript = ConstructorParameters<typeof MockAdapter>[0]
  15. const testToolSignal = new AbortController().signal
  16. /** Mount the shipped Ralph execution stack around one keyless model script. */
  17. async function mountRalph(script: MockScript, config: toolRalph.Config) {
  18. const ctx = new Context()
  19. const adapter = new MockAdapter(script)
  20. await mountAgentLoopTestDependencies(ctx)
  21. await ctx.plugin(AgentLoop, { agents: [] })
  22. await ctx.plugin(SubagentRuntime)
  23. await ctx.plugin(spawn, { providerName: 'spawn' })
  24. await ctx.plugin(WorkerThreadWorkflowEngine, {})
  25. await ctx.plugin(toolRalph, config)
  26. ctx.llm.registerAdapter(['mock'], adapter)
  27. const parentHandle = await ctx.agents.create({
  28. sessionId: SessionId('ralph-parent'),
  29. meta: { cwd: '/tmp/ralph-shared-workspace' },
  30. agentOptions: { provider: 'mock', model: 'mock' },
  31. })
  32. return { ctx, adapter, parentHandle, parent: parentHandle.agent }
  33. }
  34. describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
  35. it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', { timeout: 90_000 }, async () => {
  36. const firstReport = {
  37. status: 'continue',
  38. summary: 'ROUND_ONE_HANDOFF',
  39. evidence: ['Created migration-a.ts.'],
  40. nextSteps: ['Finish migration-b.ts.'],
  41. blocker: '',
  42. }
  43. const finalReport = {
  44. status: 'complete',
  45. summary: 'Both migration slices are complete.',
  46. evidence: ['Focused migration tests pass.'],
  47. nextSteps: [],
  48. blocker: '',
  49. }
  50. const ctx = new Context()
  51. const adapter = new MockAdapter([
  52. textResponse('PARENT_HISTORY_MARKER'),
  53. toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport),
  54. toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport),
  55. ])
  56. await mountAgentLoopTestDependencies(ctx)
  57. await ctx.plugin(AgentLoop, { agents: [] })
  58. await ctx.plugin(SubagentRuntime)
  59. await ctx.plugin(spawn, { providerName: 'spawn' })
  60. await ctx.plugin(WorkerThreadWorkflowEngine, {})
  61. await ctx.plugin(toolRalph, { maxRounds: 2 })
  62. ctx.llm.registerAdapter(['mock'], adapter)
  63. const parentHandle = await ctx.agents.create({
  64. sessionId: SessionId('ralph-parent'),
  65. meta: { cwd: '/tmp/ralph-shared-workspace' },
  66. agentOptions: { provider: 'mock', model: 'mock' },
  67. })
  68. const parent = parentHandle.agent
  69. parent.followup(createUserMessage({ content: [{ type: 'text', text: 'PARENT_PROMPT_MARKER' }], source: { kind: 'user' } }))
  70. await parent.whenIdle()
  71. const children: Agent[] = []
  72. const phases: string[] = []
  73. ctx.on('workflow/phase', (_run, title) => { phases.push(title) })
  74. ctx.on('workflow/agent-start', (_run, child) => {
  75. const agent = ctx.agents.get(child.childId)
  76. expect(agent).toBeDefined()
  77. children.push(agent!)
  78. })
  79. const result = await ctx.tools.execute({
  80. signal: testToolSignal,
  81. callId: ToolCallId('ralph-integration'),
  82. name: 'ralph',
  83. arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
  84. agent: parent,
  85. })
  86. expect(result.isError).toBe(false)
  87. expect((result.content[0] as { text: string }).text)
  88. .toContain('Ralph worker reported completion after 2 rounds.')
  89. expect(phases).toEqual(['Fresh-agent rounds'])
  90. expect(children).toHaveLength(2)
  91. expect(new Set(children.map(child => child.id)).size).toBe(2)
  92. for (const child of children) {
  93. expect(child.session.header.cwd).toBe('/tmp/ralph-shared-workspace')
  94. expect(child.session.header.parentSession).toBe(parent.session.header.id)
  95. expect(child.session.header.isSeeded).toBe(false)
  96. expect(child.session.inheritedEventCount).toBe(0)
  97. expect(ctx.agents.get(child.id)).toBeUndefined()
  98. }
  99. expect(adapter.requests).toHaveLength(3)
  100. const firstChildRequest = JSON.stringify(adapter.requests[1]!.messages)
  101. const secondChildRequest = JSON.stringify(adapter.requests[2]!.messages)
  102. expect(firstChildRequest).not.toContain('PARENT_PROMPT_MARKER')
  103. expect(firstChildRequest).not.toContain('PARENT_HISTORY_MARKER')
  104. expect(firstChildRequest).not.toContain('ROUND_ONE_HANDOFF')
  105. expect(secondChildRequest).not.toContain('PARENT_PROMPT_MARKER')
  106. expect(secondChildRequest).not.toContain('PARENT_HISTORY_MARKER')
  107. expect(secondChildRequest).toContain('ROUND_ONE_HANDOFF')
  108. await parentHandle.dispose()
  109. })
  110. it('reports the failed round and last good handoff when a child fails', { timeout: 90_000 }, async () => {
  111. const firstReport = {
  112. status: 'continue',
  113. summary: 'ROUND_ONE_HANDOFF',
  114. evidence: ['Created migration-a.ts.'],
  115. nextSteps: ['Finish migration-b.ts.'],
  116. blocker: '',
  117. }
  118. const { ctx, parent, parentHandle } = await mountRalph([
  119. toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport),
  120. maxTokensResponse('unfinished child output'),
  121. ], { maxRounds: 2 })
  122. const children: Agent[] = []
  123. ctx.on('workflow/agent-start', (_run, child) => {
  124. const agent = ctx.agents.get(child.childId)
  125. if (agent !== undefined) children.push(agent)
  126. })
  127. const result = await ctx.tools.execute({
  128. signal: testToolSignal,
  129. callId: ToolCallId('ralph-child-failure'),
  130. name: 'ralph',
  131. arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
  132. agent: parent,
  133. })
  134. expect(result.isError).toBe(true)
  135. const text = (result.content[0] as { text: string }).text
  136. expect(text).toContain('Ralph round 2 child failed before producing a structured report.')
  137. expect(text).toContain('Last successful handoff:')
  138. expect(text).toContain('ROUND_ONE_HANDOFF')
  139. expect(children).toHaveLength(2)
  140. for (const child of children) expect(ctx.agents.get(child.id)).toBeUndefined()
  141. await parentHandle.dispose()
  142. })
  143. it.each([
  144. {
  145. name: 'blocked',
  146. report: {
  147. status: 'blocked',
  148. summary: 'External authorization is required.',
  149. evidence: ['The local implementation is ready.'],
  150. nextSteps: ['Continue after authorization.'],
  151. blocker: 'The required external authorization is unavailable.',
  152. },
  153. config: { maxRounds: 2 },
  154. expectedError: false,
  155. expectedText: 'Ralph worker reported a blocker after 1 round.',
  156. },
  157. {
  158. name: 'budget-limited',
  159. report: {
  160. status: 'continue',
  161. summary: 'One slice is complete.',
  162. evidence: ['The first focused test passes.'],
  163. nextSteps: ['Implement the remaining slice.'],
  164. blocker: '',
  165. },
  166. config: { maxRounds: 1 },
  167. expectedError: false,
  168. expectedText: 'Ralph reached its 1 round limit; the worker reported work remaining.',
  169. },
  170. {
  171. name: 'unnormalized report',
  172. report: {
  173. status: 'continue',
  174. summary: ' padded summary ',
  175. evidence: ['A focused test passes.'],
  176. nextSteps: ['Continue implementation.'],
  177. blocker: '',
  178. },
  179. config: { maxRounds: 1 },
  180. expectedError: true,
  181. expectedText: 'summary must be non-empty and normalized',
  182. },
  183. {
  184. name: 'invalid continuing report',
  185. report: {
  186. status: 'continue',
  187. summary: 'Work remains.',
  188. evidence: ['A focused test passes.'],
  189. nextSteps: [],
  190. blocker: '',
  191. },
  192. config: { maxRounds: 1 },
  193. expectedError: true,
  194. expectedText: 'a continuing Ralph report needs nextSteps and an empty blocker',
  195. },
  196. {
  197. name: 'oversized report',
  198. report: {
  199. status: 'continue',
  200. summary: 'x'.repeat(300),
  201. evidence: ['A focused test passes.'],
  202. nextSteps: ['Continue implementation.'],
  203. blocker: '',
  204. },
  205. config: { maxRounds: 1, maxHandoffChars: 100 },
  206. expectedError: true,
  207. expectedText: 'Ralph round report exceeds maxHandoffChars',
  208. },
  209. ])('enforces the fixed script for $name', async ({ report, config, expectedError, expectedText }) => {
  210. const { ctx, parent, parentHandle } = await mountRalph([
  211. toolCallResponse('round-report', STRUCTURED_OUTPUT_TOOL, report),
  212. ], config)
  213. const result = await ctx.tools.execute({
  214. signal: testToolSignal,
  215. callId: ToolCallId('ralph-script-enforcement'),
  216. name: 'ralph',
  217. arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds },
  218. agent: parent,
  219. })
  220. expect(result.isError).toBe(expectedError)
  221. expect((result.content[0] as { text: string }).text).toContain(expectedText)
  222. await parentHandle.dispose()
  223. })
  224. it('cancels the real worker and fresh child to quiescence', { timeout: 90_000 }, async () => {
  225. const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 })
  226. const children: Agent[] = []
  227. const outcomes: string[] = []
  228. let resolveChildStarted!: (child: Agent) => void
  229. const childStarted = new Promise<Agent>((resolve) => { resolveChildStarted = resolve })
  230. ctx.on('workflow/agent-start', (_run, child) => {
  231. const agent = ctx.agents.get(child.childId)
  232. if (agent !== undefined) {
  233. children.push(agent)
  234. resolveChildStarted(agent)
  235. }
  236. })
  237. ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) })
  238. const controller = new AbortController()
  239. const pending = ctx.tools.execute({
  240. callId: ToolCallId('ralph-real-cancel'),
  241. name: 'ralph',
  242. arguments: { objective: 'Keep working until cancelled.', maxRounds: 2 },
  243. agent: parent,
  244. signal: controller.signal,
  245. })
  246. await childStarted
  247. controller.abort()
  248. const result = await pending
  249. expect(result.isError).toBe(true)
  250. expect((result.content[0] as { text: string }).text).toContain('Ralph workflow was cancelled')
  251. expect(outcomes).toEqual(['cancelled'])
  252. expect(ctx.agents.get(children[0]!.id)).toBeUndefined()
  253. await parentHandle.dispose()
  254. })
  255. })