ui-session.client.spec.ts 19 KB

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