ui-session.client.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. import { Context } from '@deepseek-ai/cordis'
  2. import InvariantRegistry from '@deepseek-ai/dsh-invariants'
  3. import type {
  4. AgentContext,
  5. ISessions,
  6. SessionBinding,
  7. SessionListState,
  8. SessionSnapshot,
  9. } from '@deepseek-ai/dsh-api-session-controller/client'
  10. import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
  11. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  12. import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
  13. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  14. import { Fragment } from 'react'
  15. import { afterEach, describe, expect, it, vi } from 'vitest'
  16. import {
  17. apply,
  18. type SessionPendingInteractionBase,
  19. UiSession,
  20. } from '../src/client/index.ts'
  21. import { apply as nodeApply } from '../src/index.ts'
  22. import * as SessionInvariant from '../src/invariant.ts'
  23. interface SessionsBench {
  24. readonly sessions: ISessions
  25. readonly list: ReturnType<typeof createSnapshotStore<SessionListState>>
  26. readonly resolveBinding: ReturnType<typeof vi.fn<(id: SessionId) => SessionBinding | undefined>>
  27. readonly createSession: ReturnType<typeof vi.fn<ISessions['create']>>
  28. readonly openSession: ReturnType<typeof vi.fn<(id: SessionId) => void>>
  29. readonly clearSession: ReturnType<typeof vi.fn<() => void>>
  30. binding(id: SessionId): SessionBinding
  31. select(id: SessionId | undefined): void
  32. release(id: SessionId): Promise<void>
  33. }
  34. const sessionId = (value: string): SessionId => value as SessionId
  35. function createSessionsBench(_ctx: Context): SessionsBench {
  36. const list = createSnapshotStore<SessionListState>({
  37. ids: [],
  38. byId: {},
  39. current: undefined,
  40. phase: 'ready',
  41. subagentsByParent: {},
  42. jobsBySession: {},
  43. currentAddress: undefined,
  44. })
  45. const bindings = new Map<SessionId, SessionBinding>()
  46. const scopes = new Map<SessionId, Context>()
  47. const resolveBinding = vi.fn((id: SessionId) => bindings.get(id))
  48. const createSession = vi.fn<ISessions['create']>(async options =>
  49. options?.sessionId ?? sessionId(`created-${String(options?.workspaceId ?? 'none')}`))
  50. const openSession = vi.fn((id: SessionId) => {
  51. list.update((draft) => { draft.current = id })
  52. })
  53. const clearSession = vi.fn(() => {
  54. list.update((draft) => { draft.current = undefined })
  55. })
  56. const sessions = {
  57. list,
  58. create: createSession,
  59. open: openSession,
  60. clear: clearSession,
  61. binding: resolveBinding,
  62. } as unknown as ISessions
  63. return {
  64. sessions,
  65. list,
  66. resolveBinding,
  67. createSession,
  68. openSession,
  69. clearSession,
  70. binding(id) {
  71. const scopeCtx = new Context()
  72. const snapshot = createSnapshotStore<SessionSnapshot>({
  73. sessionId: id,
  74. queue: [],
  75. running: false,
  76. subagent: null,
  77. removed: false,
  78. openState: 'open',
  79. openError: null,
  80. hasMore: false,
  81. loadingOlder: false,
  82. promptError: null,
  83. blank: false,
  84. lastAgentError: null,
  85. promptAttempted: false,
  86. awaitingFirstTurn: false,
  87. })
  88. const projections = new Map<string, HostObservable<unknown>>()
  89. const session = {
  90. sessionId: id,
  91. projections: {
  92. faceOf(key: string) {
  93. let source = projections.get(key)
  94. if (source === undefined) {
  95. source = createSnapshotStore<unknown>(undefined)
  96. projections.set(key, source)
  97. }
  98. return source
  99. },
  100. },
  101. getSnapshot: () => snapshot.getSnapshot(),
  102. subscribe: (listener: () => void) => snapshot.subscribe(listener),
  103. } as unknown as SessionBinding['session']
  104. const binding: SessionBinding = {
  105. sessionId: id,
  106. session,
  107. eventSource: new MutableSessionEventSource(),
  108. ctx: scopeCtx as AgentContext,
  109. }
  110. bindings.set(id, binding)
  111. scopes.set(id, scopeCtx)
  112. list.update((draft) => {
  113. if (!draft.ids.includes(id)) draft.ids.push(id)
  114. draft.byId[id] = {
  115. id,
  116. displayTitle: id,
  117. running: false,
  118. blank: false,
  119. updatedAt: 1,
  120. }
  121. })
  122. return binding
  123. },
  124. select(id) {
  125. list.update((draft) => { draft.current = id })
  126. },
  127. async release(id) {
  128. bindings.delete(id)
  129. const scopeCtx = scopes.get(id)
  130. scopes.delete(id)
  131. await scopeCtx?.fiber.dispose()
  132. },
  133. }
  134. }
  135. function createUiSession(ctx: Context, bench: SessionsBench): UiSession {
  136. ctx.provide('slots', { bindStoreScope: vi.fn() } as never)
  137. return new UiSession(ctx, bench.sessions)
  138. }
  139. afterEach(() => {
  140. vi.restoreAllMocks()
  141. })
  142. describe('UiSession bindings', () => {
  143. it('binds each materialized Session to renderer-owned Store cleanup', () => {
  144. const ctx = new Context()
  145. const bench = createSessionsBench(ctx)
  146. const bindStoreScope = vi.fn()
  147. ctx.provide('slots', { bindStoreScope } as never)
  148. const service = new UiSession(ctx, bench.sessions)
  149. const binding = bench.binding(sessionId('s1'))
  150. const materialized = service.adapter.resolve(binding.sessionId)
  151. expect(bindStoreScope).toHaveBeenCalledOnce()
  152. expect(bindStoreScope).toHaveBeenCalledWith(materialized)
  153. })
  154. it('materializes built-in sources, caches a binding, and publishes selection and release', async () => {
  155. const ctx = new Context()
  156. const bench = createSessionsBench(ctx)
  157. const service = createUiSession(ctx, bench)
  158. const id = sessionId('s1')
  159. const binding = bench.binding(id)
  160. const current = vi.fn()
  161. const offCurrent = service.adapter.current.subscribe(current)
  162. expect(service.adapter.current.getSnapshot()).toEqual({
  163. key: undefined,
  164. hooks: { session: undefined },
  165. keyedHooks: { projection: undefined },
  166. props: { sessionId: undefined },
  167. })
  168. expect(service.adapter.resolve('missing')).toBeUndefined()
  169. const first = service.adapter.resolve(id)!
  170. expect(service.adapter.resolve(id)).toBe(first)
  171. expect(first.key).toBe(id)
  172. expect(first.hooks.session).toBe(binding.session)
  173. expect(first.props.sessionId).toBe(id)
  174. expect(first.keyedHooks.projection?.('status'))
  175. .toBe(binding.session.projections.faceOf('status'))
  176. bench.select(id)
  177. expect(current).toHaveBeenCalledTimes(1)
  178. expect(service.adapter.current.getSnapshot()).toBe(first)
  179. bench.select(id)
  180. expect(current).toHaveBeenCalledTimes(1)
  181. bench.resolveBinding.mockClear()
  182. await bench.release(id)
  183. expect(bench.resolveBinding).not.toHaveBeenCalled()
  184. expect(current).toHaveBeenCalledTimes(2)
  185. expect(service.adapter.current.getSnapshot().key).toBeUndefined()
  186. const other = sessionId('s2')
  187. bench.binding(other)
  188. service.adapter.resolve(other)
  189. bench.resolveBinding.mockClear()
  190. await bench.release(other)
  191. expect(bench.resolveBinding).not.toHaveBeenCalled()
  192. expect(current).toHaveBeenCalledTimes(2)
  193. offCurrent()
  194. await ctx.fiber.dispose()
  195. })
  196. it('renders the empty area and a Session-keyed selected area', () => {
  197. const ctx = new Context()
  198. const bench = createSessionsBench(ctx)
  199. const service = createUiSession(ctx, bench)
  200. const empty = vi.fn(() => 'empty')
  201. const children = 'session body'
  202. if (service.adapter.renderArea === undefined) throw new Error('Session area renderer was not installed')
  203. const emptyArea = service.adapter.renderArea(
  204. service.adapter.current.getSnapshot(),
  205. { empty, children },
  206. )
  207. expect(emptyArea).toMatchObject({
  208. type: Fragment,
  209. key: null,
  210. props: { children: 'empty' },
  211. })
  212. expect(empty).toHaveBeenCalledOnce()
  213. const defaultEmptyArea = service.adapter.renderArea(
  214. service.adapter.current.getSnapshot(),
  215. { children },
  216. )
  217. expect(defaultEmptyArea).toMatchObject({
  218. type: Fragment,
  219. key: null,
  220. props: { children: null },
  221. })
  222. const id = sessionId('s1')
  223. bench.binding(id)
  224. bench.select(id)
  225. const selectedArea = service.adapter.renderArea(
  226. service.adapter.current.getSnapshot(),
  227. { empty, children },
  228. )
  229. expect(selectedArea).toMatchObject({
  230. type: Fragment,
  231. key: id,
  232. props: { children },
  233. })
  234. expect(empty).toHaveBeenCalledOnce()
  235. })
  236. it('contains a failing current-binding subscriber and continues dispatch', () => {
  237. const ctx = new Context()
  238. const bench = createSessionsBench(ctx)
  239. const service = createUiSession(ctx, bench)
  240. const id = sessionId('s1')
  241. bench.binding(id)
  242. const failure = new Error('subscriber failed')
  243. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  244. service.adapter.current.subscribe(() => { throw failure })
  245. const after = vi.fn()
  246. service.adapter.current.subscribe(after)
  247. bench.select(id)
  248. expect(after).toHaveBeenCalledOnce()
  249. expect(report).toHaveBeenCalledWith(
  250. '[ui-session] current binding subscriber failed:',
  251. failure,
  252. )
  253. })
  254. it('releases cached bindings when the owning Client context stops', async () => {
  255. const ctx = new Context()
  256. const bench = createSessionsBench(ctx)
  257. const service = createUiSession(ctx, bench)
  258. const id = sessionId('s1')
  259. bench.binding(id)
  260. bench.select(id)
  261. service.adapter.current.getSnapshot()
  262. await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
  263. await bench.release(id)
  264. })
  265. it('rebuilds live bindings and removes only the disposed source contribution', () => {
  266. const ctx = new Context()
  267. const bench = createSessionsBench(ctx)
  268. const service = createUiSession(ctx, bench)
  269. const id = sessionId('s1')
  270. bench.binding(id)
  271. bench.select(id)
  272. const custom = createSnapshotStore({ value: 1 })
  273. const keyed = (key: string): HostObservable<unknown> => createSnapshotStore(key)
  274. const dispose = service.provide({
  275. hooks: ['custom'],
  276. keyedHooks: ['customKeyed'],
  277. props: ['customProp'],
  278. resolve: () => ({
  279. hooks: { custom },
  280. keyedHooks: { customKeyed: keyed },
  281. props: { customProp: 'value' },
  282. }),
  283. })
  284. const disposeNeighbor = service.provide({
  285. props: ['neighborProp'],
  286. resolve: () => ({ props: { neighborProp: 'neighbor' } }),
  287. })
  288. const contributed = service.adapter.current.getSnapshot()
  289. expect(contributed.hooks.custom).toBe(custom)
  290. expect(contributed.keyedHooks.customKeyed).toBe(keyed)
  291. expect(contributed.props.customProp).toBe('value')
  292. expect(contributed.props.neighborProp).toBe('neighbor')
  293. dispose()
  294. const restored = service.adapter.current.getSnapshot()
  295. expect(restored.hooks.session).toBeDefined()
  296. expect(typeof restored.keyedHooks.projection).toBe('function')
  297. expect(restored.props.sessionId).toBe(id)
  298. expect(restored.hooks).not.toHaveProperty('custom')
  299. expect(restored.props.neighborProp).toBe('neighbor')
  300. dispose()
  301. expect(service.adapter.current.getSnapshot().props.neighborProp).toBe('neighbor')
  302. disposeNeighbor()
  303. expect(service.adapter.current.getSnapshot().props).not.toHaveProperty('neighborProp')
  304. })
  305. it.each([
  306. ['hook', { resolve: () => ({ hooks: { surprise: createSnapshotStore(1) } }) }],
  307. ['keyed hook', { resolve: () => ({ keyedHooks: { surprise: () => createSnapshotStore(1) } }) }],
  308. ['prop', { resolve: () => ({ props: { surprise: 1 } }) }],
  309. ] as const)('rejects an undeclared %s returned by a contribution', (kind, descriptor) => {
  310. const ctx = new Context()
  311. const bench = createSessionsBench(ctx)
  312. const service = createUiSession(ctx, bench)
  313. service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
  314. expect(() => { service.provide(descriptor as never) })
  315. .toThrow(`uiSession.provide: undeclared ${kind} 'surprise'`)
  316. })
  317. it.each([
  318. ['hook', { hooks: ['missing'], resolve: () => ({}) }],
  319. ['keyed hook', { keyedHooks: ['missing'], resolve: () => ({}) }],
  320. ['prop', { props: ['missing'], resolve: () => ({}) }],
  321. ] as const)('rejects a missing declared %s', (kind, descriptor) => {
  322. const ctx = new Context()
  323. const bench = createSessionsBench(ctx)
  324. const service = createUiSession(ctx, bench)
  325. service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
  326. expect(() => { service.provide(descriptor) })
  327. .toThrow(`uiSession.provide: missing ${kind} 'missing'`)
  328. })
  329. it.each([
  330. ['hook', { hooks: ['session'], resolve: () => ({ hooks: { session: createSnapshotStore(1) } }) }],
  331. ['keyed hook', {
  332. keyedHooks: ['projection'],
  333. resolve: () => ({ keyedHooks: { projection: () => createSnapshotStore(1) } }),
  334. }],
  335. ['prop', { props: ['sessionId'], resolve: () => ({ props: { sessionId: 'other' } }) }],
  336. ] as const)('rejects a duplicate declared %s', (kind, descriptor) => {
  337. const ctx = new Context()
  338. const bench = createSessionsBench(ctx)
  339. const service = createUiSession(ctx, bench)
  340. expect(() => { service.provide(descriptor) })
  341. .toThrow(`uiSession.provide: duplicate ${kind}`)
  342. })
  343. it('rejects cross-compartment collisions at the final standard prop name', () => {
  344. const ctx = new Context()
  345. const bench = createSessionsBench(ctx)
  346. const service = createUiSession(ctx, bench)
  347. const source = createSnapshotStore(1)
  348. service.provide({
  349. hooks: ['feature'],
  350. resolve: () => ({ hooks: { feature: source } }),
  351. })
  352. const before = service.adapter.current.getSnapshot()
  353. expect(() => service.provide({
  354. keyedHooks: ['feature'],
  355. resolve: () => ({ keyedHooks: { feature: () => source } }),
  356. })).toThrow("uiSession.provide: duplicate keyed hook 'feature' at prop 'useFeature'")
  357. expect(() => service.provide({
  358. props: ['useFeature'],
  359. resolve: () => ({ props: { useFeature: true } }),
  360. })).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'")
  361. expect(service.adapter.current.getSnapshot()).toBe(before)
  362. })
  363. it('releases partially rebuilt bindings when a later Session contribution fails', () => {
  364. const ctx = new Context()
  365. const bench = createSessionsBench(ctx)
  366. const service = createUiSession(ctx, bench)
  367. service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
  368. service.adapter.resolve(bench.binding(sessionId('s2')).sessionId)
  369. let calls = 0
  370. expect(() => service.provide({
  371. props: ['partial'],
  372. resolve: () => {
  373. calls += 1
  374. if (calls === 2) throw new Error('second binding failed')
  375. return { props: { partial: true } }
  376. },
  377. })).toThrow('second binding failed')
  378. expect(calls).toBe(2)
  379. expect(service.adapter.resolve(sessionId('s1'))?.props).not.toHaveProperty('partial')
  380. })
  381. })
  382. describe('UiSession pending interactions', () => {
  383. it('publishes the highest-precedence exact object and removes each source independently', async () => {
  384. const ctx = new Context()
  385. const bench = createSessionsBench(ctx)
  386. const service = createUiSession(ctx, bench)
  387. const id = sessionId('s1')
  388. const listener = vi.fn()
  389. const off = service.pendingInteractions.subscribe(listener)
  390. const registerApproval = service.registerPendingInteraction<SessionPendingInteractionBase>(
  391. () => 0,
  392. )
  393. const registerQuestion = service.registerPendingInteraction<SessionPendingInteractionBase>(
  394. interaction => interaction.kind === 'plan-review' ? 2 : 1,
  395. )
  396. const registerBackground = service.registerPendingInteraction<SessionPendingInteractionBase>(
  397. () => -1,
  398. )
  399. listener.mockClear()
  400. const approval = { key: 'approval:1', kind: 'approval', sessionId: id }
  401. const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id }
  402. const question = { key: 'question:1', kind: 'question', sessionId: id }
  403. const plan = { key: 'question:2', kind: 'plan-review', sessionId: id }
  404. const background = { key: 'background:1', kind: 'background', sessionId: id }
  405. const delegate = (): Promise<void> => Promise.resolve()
  406. const removeApproval = registerApproval(approval, delegate)
  407. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
  408. const removeDuplicate = registerApproval(duplicate, delegate)
  409. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
  410. const removeQuestion = registerQuestion(question, delegate)
  411. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question)
  412. const removePlan = registerQuestion(plan, delegate)
  413. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
  414. const removeBackground = registerBackground(background, delegate)
  415. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
  416. removeBackground()
  417. removeQuestion()
  418. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
  419. removePlan()
  420. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
  421. removeDuplicate()
  422. expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
  423. removeApproval()
  424. removeApproval()
  425. expect(service.pendingInteractions.getSnapshot().has(id)).toBe(false)
  426. off()
  427. await ctx.fiber.dispose()
  428. })
  429. it('rejects duplicate keys and contains a failing aggregate subscriber', () => {
  430. const ctx = new Context()
  431. const bench = createSessionsBench(ctx)
  432. const service = createUiSession(ctx, bench)
  433. const registerPendingInteraction = service.registerPendingInteraction<SessionPendingInteractionBase>(
  434. () => 1,
  435. )
  436. const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') }
  437. const delegate = () => Promise.resolve()
  438. const remove = registerPendingInteraction(interaction, delegate)
  439. expect(() => { registerPendingInteraction(interaction, delegate) })
  440. .toThrow("ui-session: duplicate pending interaction key 'question:1'")
  441. const failure = new Error('pending subscriber failed')
  442. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  443. service.pendingInteractions.subscribe(() => { throw failure })
  444. const after = vi.fn()
  445. service.pendingInteractions.subscribe(after)
  446. remove()
  447. expect(after).toHaveBeenCalledOnce()
  448. expect(report).toHaveBeenCalledWith(
  449. '[ui-session] pending interactions subscriber failed:',
  450. failure,
  451. )
  452. })
  453. it('removes active values before awaiting their teardown delegation', async () => {
  454. const ctx = new Context()
  455. const bench = createSessionsBench(ctx)
  456. const service = createUiSession(ctx, bench)
  457. const gate = Promise.withResolvers<undefined>()
  458. const delegate = vi.fn(() => gate.promise)
  459. const publish = service.registerPendingInteraction<SessionPendingInteractionBase>(() => 1)
  460. const remove = publish(
  461. { key: 'question:1', kind: 'question', sessionId: sessionId('s1') },
  462. delegate,
  463. )
  464. let disposed = false
  465. const disposal = ctx.fiber.dispose().then(() => { disposed = true })
  466. await vi.waitFor(() => { expect(delegate).toHaveBeenCalledOnce() })
  467. expect(service.pendingInteractions.getSnapshot()).toEqual(new Map())
  468. expect(disposed).toBe(false)
  469. remove()
  470. remove()
  471. gate.resolve(undefined)
  472. await disposal
  473. expect(disposed).toBe(true)
  474. })
  475. })
  476. describe('ui-session apply', () => {
  477. it('provides the root sources and installs the Session scope adapter', () => {
  478. const ctx = new Context()
  479. const bench = createSessionsBench(ctx)
  480. const slots = {
  481. provideRoot: vi.fn(),
  482. installScope: vi.fn(),
  483. }
  484. ctx.provide('sessions', bench.sessions)
  485. ctx.provide('slots', slots as never)
  486. apply(ctx)
  487. expect(ctx.uiSession).toBeInstanceOf(UiSession)
  488. expect(slots.provideRoot).toHaveBeenCalledWith({
  489. hooks: {
  490. sessions: bench.sessions.list,
  491. sessionPendingInteraction: ctx.uiSession.pendingInteractions,
  492. },
  493. })
  494. expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter)
  495. })
  496. it('keeps the Host loader half inert and registers the invariant companion', async () => {
  497. expect(() => { nodeApply() }).not.toThrow()
  498. const ctx = new Context()
  499. await ctx.plugin(InvariantRegistry, { enabled: true })
  500. await expect(ctx.plugin(SessionInvariant).await()).resolves.toBeDefined()
  501. })
  502. })