integration.spec.ts 11 KB

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