control-queue.host.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { Context } from '@deepseek-ai/cordis'
  2. import type { Agent, Inbox, InboxState } from '@deepseek-ai/dsh-agent'
  3. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  4. import { SessionId } from '@deepseek-ai/dsh-session'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { SessionControlController } from '../src/control.ts'
  7. import type { SessionControlFrame } from '../src/types.ts'
  8. import {
  9. mountAgentLoopTestDependencies,
  10. mountAgentLoopTestHarness,
  11. } from '@deepseek-ai/dsh-agent-loop-testkit'
  12. const ownedContexts = new Set<Context>()
  13. afterEach(async () => {
  14. await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
  15. ownedContexts.clear()
  16. })
  17. async function harness(): Promise<{
  18. ctx: Context
  19. control: SessionControlController
  20. agent: Agent
  21. inbox: Inbox
  22. }> {
  23. const ctx = new Context()
  24. ownedContexts.add(ctx)
  25. await mountAgentLoopTestDependencies(ctx)
  26. const loop = await mountAgentLoopTestHarness(ctx)
  27. const agent = await loop.create(SessionId('queue-session'))
  28. return { ctx, control: new SessionControlController(ctx), agent, inbox: agent.inbox }
  29. }
  30. function message(text: string, source: 'user' | 'plugin' = 'user') {
  31. return createUserMessage({
  32. content: [{ type: 'text', text }],
  33. source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' },
  34. })
  35. }
  36. describe('Session control Inbox projection', () => {
  37. /** Consume frames until the next durable Inbox value. */
  38. async function nextInboxFrame(
  39. iterator: AsyncIterator<SessionControlFrame>,
  40. ): Promise<Extract<SessionControlFrame, { type: 'projection' }>> {
  41. for (;;) {
  42. const next = await iterator.next()
  43. if (next.done) throw new Error('stream ended before an Inbox value')
  44. if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value
  45. }
  46. }
  47. it('projects both pending lists in baselines and live replacement frames', async () => {
  48. const { control, inbox } = await harness()
  49. const queued = message('queued')
  50. const steering = message('steering')
  51. const context = message('context', 'plugin')
  52. inbox.append('next-turn', queued)
  53. inbox.append('next-step', steering)
  54. inbox.append('next-step', context)
  55. const abort = new AbortController()
  56. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  57. const opened = await iterator.next()
  58. expect(opened.value).toMatchObject({
  59. type: 'baseline',
  60. value: {
  61. projections: {
  62. 'queue-session': { values: { inbox: {
  63. 'next-turn': [queued], 'next-step': [steering, context],
  64. } } },
  65. },
  66. },
  67. })
  68. const replacement = message('replacement')
  69. inbox.append('next-turn', replacement)
  70. const replaced = await nextInboxFrame(iterator)
  71. expect(replaced.value).toMatchObject({ 'next-turn': [queued, replacement] })
  72. inbox.remove(steering.id)
  73. const removed = await nextInboxFrame(iterator)
  74. expect(removed.value).toMatchObject({ 'next-step': [context] })
  75. abort.abort()
  76. await iterator.next()
  77. })
  78. it('derives queue replacements from the completed projection regardless of registration order', async () => {
  79. const ctx = new Context()
  80. ownedContexts.add(ctx)
  81. await mountAgentLoopTestDependencies(ctx)
  82. const loop = await mountAgentLoopTestHarness(ctx)
  83. const control = new SessionControlController(ctx)
  84. const agent = await loop.create(SessionId('late-projection-queue'))
  85. const { inbox } = 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. 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. abort.abort()
  99. await iterator.next()
  100. })
  101. it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => {
  102. const { control, inbox } = await harness()
  103. const identified = createUserMessage({
  104. content: [{ type: 'text', text: 'browser prompt' }],
  105. source: { kind: 'user', rpcId: 'req-42' as never },
  106. })
  107. inbox.append('next-turn', identified)
  108. inbox.append('next-step', message('plain steering'))
  109. const abort = new AbortController()
  110. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  111. const opened = await iterator.next()
  112. if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline')
  113. const inboxValue = opened.value.value.projections['queue-session' as SessionId]?.values.inbox as unknown as InboxState
  114. expect(inboxValue['next-turn'][0]?.source).toMatchObject({ kind: 'user', rpcId: 'req-42' })
  115. expect(inboxValue['next-step'][0]?.source).toEqual({ kind: 'user' })
  116. abort.abort()
  117. await iterator.next()
  118. })
  119. it('publishes Inbox values for sessions without a live Agent', async () => {
  120. const { ctx, control } = await harness()
  121. const abort = new AbortController()
  122. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  123. await iterator.next()
  124. const session = ctx.sessions.create(SessionId('unattached-inbox'))
  125. const pending = message('unattached')
  126. session.append('agent/inbox/spliced', {
  127. target: 'next-turn', start: 0, inserted: [pending],
  128. })
  129. expect(ctx.agents.get(session.id)).toBeUndefined()
  130. await expect(nextInboxFrame(iterator)).resolves.toMatchObject({
  131. sessionId: session.id, value: { 'next-turn': [pending], 'next-step': [] },
  132. })
  133. abort.abort()
  134. await iterator.next()
  135. })
  136. it('drops broadcasts after cancellation has ended its queue', async () => {
  137. const { control, inbox } = await harness()
  138. const abort = new AbortController()
  139. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  140. await iterator.next()
  141. const waiting = iterator.next()
  142. await Promise.resolve()
  143. abort.abort()
  144. inbox.append('next-turn', message('late'))
  145. await expect(waiting).resolves.toMatchObject({ done: true })
  146. })
  147. it('ends active streams on context disposal after flushing buffered frames', async () => {
  148. const { ctx, control, inbox } = await harness()
  149. const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]()
  150. await iterator.next()
  151. const first = message('first')
  152. const second = message('second')
  153. inbox.append('next-turn', first)
  154. inbox.append('next-turn', second)
  155. const values: InboxState[] = []
  156. ownedContexts.delete(ctx)
  157. await ctx.fiber.dispose()
  158. for (;;) {
  159. const next = await iterator.next()
  160. if (next.done) break
  161. if (next.value.type === 'projection' && next.value.key === 'inbox') values.push(next.value.value as unknown as InboxState)
  162. }
  163. expect(values.map(value => value['next-turn'].map(item => item.id))).toEqual([[first.id], [first.id, second.id]])
  164. })
  165. })