control-queue.host.spec.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
  3. import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
  4. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  6. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  7. import { describe, expect, it } from 'vitest'
  8. import { SessionControlController } from '../src/control.ts'
  9. import type { SessionControlFrame } from '../src/types.ts'
  10. import { ReactLoopInbox } from '@deepseek-ai/dsh-agent-loop'
  11. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  12. async function harness(): Promise<{
  13. ctx: Context
  14. control: SessionControlController
  15. agent: Agent
  16. inbox: Inbox
  17. }> {
  18. const ctx = new Context()
  19. await ctx.plugin(SessionStore)
  20. await ctx.plugin(SessionProjectionRegistry)
  21. await ctx.plugin(AgentRegistry)
  22. const session = ctx.sessions.create(SessionId('queue-session'))
  23. const agent: Agent = {
  24. id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'running', ctx,
  25. send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
  26. runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
  27. }
  28. const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
  29. Object.assign(agent, { inbox })
  30. ctx.agents.register(agent)
  31. return { ctx, control: new SessionControlController(ctx), agent, inbox }
  32. }
  33. function message(text: string, source: 'user' | 'plugin' = 'user') {
  34. return createUserMessage({
  35. content: [{ type: 'text', text }],
  36. source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' },
  37. })
  38. }
  39. describe('Session control queue projection', () => {
  40. /** Consume frames until the next queue replacement (inbox projection frames interleave). */
  41. async function nextQueueFrame(
  42. iterator: AsyncIterator<SessionControlFrame>,
  43. ): Promise<Extract<SessionControlFrame, { type: 'queue' }>> {
  44. for (;;) {
  45. const next = await iterator.next()
  46. if (next.done) throw new Error('stream ended before a queue frame')
  47. if (next.value.type === 'queue') return next.value
  48. }
  49. }
  50. it('projects both pending lists in baselines and live replacement frames', async () => {
  51. const { control, inbox } = await harness()
  52. const queued = message('queued')
  53. const steering = message('steering')
  54. const context = message('context', 'plugin')
  55. inbox.append('next-turn', queued)
  56. inbox.append('next-step', steering)
  57. inbox.append('next-step', context)
  58. const abort = new AbortController()
  59. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  60. const opened = await iterator.next()
  61. expect(opened.value).toMatchObject({
  62. type: 'baseline',
  63. value: {
  64. queues: {
  65. 'queue-session': [
  66. { id: queued.id, placement: 'queued' },
  67. { id: steering.id, placement: 'steering' },
  68. { id: context.id, placement: 'context' },
  69. ],
  70. },
  71. },
  72. })
  73. const replacement = message('replacement')
  74. inbox.append('next-turn', replacement)
  75. const replaced = await nextQueueFrame(iterator)
  76. expect(replaced.items.map(item => item.id)).toContain(replacement.id)
  77. inbox.remove(steering.id)
  78. const removed = await nextQueueFrame(iterator)
  79. expect(removed.items.map(item => item.id)).not.toContain(steering.id)
  80. abort.abort()
  81. await iterator.next()
  82. })
  83. it('derives queue replacements from the completed projection regardless of registration order', async () => {
  84. const ctx = new Context()
  85. await ctx.plugin(SessionStore)
  86. await ctx.plugin(SessionProjectionRegistry)
  87. await ctx.plugin(AgentRegistry)
  88. const control = new SessionControlController(ctx)
  89. const session = ctx.sessions.create(SessionId('late-projection-queue'))
  90. const agent: Agent = {
  91. id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'running', ctx,
  92. send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel: () => {},
  93. runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(),
  94. }
  95. const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
  96. Object.assign(agent, { inbox })
  97. ctx.agents.register(agent)
  98. const abort = new AbortController()
  99. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  100. await iterator.next()
  101. const pending = message('late projection')
  102. agent.inbox.append('next-turn', pending)
  103. await expect(iterator.next()).resolves.toMatchObject({
  104. value: {
  105. type: 'projection',
  106. key: 'inbox',
  107. value: { 'next-turn': [{ id: pending.id }], 'next-step': [] },
  108. },
  109. })
  110. await expect(nextQueueFrame(iterator)).resolves.toMatchObject({
  111. items: [{ id: pending.id, placement: 'queued' }],
  112. })
  113. abort.abort()
  114. await iterator.next()
  115. })
  116. it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => {
  117. const { control, inbox } = await harness()
  118. const identified = createUserMessage({
  119. content: [{ type: 'text', text: 'browser prompt' }],
  120. source: { kind: 'user', rpcId: 'req-42' as never },
  121. })
  122. inbox.append('next-turn', identified)
  123. inbox.append('next-step', message('plain steering'))
  124. const abort = new AbortController()
  125. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  126. const opened = await iterator.next()
  127. if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline')
  128. const items = opened.value.value.queues['queue-session' as SessionId] ?? []
  129. expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([
  130. { id: identified.id, placement: 'queued', rpcId: 'req-42' },
  131. { id: items[1]?.id, placement: 'steering', rpcId: undefined },
  132. ])
  133. expect('rpcId' in (items[1] ?? {})).toBe(false)
  134. abort.abort()
  135. await iterator.next()
  136. })
  137. it('ignores inbox events without the exact live Agent session', async () => {
  138. const { ctx, control, agent, inbox } = await harness()
  139. const abort = new AbortController()
  140. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  141. await iterator.next()
  142. const unrelated = ctx.sessions.create(SessionId('unrelated-queue'))
  143. unrelated.append('agent/inbox/spliced', {
  144. target: 'next-turn',
  145. start: 0,
  146. inserted: [message('unrelated')],
  147. })
  148. const replacement = ctx.sessions.create(SessionId('replacement-session'))
  149. Object.defineProperty(agent, 'session', { configurable: true, value: replacement })
  150. inbox.append('next-turn', message('wrong-session'))
  151. abort.abort()
  152. await iterator.next()
  153. })
  154. it('drops broadcasts after cancellation has ended its queue', async () => {
  155. const { control, inbox } = await harness()
  156. const abort = new AbortController()
  157. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  158. await iterator.next()
  159. const waiting = iterator.next()
  160. await Promise.resolve()
  161. abort.abort()
  162. inbox.append('next-turn', message('late'))
  163. await expect(waiting).resolves.toMatchObject({ done: true })
  164. })
  165. it('ends active streams on context disposal after flushing buffered frames', async () => {
  166. const { ctx, control, inbox } = await harness()
  167. const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]()
  168. await iterator.next()
  169. const first = message('first')
  170. const second = message('second')
  171. inbox.append('next-turn', first)
  172. inbox.append('next-turn', second)
  173. const queues: Extract<SessionControlFrame, { type: 'queue' }>[] = []
  174. await ctx.fiber.dispose()
  175. for (;;) {
  176. const next = await iterator.next()
  177. if (next.done) break
  178. if (next.value.type === 'queue') queues.push(next.value)
  179. }
  180. expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]])
  181. })
  182. })