subagent-inprocess.spec.ts 3.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import SessionStore from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
  8. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  9. import * as Invariants from '@deepseek-ai/dsh-invariants'
  10. import SubagentService from '@deepseek-ai/dsh-subagent'
  11. import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  12. import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts'
  13. type Script = ConstructorParameters<typeof MockAdapter>[0]
  14. /**
  15. * Drives the shared in-process run driver DIRECTLY (no provider package), so the
  16. * driver's own contract — depth read/cap, the one-shot drive, the result read —
  17. * is covered independently of which backend (spawn/fork) calls it. The only
  18. * mocked boundary is the model; the real agent loop, SubagentService, and
  19. * dsh-invariants are mounted, so a malformed child session log fails the test.
  20. */
  21. async function setup(script: Script) {
  22. const ctx = new Context()
  23. await ctx.plugin(LlmService)
  24. await ctx.plugin(SessionStore)
  25. await ctx.plugin(SystemPrompt)
  26. await ctx.plugin(ToolRegistry)
  27. await ctx.plugin(AgentRegistry)
  28. await ctx.plugin(Invariants)
  29. await ctx.plugin(AgentLoop, { agents: [] })
  30. await ctx.plugin(SubagentService)
  31. ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
  32. const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
  33. return { ctx, parent }
  34. }
  35. function text(blocks: { type: string; text?: string }[]): string {
  36. return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
  37. }
  38. describe('depthOf', () => {
  39. it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => {
  40. const { parent } = await setup([])
  41. expect(depthOf(parent)).toBe(0)
  42. const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent
  43. expect(depthOf(withDepth)).toBe(3)
  44. })
  45. })
  46. describe('startInProcessRun', () => {
  47. it('drives a fresh child (no seed) to completion and returns its output', async () => {
  48. const { ctx, parent } = await setup([textResponse('driver child answer')])
  49. const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
  50. const result = await run.result
  51. expect(result.stopReason).toBe('completed')
  52. expect(text(result.output)).toBe('driver child answer')
  53. expect(depthOf(ctx.agents.get(run.id)!)).toBe(1)
  54. await run.dispose()
  55. })
  56. it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
  57. const { ctx, parent } = await setup([])
  58. expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
  59. .toThrow(SubagentDepthError)
  60. })
  61. it('seeds the child session when a seed is supplied', async () => {
  62. // Drive the parent through one real turn, then seed the child with that
  63. // completed-turn prefix — the child must SEE the parent's history but its
  64. // result is scoped to its OWN events (not the seeded parent message).
  65. const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')])
  66. parent.send([{ type: 'text', text: 'parent q' }])
  67. await parent.whenIdle()
  68. const seed = parent.session.events.slice()
  69. const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
  70. const result = await run.result
  71. expect(result.stopReason).toBe('completed')
  72. expect(text(result.output)).toBe('seeded child reply')
  73. const child = ctx.agents.get(run.id)!
  74. // The child inherited the parent's prefix.
  75. expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true)
  76. await run.dispose()
  77. })
  78. })