control-queue.host.spec.ts 5.8 KB

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