control-queue.host.spec.ts 6.3 KB

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