inbox-projection.client.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. /** Inbox projection delivery and queue-operation transport. */
  2. import { describe, expect, onTestFinished } from 'vitest'
  3. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  4. import type { InboxState } from '@deepseek-ai/dsh-agent/types'
  5. import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
  6. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  7. import { SessionManager } from '../src/client/sessions/manager.ts'
  8. import { ok } from '@deepseek-ai/dsh-remote-mock'
  9. import { createClientTest, type ClientTestFixtures, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
  10. import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
  11. const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) })
  12. function makeManager(remote: ClientTestFixtures['remote']): SessionManager {
  13. const manager = new SessionManager(remote as unknown as SessionRemotes)
  14. onTestFinished(() => manager.dispose())
  15. return manager
  16. }
  17. const SID = 'fk-q1' as SessionId
  18. const text = (value: string) => [{ type: 'text' as const, text: value }]
  19. let nextSeq = 1
  20. function message(label: string, body: string) {
  21. return createUserMessage({
  22. content: text(body),
  23. source: { kind: 'user', rpcId: `rpc-${label}` } as never,
  24. })
  25. }
  26. function inboxFrame(value: InboxState): Extract<SessionControlFrame, { type: 'projection' }> {
  27. return {
  28. type: 'projection',
  29. sessionId: SID,
  30. key: 'inbox',
  31. seq: nextSeq++,
  32. value: value as never,
  33. }
  34. }
  35. describe('Inbox projection intake', () => {
  36. it('stores the complete Agent-owned value without adding queue state to the Session snapshot', ({ remote }) => {
  37. const manager = makeManager(remote)
  38. const queued = message('queued', 'later')
  39. const steering = message('steering', 'now')
  40. const value = { 'next-turn': [queued], 'next-step': [steering] }
  41. manager.handleControlFrame(inboxFrame(value))
  42. const session = manager.get(SID)
  43. expect(session.projections.faceOf('inbox').getSnapshot()).toEqual(value)
  44. expect(session.getSnapshot()).not.toHaveProperty('queue')
  45. })
  46. it.for(['included', 'omitted'] as const)(
  47. 'keeps a newer list Inbox when a delayed control baseline has the key %s',
  48. async (key, { remote }) => {
  49. const list = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  50. remote.session.list.mockReturnValue(list.promise)
  51. const manager = makeManager(remote)
  52. const empty = { 'next-turn': [], 'next-step': [] }
  53. const stale = { ...empty, 'next-turn': [message('removed', 'already removed')] }
  54. const result = ok({ items: [{
  55. sessionId: SID, updatedAt: 1, running: false, blank: false,
  56. projections: { asOfSeq: 21, values: { inbox: empty } },
  57. }] })
  58. let refreshed: Promise<void> | undefined
  59. try {
  60. manager.handleConnected()
  61. refreshed = manager.refreshList()
  62. list.resolve(result)
  63. await refreshed
  64. const face = manager.get(SID).projections.faceOf('inbox')
  65. expect(face.getSnapshot()).toEqual(empty)
  66. manager.handleControlFrame({
  67. type: 'baseline',
  68. value: { jobs: {}, projections: { [SID]: {
  69. asOfSeq: 20, values: key === 'included' ? { inbox: stale } : {},
  70. } } },
  71. })
  72. expect(face.getSnapshot()).toEqual(empty)
  73. } finally {
  74. list.resolve(result)
  75. await refreshed
  76. await manager.dispose()
  77. }
  78. },
  79. )
  80. it.for(['control-first', 'list-first'] as const)(
  81. 'replaces cold Session Inbox values across Host generations (%s)',
  82. async (order, { remote }) => {
  83. const list = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
  84. remote.session.list.mockReturnValue(list.promise)
  85. const manager = makeManager(remote)
  86. const hiddenSessionId = 'cold-hidden-inbox' as SessionId
  87. const ghost = message('ghost', 'acceptance was not persisted')
  88. const pending = message('pending', 'claim was not persisted')
  89. const empty = { 'next-turn': [], 'next-step': [] }
  90. const restored = { 'next-turn': [pending], 'next-step': [] }
  91. manager.handleControlFrame({ ...inboxFrame({ ...empty, 'next-turn': [ghost] }), seq: 20 })
  92. manager.handleControlFrame({ ...inboxFrame(empty), sessionId: hiddenSessionId, seq: 20 })
  93. const face = manager.get(SID).projections.faceOf('inbox')
  94. const baseline = { type: 'baseline', value: { jobs: {}, projections: {} } } as const
  95. const result = ok({ items: [
  96. { sessionId: SID, updatedAt: 1, running: false, blank: false,
  97. projections: { asOfSeq: 1, values: { inbox: empty } } },
  98. { sessionId: hiddenSessionId, updatedAt: 1, running: false, blank: false,
  99. projections: { asOfSeq: 1, values: { inbox: restored } } },
  100. ] })
  101. let refreshed: Promise<void> | undefined
  102. try {
  103. manager.handleConnected()
  104. refreshed = manager.refreshList()
  105. expect(face.getSnapshot()).toBeUndefined()
  106. if (order === 'control-first') manager.handleControlFrame(baseline)
  107. list.resolve(result)
  108. await refreshed
  109. if (order === 'list-first') manager.handleControlFrame(baseline)
  110. expect(manager.get(SID).projections.faceOf('inbox')).toBe(face)
  111. expect(face.getSnapshot()).toEqual(empty)
  112. expect(manager.get(hiddenSessionId).projections.faceOf('inbox').getSnapshot()).toEqual(restored)
  113. } finally {
  114. list.resolve(result)
  115. await refreshed
  116. await manager.dispose()
  117. }
  118. },
  119. )
  120. it('retains only the highest-seq value received before Session materialization', ({ remote }) => {
  121. const manager = makeManager(remote)
  122. manager.handleControlFrame(inboxFrame({
  123. 'next-turn': [message('old', 'old')],
  124. 'next-step': [],
  125. }))
  126. const latest = {
  127. 'next-turn': [message('latest', 'latest')],
  128. 'next-step': [],
  129. }
  130. manager.handleControlFrame(inboxFrame(latest))
  131. expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toEqual(latest)
  132. })
  133. })
  134. describe('queue operation transport', () => {
  135. it('does not mutate the Inbox projection before the Host publishes its committed value', async ({ remote }) => {
  136. remote.session.updateQueue.mockResolvedValue(ok({ accepted: true }))
  137. const manager = makeManager(remote)
  138. const pending = message('pending', 'before')
  139. const initial = { 'next-turn': [pending], 'next-step': [] }
  140. manager.handleControlFrame(inboxFrame(initial))
  141. const session = manager.get(SID)
  142. await expect(session.updateQueue(pending.id, { kind: 'edit', content: text('after') }))
  143. .resolves.toEqual({ ok: true, value: { accepted: true } })
  144. expect(remote.session.updateQueue).toHaveBeenCalledExactlyOnceWith({
  145. sessionId: SID,
  146. itemId: pending.id,
  147. action: { kind: 'edit', content: text('after') },
  148. })
  149. expect(session.projections.faceOf('inbox').getSnapshot()).toBe(initial)
  150. })
  151. })