client-apply.client.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. /**
  2. * Runtime plugin browser-half apply: slots + object services mounting over the
  3. * connection handle, Remote stream wiring into the object layer, and
  4. * fiber-scoped stream teardown.
  5. */
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { describe, expect, it, vi } from 'vitest'
  8. import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
  9. import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-api-session-controller/client'
  10. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  11. import * as RuntimeClient from '../src/client/index.ts'
  12. import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts'
  13. import { Session } from '../src/client/sessions/session.ts'
  14. import { SessionRuntime } from '../src/client/sessions/service.ts'
  15. import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
  16. interface Bench {
  17. ctx: Context
  18. api: FakeApiClient
  19. start: ReturnType<typeof vi.fn<ConnectionHandle['start']>>
  20. dispatchRemote(event: string, args: readonly unknown[]): void
  21. }
  22. async function mount(configure?: (api: FakeApiClient) => void): Promise<Bench> {
  23. const ctx = new Context()
  24. await ctx.plugin(TypertRegistry)
  25. const api = new FakeApiClient()
  26. configure?.(api)
  27. const listeners = new Map<string, Set<(...args: never[]) => void>>()
  28. const dispatchRemote = (event: string, args: readonly unknown[]): void => {
  29. for (const listener of listeners.get(event) ?? []) listener(...args as never[])
  30. }
  31. const start = vi.fn<ConnectionHandle['start']>(() => ({ stop: () => {} }))
  32. const bench: Bench = { ctx, api, start, dispatchRemote }
  33. const handle: ConnectionHandle = {
  34. api,
  35. isLoopback: true,
  36. hostDescription: {
  37. getSnapshot: () => undefined,
  38. subscribe: () => () => {},
  39. },
  40. rpc: {
  41. call: () => Promise.reject(new Error('unexpected generic RPC call')),
  42. },
  43. registerGenerationSource: () => () => {},
  44. start,
  45. }
  46. const remote = fakeRemote(api)
  47. ctx.reflect.provide('connection', handle)
  48. ctx.reflect.provide('remote', {
  49. ...remote,
  50. $on: (event: string, listener: (...args: never[]) => void) => {
  51. const eventListeners = listeners.get(event) ?? new Set()
  52. eventListeners.add(listener)
  53. listeners.set(event, eventListeners)
  54. return () => { eventListeners.delete(listener) }
  55. },
  56. })
  57. ctx.reflect.provide('remote.commands', remote.commands)
  58. ctx.reflect.provide('remote.session', remote.session)
  59. ctx.reflect.provide('remote.workspace', remote.workspace)
  60. await ctx.plugin(RuntimeClient).await()
  61. return bench
  62. }
  63. async function flushMicrotasks(): Promise<void> {
  64. for (let i = 0; i < 12; i++) await Promise.resolve()
  65. }
  66. describe('runtime client apply', () => {
  67. it('refreshes Sessions on every Gateway connection generation', async () => {
  68. const refresh = vi.spyOn(SessionRuntime.prototype, 'handleConnected')
  69. const bench = await mount()
  70. bench.ctx.emit('connection/reset')
  71. bench.ctx.emit('connection/reset')
  72. expect(refresh).toHaveBeenCalledTimes(2)
  73. refresh.mockRestore()
  74. })
  75. it('mounts slots, Sessions, and Workspaces and routes their independent streams', async () => {
  76. const bench = await mount()
  77. expect(bench.ctx.get('slots') !== undefined).toBe(true)
  78. // The built-in 'root' declaration ships with this package's SlotRegistry
  79. // (the SlotMap 'root' merge lives here).
  80. expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
  81. const sessions = bench.ctx.get('sessions')
  82. const workspaces = bench.ctx.get('workspaces')
  83. expect(sessions !== undefined).toBe(true)
  84. expect(workspaces !== undefined).toBe(true)
  85. // The bound the wire schema enforces, not a per-connection negotiation.
  86. expect((sessions as SessionRuntime).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
  87. if (workspaces === undefined) throw new Error('WorkspaceRuntime missing after runtime apply')
  88. expect(bench.start).not.toHaveBeenCalled()
  89. // Session Remote events reach the object layer and land in the list store.
  90. bench.dispatchRemote('api-session/added', [{
  91. sessionId: 's-new', updatedAt: 1, running: false, blank: true,
  92. }])
  93. await Promise.resolve()
  94. expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
  95. await flushMicrotasks()
  96. bench.api.pushWorkspace({
  97. type: 'upsert',
  98. workspace: {
  99. workspaceId: 'w-new' as never, path: '/w/new', title: 'new', sessionIds: [],
  100. createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  101. },
  102. })
  103. await flushMicrotasks()
  104. expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
  105. // Gateway generation publication routes without throwing.
  106. bench.ctx.emit('connection/reset')
  107. })
  108. it('selects the recent Workspace once when the first baselines have no current session', async () => {
  109. const bench = await mount((api) => {
  110. api.workspaceBaseline = {
  111. items: [{
  112. workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [],
  113. createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
  114. }] as never[],
  115. archivedSessionIds: [],
  116. }
  117. api.onList = () => Promise.resolve(ok({ items: [] }))
  118. })
  119. bench.ctx.emit('connection/reset')
  120. const sessions = bench.ctx.get('sessions') as SessionRuntime
  121. await vi.waitFor(() => {
  122. expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
  123. })
  124. expect(sessions.list.getSnapshot().current).toBe('fk-new')
  125. sessions.clear()
  126. bench.api.pushWorkspace({
  127. type: 'upsert',
  128. workspace: bench.api.workspaceBaseline.items[0] as never,
  129. })
  130. await flushMicrotasks()
  131. expect(sessions.list.getSnapshot().current).toBeUndefined()
  132. expect(bench.api.callsOf('session.create')).toHaveLength(1)
  133. })
  134. it('wires registry changes into resident Sessions during the runtime apply pass', async () => {
  135. const bench = await mount()
  136. const sessions = bench.ctx.get('sessions') as SessionRuntime
  137. bench.dispatchRemote('api-session/added', [{
  138. sessionId: 's-registry', updatedAt: 1, running: false, blank: true,
  139. }])
  140. await flushMicrotasks()
  141. expect(sessions.binding('s-registry' as never)).toBeDefined()
  142. const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
  143. const definition: ConversationNodeDefinition<null> = {
  144. kind: 'registry-probe',
  145. target: 'chat',
  146. match: () => null,
  147. start: () => null,
  148. update: context => context.state,
  149. buildViewNode: () => null,
  150. }
  151. bench.ctx.conversationEvents.register(definition)
  152. await flushMicrotasks()
  153. expect(rebuild).toHaveBeenCalledOnce()
  154. rebuild.mockRestore()
  155. })
  156. it('does not own the Connection loop and closes its Remote streams on unload', async () => {
  157. const bench = await mount()
  158. await bench.ctx.fiber.dispose()
  159. expect(bench.start).not.toHaveBeenCalled()
  160. })
  161. })