client-apply.client.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. /**
  2. * Session Controller Client apply inside the assembled client: Remote events
  3. * arriving as emit frames on the `$events` stream, the control stream over
  4. * the real Connection, and Agent Context identity through the Typert registry.
  5. */
  6. import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
  7. import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
  8. import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
  9. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  10. import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  11. import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
  12. import { afterEach, describe, expect, vi, type MockInstance } from 'vitest'
  13. import { ClientSessions } from '../src/client/sessions/service.ts'
  14. import type { SessionListValue } from '../src/types.ts'
  15. const SELF = '@deepseek-ai/dsh-api-session-controller'
  16. const ROSTER = webApp.closure([SELF])
  17. const it = createClientTest({ roster: ROSTER })
  18. const EVENTS = '$events'
  19. const CONTROL = 'session/control'
  20. const BASELINE = { type: 'baseline', value: { jobs: {}, projections: {} } }
  21. /** The first client boot pays the cold module transform of the cone. */
  22. const COLD_BOOT_TIMEOUT_MS = 60_000
  23. const sid = (value: string): SessionId => value as SessionId
  24. afterEach(() => {
  25. vi.restoreAllMocks()
  26. })
  27. async function bench(start: () => Promise<TestClient>) {
  28. const client = await start()
  29. return { client, sessions: client.ctx.sessions as ClientSessions }
  30. }
  31. /** Deliver one Remote event the way the Host forwards it: an emit frame on the `$events` stream, consumed by the client. */
  32. async function emit(mock: RemoteMock, event: string, ...args: unknown[]): Promise<void> {
  33. mock.streams.push(EVENTS, { type: 'emit', event, args })
  34. await mock.streams.drained(EVENTS)
  35. }
  36. function baselines(accept: MockInstance): number {
  37. return accept.mock.calls.filter(([frame]) => (frame as { type: string }).type === 'baseline').length
  38. }
  39. describe('Session Controller Client apply', () => {
  40. it('routes Remote events from the $events stream into the object layer and runs handleConnected once per generation', async ({ mock, start }) => {
  41. const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
  42. const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError')
  43. const { client, sessions } = await bench(start)
  44. // The first generation's `connection/reset` already ran it; apply itself saw no Host yet.
  45. await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() })
  46. await emit(mock, 'api-session/added', { sessionId: sid('session-1'), updatedAt: 1, running: false, blank: true })
  47. await vi.waitFor(() => {
  48. expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: false, updatedAt: 1 })
  49. })
  50. await emit(mock, 'api-session/status', sid('session-1'), true)
  51. await emit(mock, 'api-session/activity', sid('session-1'), 9)
  52. await emit(mock, 'api-session/error', sid('session-1'), 'agent failed')
  53. await vi.waitFor(() => {
  54. expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: true, updatedAt: 9 })
  55. })
  56. expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed')
  57. await emit(mock, 'api-session/removed', sid('session-1'))
  58. await vi.waitFor(() => { expect(sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() })
  59. client.connection.reconnect()
  60. await mock.streams.opened(EVENTS, 2)
  61. await vi.waitFor(() => { expect(connected).toHaveBeenCalledTimes(2) })
  62. }, COLD_BOOT_TIMEOUT_MS)
  63. it('runs handleConnected at apply when the Host is already connected, as a reload of the row does', async ({ start }) => {
  64. const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
  65. const { client } = await bench(start)
  66. await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() })
  67. await client.reload(SELF)
  68. expect(connected).toHaveBeenCalledTimes(2)
  69. })
  70. it('keeps immediate control projections when the ready notification follows their baseline', async ({ mock, start }) => {
  71. const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
  72. const sessionId = sid('immediate-baseline')
  73. mock.remote.session.list.mockResolvedValue(ok({ items: [{
  74. sessionId, updatedAt: 1, running: false, blank: false,
  75. }] }))
  76. let projection = { asOfSeq: 20, values: { title: 'Before restart' } }
  77. mock.stream(CONTROL, (_args, stream) => {
  78. stream.push({ type: 'baseline', value: { jobs: {}, projections: { [sessionId]: projection } } })
  79. })
  80. const { client, sessions } = await bench(start)
  81. await vi.waitFor(() => {
  82. expect(sessions.list.getSnapshot().byId[sessionId]?.title).toBe('Before restart')
  83. })
  84. client.ctx.emit('connection/reset')
  85. await client.flush()
  86. expect(sessions.list.getSnapshot().byId[sessionId]?.title).toBe('Before restart')
  87. projection = { asOfSeq: 1, values: { title: 'After restart' } }
  88. client.connection.reconnect()
  89. await vi.waitFor(() => {
  90. expect(sessions.list.getSnapshot().byId[sessionId]?.title).toBe('After restart')
  91. })
  92. await client.unload(SELF)
  93. client.connection.reconnect()
  94. await mock.streams.opened(EVENTS, 3)
  95. await vi.waitFor(() => { expect(client.connection.generation.getSnapshot()?.id).toBe(3) })
  96. expect(connected).toHaveBeenCalledTimes(2)
  97. })
  98. it('accepts the control baseline, retries a carrier loss once, and reports a second opening snapshot as a protocol failure', async ({ mock, start }) => {
  99. const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
  100. const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
  101. await start()
  102. await vi.waitFor(() => { expect(baselines(accept)).toBe(1) })
  103. expect(accept).toHaveBeenCalledWith(BASELINE)
  104. // One immediate retry while the Host is available reopens the stream, whose script pushes the baseline again.
  105. mock.streams.fail(CONTROL, new RemoteStreamCarrierError('generation lost'))
  106. await vi.waitFor(() => { expect(baselines(accept)).toBe(2) })
  107. expect(mock.log.streams(CONTROL)).toHaveLength(2)
  108. mock.streams.push(CONTROL, BASELINE)
  109. await vi.waitFor(() => {
  110. expect(logged).toHaveBeenCalledWith(
  111. '[session-controller] control stream failed:',
  112. expect.objectContaining({ message: 'session control stream emitted more than one opening snapshot' }),
  113. )
  114. })
  115. })
  116. it('materializes Host-addressed Agent scopes before the Session list arrives', async ({ mock, start }) => {
  117. const list = Promise.withResolvers<RemoteResult<SessionListValue>>()
  118. mock.remote.session.list.mockReturnValueOnce(list.promise)
  119. const { client, sessions } = await bench(start)
  120. const adapter = client.ctx.typert.contexts.getClient('agent')
  121. const first = adapter?.resolve(sid('agent-early'))
  122. const second = adapter?.resolve(sid('agent-early'))
  123. if (!isTypertOwnedValue(first) || !isTypertOwnedValue(second)) throw new Error('expected owned Contexts')
  124. using firstOwner = first
  125. using secondOwner = second
  126. expect(sessions.scopeOf(firstOwner.value)).toBe(sid('agent-early'))
  127. expect(secondOwner.value).toBe(firstOwner.value)
  128. expect(sessions.retainInfo(sid('agent-early')).getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { gateway: 2 } })
  129. expect(mock.log.requests('session/follow')).toHaveLength(0)
  130. list.resolve(ok({ items: [] }))
  131. await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
  132. expect(sessions.scope(sid('agent-early'))).toBe(firstOwner.value)
  133. })
  134. it('projects Agent Context identity in both directions and withdraws the adapter when the row unloads', async ({ mock, start }) => {
  135. const { client, sessions } = await bench(start)
  136. await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
  137. await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true })
  138. expect(sessions.scope(sid('agent-1'))).toBeUndefined()
  139. using reference = sessions.retainAgentScope(sid('agent-1'))
  140. const scoped = reference.binding.ctx
  141. const adapter = client.ctx.typert.contexts.getClient('agent')
  142. expect(adapter?.identity(client.ctx)).toBeUndefined()
  143. expect(adapter?.identity(scoped)).toBe(sid('agent-1'))
  144. const resolved = adapter?.resolve(sid('agent-1'))
  145. if (!isTypertOwnedValue(resolved)) throw new Error('expected invocation ownership')
  146. using invocation = resolved
  147. expect(invocation.value).toBe(scoped)
  148. await client.unload(SELF)
  149. expect(client.ctx.typert.contexts.getClient('agent')).toBeUndefined()
  150. })
  151. it('waits for a Host generation before retrying the control stream', async ({ mock, start }) => {
  152. const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
  153. const hostBack = Promise.withResolvers<undefined>()
  154. let opens = 0
  155. // The second $events generation stays unready until the test lets the Host answer.
  156. mock.stream(EVENTS, (_args, stream) => {
  157. opens += 1
  158. const ready = { type: 'ready', clientId: `mock-client-${String(opens)}`, host: { home: '/home/mock' } }
  159. if (opens === 1) stream.push(ready)
  160. else void hostBack.promise.then(() => { stream.push(ready) })
  161. })
  162. const client = await start()
  163. await vi.waitFor(() => { expect(baselines(accept)).toBe(1) })
  164. client.connection.reconnect()
  165. await mock.streams.opened(EVENTS, 2)
  166. expect(client.connection.generation.getSnapshot()).toBeUndefined()
  167. mock.streams.fail(CONTROL, new RemoteStreamCarrierError('offline'))
  168. await client.flush()
  169. expect(baselines(accept)).toBe(1)
  170. expect(mock.log.streams(CONTROL)).toHaveLength(1)
  171. hostBack.resolve(undefined)
  172. await vi.waitFor(() => { expect(baselines(accept)).toBe(2) })
  173. expect(client.connection.generation.getSnapshot()).toMatchObject({ id: 2 })
  174. })
  175. })