ui-session.client.spec.ts 19 KB

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