integration.spec.ts 11 KB

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