1
0

turn-stop.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry, { type Agent, type ContinuationStop } 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 { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  11. async function harness(adapter: MockAdapter): Promise<Context> {
  12. const ctx = new Context()
  13. await ctx.plugin(LlmService)
  14. await ctx.plugin(SessionStore)
  15. await ctx.plugin(SystemPrompt)
  16. await ctx.plugin(ToolRegistry)
  17. await ctx.plugin(AgentRegistry)
  18. await ctx.plugin(Invariants)
  19. await ctx.plugin(AgentLoop, { agents: [] })
  20. ctx.llm.registerAdapter(['mock'], adapter)
  21. return ctx
  22. }
  23. function send(agent: Agent, text = 'go'): Promise<void> {
  24. agent.send([{ type: 'text', text }])
  25. return agent.whenIdle()
  26. }
  27. function registerEcho(ctx: Context): void {
  28. ctx.tools.register(defineTool({
  29. name: 'echo',
  30. description: 'echo',
  31. parameters: { text: { type: 'string' } },
  32. async execute(args) {
  33. return [{ type: 'text', text: String(args.text) }]
  34. },
  35. }))
  36. }
  37. describe('agent/turn-stop', () => {
  38. it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
  39. const adapter = new MockAdapter([
  40. textResponse('the ordinary decision is stop'),
  41. textResponse('must not be requested'),
  42. ])
  43. const ctx = await harness(adapter)
  44. const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { provider: 'mock', model: 'mock' })
  45. agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
  46. let steered = false
  47. ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
  48. const downstream = await next()
  49. if (subject === agent && !steered) {
  50. steered = true
  51. subject.steer([{ type: 'text', text: 'late continuation steering' }])
  52. }
  53. return downstream
  54. }, { prepend: true })
  55. await send(agent)
  56. expect(adapter.requests).toHaveLength(1)
  57. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  58. expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
  59. expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
  60. })
  61. it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
  62. const adapter = new MockAdapter([
  63. textResponse('terminal answer'),
  64. textResponse('must not become a late-steering turn'),
  65. ])
  66. const ctx = await harness(adapter)
  67. const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { provider: 'mock', model: 'mock' })
  68. agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
  69. let injected = false
  70. ctx.on('session/flush', (session) => {
  71. if (session !== agent.session || injected) return
  72. injected = true
  73. agent.steer([{ type: 'text', text: 'steering from flush' }])
  74. })
  75. await send(agent)
  76. expect(injected).toBe(true)
  77. expect(agent.status).toBe('idle')
  78. expect(adapter.requests).toHaveLength(1)
  79. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  80. expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
  81. expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
  82. })
  83. it('preserves an ordinary queued send that arrives during terminal flush', async () => {
  84. const adapter = new MockAdapter([
  85. textResponse('first terminal answer'),
  86. textResponse('queued follow-up answer'),
  87. ])
  88. const ctx = await harness(adapter)
  89. const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { provider: 'mock', model: 'mock' })
  90. agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
  91. let queued = false
  92. ctx.on('session/flush', (session) => {
  93. if (session !== agent.session || queued) return
  94. queued = true
  95. agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
  96. })
  97. await send(agent)
  98. expect(agent.status).toBe('idle')
  99. expect(adapter.requests).toHaveLength(2)
  100. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
  101. expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
  102. })
  103. it('filters a scoped terminal listener to its own agent', async () => {
  104. const adapter = new MockAdapter([
  105. toolCallResponse('a1', 'echo', { text: 'a' }),
  106. toolCallResponse('b1', 'echo', { text: 'b' }),
  107. textResponse('b continues normally'),
  108. ])
  109. const ctx = await harness(adapter)
  110. registerEcho(ctx)
  111. const stopped = ctx.agentLoop.create(SessionId('stopped'), { provider: 'mock', model: 'mock' })
  112. const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { provider: 'mock', model: 'mock' })
  113. stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
  114. await send(stopped)
  115. expect(adapter.requests).toHaveLength(1)
  116. await send(ordinary)
  117. expect(adapter.requests).toHaveLength(3)
  118. expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
  119. expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
  120. })
  121. it('unregisters with its scoped owner disposer', async () => {
  122. const adapter = new MockAdapter([
  123. toolCallResponse('first', 'echo', { text: 'first' }),
  124. toolCallResponse('second', 'echo', { text: 'second' }),
  125. textResponse('continued after listener disposal'),
  126. ])
  127. const ctx = await harness(adapter)
  128. registerEcho(ctx)
  129. const agent = ctx.agentLoop.create(SessionId('owned-listener'), { provider: 'mock', model: 'mock' })
  130. const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
  131. await send(agent, 'first turn')
  132. expect(adapter.requests).toHaveLength(1)
  133. disposeStop()
  134. await send(agent, 'second turn')
  135. expect(adapter.requests).toHaveLength(3)
  136. })
  137. it('fails a throwing terminal policy closed while the driver survives', async () => {
  138. const adapter = new MockAdapter([
  139. textResponse('throwing policy'),
  140. textResponse('healthy later turn'),
  141. ])
  142. const ctx = await harness(adapter)
  143. const agent = ctx.agentLoop.create(SessionId('bad-policy'), { provider: 'mock', model: 'mock' })
  144. const reasons: TurnEndReason[] = []
  145. const errors: string[] = []
  146. ctx.on('session/event', (session, event) => {
  147. if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
  148. })
  149. agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
  150. const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
  151. throw new Error('terminal policy exploded')
  152. })
  153. await send(agent, 'first')
  154. disposeThrowing()
  155. await send(agent, 'healthy')
  156. expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed'])
  157. expect(errors).toContain('terminal policy exploded')
  158. expect(adapter.requests).toHaveLength(2)
  159. })
  160. })