client-apply.client.spec.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { Context } from '@deepseek-ai/cordis'
  2. import type { Fiber } from '@deepseek-ai/cordis'
  3. import type {
  4. ConnectionGeneration,
  5. ConnectionHandle,
  6. } from '@deepseek-ai/dsh-client-connection/client'
  7. import {
  8. RemoteStreamCarrierError,
  9. RemoteStream,
  10. type RemoteStreamOptions,
  11. } from '@deepseek-ai/dsh-api-gateway/client'
  12. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  13. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  14. import { afterEach, describe, expect, it, vi } from 'vitest'
  15. import * as SessionClient from '../src/client/index.ts'
  16. import { ClientSessions } from '../src/client/sessions/service.ts'
  17. import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
  18. const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } }
  19. const sid = (value: string): SessionId => value as SessionId
  20. type RemoteListener = (...args: never[]) => void
  21. interface Bench {
  22. readonly ctx: Context
  23. readonly api: FakeApiClient
  24. readonly fiber: Fiber
  25. readonly sessions: ClientSessions
  26. dispatch(event: string, ...args: unknown[]): void
  27. publishGeneration(generation: ConnectionGeneration | undefined): void
  28. }
  29. const contexts = new Set<Context>()
  30. afterEach(async () => {
  31. vi.restoreAllMocks()
  32. await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() }))
  33. contexts.clear()
  34. })
  35. async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
  36. const ctx = new Context()
  37. contexts.add(ctx)
  38. await ctx.plugin(TypertRegistry)
  39. const api = new FakeApiClient()
  40. const remote = fakeRemote(api)
  41. const listeners = new Map<string, Set<RemoteListener>>()
  42. const generationListeners = new Set<() => void>()
  43. let generation = initialGeneration
  44. const connection: ConnectionHandle = {
  45. isLoopback: true,
  46. generation: {
  47. getSnapshot: () => generation,
  48. subscribe: (listener) => {
  49. generationListeners.add(listener)
  50. return () => { generationListeners.delete(listener) }
  51. },
  52. },
  53. state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} },
  54. rpc: {
  55. call: () => Promise.reject(new Error('unexpected generic RPC call')),
  56. },
  57. reconnect: () => {},
  58. registerGenerationSource: () => () => {},
  59. start: () => ({ stop: () => {} }),
  60. }
  61. ctx.reflect.provide('remote', {
  62. ...remote,
  63. $stream: <Item>(options: RemoteStreamOptions<Item>) => (
  64. new RemoteStream(connection, options)
  65. ),
  66. get $host() {
  67. return { home: generation?.host.home, isLoopback: connection.isLoopback }
  68. },
  69. $on: (event: string, listener: RemoteListener) => {
  70. const eventListeners = listeners.get(event) ?? new Set<RemoteListener>()
  71. eventListeners.add(listener)
  72. listeners.set(event, eventListeners)
  73. return () => { eventListeners.delete(listener) }
  74. },
  75. })
  76. ctx.reflect.provide('remote.commands', remote.commands)
  77. ctx.reflect.provide('remote.session', remote.session)
  78. ctx.reflect.provide('remote.subagents', remote.subagents)
  79. const fiber = ctx.plugin(SessionClient)
  80. await fiber
  81. const sessions = ctx.sessions as ClientSessions
  82. return {
  83. ctx,
  84. api,
  85. fiber,
  86. sessions,
  87. dispatch: (event, ...args) => {
  88. for (const listener of listeners.get(event) ?? []) listener(...args as never[])
  89. },
  90. publishGeneration: (next) => {
  91. generation = next
  92. for (const listener of [...generationListeners]) listener()
  93. },
  94. }
  95. }
  96. async function flush(): Promise<void> {
  97. for (let index = 0; index < 12; index++) await Promise.resolve()
  98. }
  99. describe('Session Controller Client apply', () => {
  100. it('routes Session Remote Events and connection generations into the object layer', async () => {
  101. const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
  102. const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError')
  103. const bench = await mount()
  104. expect(connected).not.toHaveBeenCalled()
  105. bench.dispatch('api-session/added', {
  106. sessionId: sid('session-1'),
  107. updatedAt: 1,
  108. running: false,
  109. blank: true,
  110. })
  111. await flush()
  112. expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({
  113. running: false,
  114. updatedAt: 1,
  115. })
  116. bench.dispatch('api-session/status', sid('session-1'), true)
  117. bench.dispatch('api-session/activity', sid('session-1'), 9)
  118. bench.dispatch('api-session/error', sid('session-1'), 'agent failed')
  119. await flush()
  120. expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({
  121. running: true,
  122. updatedAt: 9,
  123. })
  124. expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed')
  125. bench.dispatch('api-session/removed', sid('session-1'))
  126. await flush()
  127. expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined()
  128. bench.ctx.emit('connection/reset')
  129. expect(connected).toHaveBeenCalledOnce()
  130. })
  131. it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => {
  132. const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
  133. const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
  134. const bench = await mount(GENERATION)
  135. await flush()
  136. expect(accept).toHaveBeenCalledWith({
  137. type: 'baseline',
  138. value: { queues: {}, jobs: {}, projections: {} },
  139. })
  140. bench.api.failStreams(new RemoteStreamCarrierError('generation lost'))
  141. await flush()
  142. expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
  143. bench.api.pushControl({ type: 'baseline', value: bench.api.controlBaseline } as never)
  144. await vi.waitFor(() => {
  145. expect(logged).toHaveBeenCalledWith(
  146. '[session-controller] control stream failed:',
  147. expect.objectContaining({ message: 'session control stream emitted more than one opening snapshot' }),
  148. )
  149. })
  150. })
  151. it('materializes Host-addressed Agent scopes before the Session list arrives', async () => {
  152. const bench = await mount()
  153. const adapter = bench.ctx.typert.contexts.getClient('agent')
  154. const first = adapter?.resolve(sid('agent-early'))
  155. expect(first).toBeDefined()
  156. expect(bench.sessions.scopeOf(first as Context)).toBe(sid('agent-early'))
  157. expect(adapter?.resolve(sid('agent-early'))).toBe(first)
  158. })
  159. it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => {
  160. const bench = await mount(GENERATION)
  161. await flush()
  162. expect(bench.sessions.list.getSnapshot().phase).toBe('ready')
  163. bench.dispatch('api-session/added', {
  164. sessionId: sid('agent-1'),
  165. updatedAt: 1,
  166. running: false,
  167. blank: true,
  168. })
  169. await flush()
  170. const scoped = bench.sessions.scope(sid('agent-1'))
  171. const adapter = bench.ctx.typert.contexts.getClient('agent')
  172. expect(scoped).toBeDefined()
  173. expect(adapter?.identity(bench.ctx)).toBeUndefined()
  174. expect(adapter?.identity(scoped!)).toBe(sid('agent-1'))
  175. expect(adapter?.resolve(sid('agent-1'))).toBe(scoped)
  176. await bench.fiber.dispose()
  177. expect(bench.ctx.typert.contexts.getClient('agent')).toBeUndefined()
  178. })
  179. it('waits for a Host generation before retrying the control stream', async () => {
  180. const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
  181. const bench = await mount()
  182. await flush()
  183. expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
  184. bench.api.failStreams(new RemoteStreamCarrierError('offline'))
  185. await flush()
  186. expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
  187. bench.publishGeneration(GENERATION)
  188. await flush()
  189. expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
  190. })
  191. })