integration.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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, CallId } 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', 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: CallId('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.seedLength).toBeUndefined()
  96. expect(ctx.agents.get(child.id)).toBeUndefined()
  97. }
  98. expect(adapter.requests).toHaveLength(3)
  99. const firstChildRequest = JSON.stringify(adapter.requests[1]!.messages)
  100. const secondChildRequest = JSON.stringify(adapter.requests[2]!.messages)
  101. expect(firstChildRequest).not.toContain('PARENT_PROMPT_MARKER')
  102. expect(firstChildRequest).not.toContain('PARENT_HISTORY_MARKER')
  103. expect(firstChildRequest).not.toContain('ROUND_ONE_HANDOFF')
  104. expect(secondChildRequest).not.toContain('PARENT_PROMPT_MARKER')
  105. expect(secondChildRequest).not.toContain('PARENT_HISTORY_MARKER')
  106. expect(secondChildRequest).toContain('ROUND_ONE_HANDOFF')
  107. await parentHandle.dispose()
  108. })
  109. it('reports the failed round and last good handoff when a child fails', async () => {
  110. const firstReport = {
  111. status: 'continue',
  112. summary: 'ROUND_ONE_HANDOFF',
  113. evidence: ['Created migration-a.ts.'],
  114. nextSteps: ['Finish migration-b.ts.'],
  115. blocker: '',
  116. }
  117. const { ctx, parent, parentHandle } = await mountRalph([
  118. toolCallResponse('round-1', STRUCTURED_OUTPUT_TOOL, firstReport),
  119. maxTokensResponse('unfinished child output'),
  120. ], { maxRounds: 2 })
  121. const children: Agent[] = []
  122. ctx.on('workflow/agent-start', (_run, child) => {
  123. const agent = ctx.agents.get(child.childId)
  124. if (agent !== undefined) children.push(agent)
  125. })
  126. const result = await ctx.tools.execute({
  127. signal: testToolSignal,
  128. callId: CallId('ralph-child-failure'),
  129. name: 'ralph',
  130. arguments: { objective: 'Complete both migration slices.', maxRounds: 2 },
  131. agent: parent,
  132. })
  133. expect(result.isError).toBe(true)
  134. const text = (result.content[0] as { text: string }).text
  135. expect(text).toContain('Ralph round 2 child failed before producing a structured report.')
  136. expect(text).toContain('Last successful handoff:')
  137. expect(text).toContain('ROUND_ONE_HANDOFF')
  138. expect(children).toHaveLength(2)
  139. for (const child of children) expect(ctx.agents.get(child.id)).toBeUndefined()
  140. await parentHandle.dispose()
  141. })
  142. it.each([
  143. {
  144. name: 'blocked',
  145. report: {
  146. status: 'blocked',
  147. summary: 'External authorization is required.',
  148. evidence: ['The local implementation is ready.'],
  149. nextSteps: ['Continue after authorization.'],
  150. blocker: 'The required external authorization is unavailable.',
  151. },
  152. config: { maxRounds: 2 },
  153. expectedError: false,
  154. expectedText: 'Ralph worker reported a blocker after 1 round.',
  155. },
  156. {
  157. name: 'budget-limited',
  158. report: {
  159. status: 'continue',
  160. summary: 'One slice is complete.',
  161. evidence: ['The first focused test passes.'],
  162. nextSteps: ['Implement the remaining slice.'],
  163. blocker: '',
  164. },
  165. config: { maxRounds: 1 },
  166. expectedError: false,
  167. expectedText: 'Ralph reached its 1 round limit; the worker reported work remaining.',
  168. },
  169. {
  170. name: 'unnormalized report',
  171. report: {
  172. status: 'continue',
  173. summary: ' padded summary ',
  174. evidence: ['A focused test passes.'],
  175. nextSteps: ['Continue implementation.'],
  176. blocker: '',
  177. },
  178. config: { maxRounds: 1 },
  179. expectedError: true,
  180. expectedText: 'summary must be non-empty and normalized',
  181. },
  182. {
  183. name: 'invalid continuing report',
  184. report: {
  185. status: 'continue',
  186. summary: 'Work remains.',
  187. evidence: ['A focused test passes.'],
  188. nextSteps: [],
  189. blocker: '',
  190. },
  191. config: { maxRounds: 1 },
  192. expectedError: true,
  193. expectedText: 'a continuing Ralph report needs nextSteps and an empty blocker',
  194. },
  195. {
  196. name: 'oversized report',
  197. report: {
  198. status: 'continue',
  199. summary: 'x'.repeat(300),
  200. evidence: ['A focused test passes.'],
  201. nextSteps: ['Continue implementation.'],
  202. blocker: '',
  203. },
  204. config: { maxRounds: 1, maxHandoffChars: 100 },
  205. expectedError: true,
  206. expectedText: 'Ralph round report exceeds maxHandoffChars',
  207. },
  208. ])('enforces the fixed script for $name', async ({ report, config, expectedError, expectedText }) => {
  209. const { ctx, parent, parentHandle } = await mountRalph([
  210. toolCallResponse('round-report', STRUCTURED_OUTPUT_TOOL, report),
  211. ], config)
  212. const result = await ctx.tools.execute({
  213. signal: testToolSignal,
  214. callId: CallId('ralph-script-enforcement'),
  215. name: 'ralph',
  216. arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds },
  217. agent: parent,
  218. })
  219. expect(result.isError).toBe(expectedError)
  220. expect((result.content[0] as { text: string }).text).toContain(expectedText)
  221. await parentHandle.dispose()
  222. })
  223. it('cancels the real worker and fresh child to quiescence', { timeout: 20_000 }, async () => {
  224. const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 })
  225. const children: Agent[] = []
  226. const outcomes: string[] = []
  227. let resolveChildStarted!: (child: Agent) => void
  228. const childStarted = new Promise<Agent>((resolve) => { resolveChildStarted = resolve })
  229. ctx.on('workflow/agent-start', (_run, child) => {
  230. const agent = ctx.agents.get(child.childId)
  231. if (agent !== undefined) {
  232. children.push(agent)
  233. resolveChildStarted(agent)
  234. }
  235. })
  236. ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) })
  237. const controller = new AbortController()
  238. const pending = ctx.tools.execute({
  239. callId: CallId('ralph-real-cancel'),
  240. name: 'ralph',
  241. arguments: { objective: 'Keep working until cancelled.', maxRounds: 2 },
  242. agent: parent,
  243. signal: controller.signal,
  244. })
  245. await childStarted
  246. controller.abort()
  247. const result = await pending
  248. expect(result.isError).toBe(true)
  249. expect((result.content[0] as { text: string }).text).toContain('Ralph workflow was cancelled')
  250. expect(outcomes).toEqual(['cancelled'])
  251. expect(ctx.agents.get(children[0]!.id)).toBeUndefined()
  252. await parentHandle.dispose()
  253. })
  254. })