turn-stop.spec.ts 7.8 KB

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