control-queue.host.spec.ts 5.7 KB

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