ui-session.client.spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. import { Context } from '@deepseek-ai/cordis'
  2. import type {
  3. AgentContext,
  4. ISessions,
  5. SessionBinding,
  6. SessionListState,
  7. SessionReference,
  8. SessionRetainInfo,
  9. SessionSnapshot,
  10. } from '@deepseek-ai/dsh-api-session-controller/client'
  11. import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
  12. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  13. import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots'
  14. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  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. interface SessionsBench {
  23. readonly sessions: ISessions
  24. readonly list: ReturnType<typeof createSnapshotStore<SessionListState>>
  25. readonly resolveBinding: ReturnType<typeof vi.fn<(id: SessionId) => SessionBinding | undefined>>
  26. readonly createSession: ReturnType<typeof vi.fn<ISessions['create']>>
  27. readonly retainInfo: ReturnType<typeof vi.fn<ISessions['retainInfo']>>
  28. binding(id: SessionId): SessionBinding
  29. reference(binding: SessionBinding): SessionReference
  30. setMainView(id: SessionId, count: number): void
  31. setRetainInfo(id: SessionId, count: number): void
  32. emitStatus(id: SessionId, running: boolean): void
  33. release(id: SessionId): Promise<void>
  34. }
  35. const sessionId = (value: string): SessionId => value as SessionId
  36. const roots: Context[] = []
  37. function createSessionsBench(ctx: Context): SessionsBench {
  38. roots.push(ctx)
  39. let statusListener: ((id: SessionId, running: boolean) => void) | undefined
  40. ctx.provide('remote', {
  41. $on: (_event: string, listener: (id: SessionId, running: boolean) => void) => {
  42. statusListener = listener
  43. return () => {
  44. if (statusListener === listener) statusListener = undefined
  45. }
  46. },
  47. } as never)
  48. const list = createSnapshotStore<SessionListState>({
  49. ids: [],
  50. byId: {},
  51. phase: 'ready',
  52. subagentsByParent: {},
  53. jobsBySession: {},
  54. })
  55. const bindings = new Map<SessionId, SessionBinding>()
  56. const scopes = new Map<SessionId, Context>()
  57. const retention = new Map<SessionId, ReturnType<typeof createSnapshotStore<SessionRetainInfo>>>()
  58. const retainSource = (id: SessionId): ReturnType<typeof createSnapshotStore<SessionRetainInfo>> => {
  59. let source = retention.get(id)
  60. if (source === undefined) {
  61. source = createSnapshotStore<SessionRetainInfo>({ referenceCount: 0, retainedBy: {} })
  62. retention.set(id, source)
  63. }
  64. return source
  65. }
  66. const resolveBinding = vi.fn((id: SessionId) => bindings.get(id))
  67. const createSession = vi.fn<ISessions['create']>(async options =>
  68. options?.sessionId ?? sessionId(`created-${String(options?.workspaceId ?? 'none')}`))
  69. const retainInfo = vi.fn<ISessions['retainInfo']>(id => retainSource(id))
  70. const sessions = {
  71. list,
  72. create: createSession,
  73. binding: resolveBinding,
  74. retainInfo,
  75. } as unknown as ISessions
  76. return {
  77. sessions,
  78. list,
  79. resolveBinding,
  80. createSession,
  81. retainInfo,
  82. binding(id) {
  83. const scopeCtx = new Context()
  84. ctx.effect(() => () => scopeCtx.fiber.dispose())
  85. const snapshot = createSnapshotStore<SessionSnapshot>({
  86. sessionId: id,
  87. pendingSubmissions: [],
  88. running: false,
  89. subagent: null,
  90. removed: false,
  91. openState: 'open',
  92. openError: null,
  93. hasMore: false,
  94. loadingOlder: false,
  95. promptError: null,
  96. blank: false,
  97. lastAgentError: null,
  98. promptAttempted: false,
  99. awaitingFirstTurn: false,
  100. })
  101. const projections = new Map<string, HostObservable<unknown>>()
  102. const session = {
  103. sessionId: id,
  104. projections: {
  105. faceOf(key: string) {
  106. let source = projections.get(key)
  107. if (source === undefined) {
  108. source = createSnapshotStore<unknown>(undefined)
  109. projections.set(key, source)
  110. }
  111. return source
  112. },
  113. },
  114. getSnapshot: () => snapshot.getSnapshot(),
  115. subscribe: (listener: () => void) => snapshot.subscribe(listener),
  116. } as unknown as SessionBinding['session']
  117. const binding: SessionBinding = {
  118. sessionId: id,
  119. session,
  120. eventSource: new MutableSessionEventSource(),
  121. ctx: scopeCtx as AgentContext,
  122. }
  123. bindings.set(id, binding)
  124. scopes.set(id, scopeCtx)
  125. list.update((draft) => {
  126. if (!draft.ids.includes(id)) draft.ids.push(id)
  127. draft.byId[id] = {
  128. id,
  129. displayTitle: id,
  130. running: false,
  131. retainedBy: {},
  132. blank: false,
  133. updatedAt: 1,
  134. }
  135. })
  136. return binding
  137. },
  138. reference(binding) {
  139. let released = false
  140. const release = vi.fn(() => { released = true })
  141. return {
  142. sessionId: binding.sessionId,
  143. ready: Promise.resolve(binding),
  144. get binding() {
  145. if (released || bindings.get(binding.sessionId) !== binding) throw new Error('Session reference is released')
  146. return binding
  147. },
  148. release,
  149. [Symbol.dispose]: release,
  150. }
  151. },
  152. setMainView(id, count) {
  153. this.setRetainInfo(id, count)
  154. list.update((draft) => {
  155. const row = draft.byId[id]
  156. if (row === undefined) throw new Error(`unknown test Session ${id}`)
  157. draft.byId[id] = {
  158. ...row,
  159. retainedBy: count === 0 ? {} : { mainView: count },
  160. }
  161. })
  162. },
  163. setRetainInfo(id, count) {
  164. retainSource(id).set({
  165. referenceCount: count,
  166. retainedBy: count === 0 ? {} : { mainView: count },
  167. })
  168. },
  169. emitStatus(id, running) {
  170. if (statusListener === undefined) throw new Error('api-session/status is not subscribed')
  171. statusListener(id, running)
  172. },
  173. async release(id) {
  174. bindings.delete(id)
  175. const scopeCtx = scopes.get(id)
  176. scopes.delete(id)
  177. await scopeCtx?.fiber.dispose()
  178. },
  179. }
  180. }
  181. function createUiSession(ctx: Context, bench: SessionsBench): UiSession {
  182. ctx.provide('slots', { bindStoreScope: vi.fn() } as never)
  183. return new UiSession(ctx, bench.sessions)
  184. }
  185. afterEach(async () => {
  186. await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
  187. vi.restoreAllMocks()
  188. })
  189. describe('UiSession bindings', () => {
  190. it('tracks the main-view source and moves its retention watcher between Sessions', () => {
  191. const ctx = new Context()
  192. const bench = createSessionsBench(ctx)
  193. const first = bench.reference(bench.binding(sessionId('s1')))
  194. const second = bench.reference(bench.binding(sessionId('s2')))
  195. const service = createUiSession(ctx, bench)
  196. const firstSource = service.bindingSource(first)
  197. const secondSource = service.bindingSource(second)
  198. const current = service.adapter.current
  199. const changed = vi.fn()
  200. current.subscribe(changed)
  201. expect(current.getSnapshot().key).toBeUndefined()
  202. bench.setMainView(first.sessionId, 1)
  203. expect(current.getSnapshot()).toBe(firstSource.getSnapshot())
  204. const missing = sessionId('missing')
  205. bench.list.update((draft) => { draft.byId[missing] = undefined as never })
  206. expect(current.getSnapshot()).toBe(firstSource.getSnapshot())
  207. bench.list.update((draft) => { Reflect.deleteProperty(draft.byId, missing) })
  208. bench.retainInfo.mockClear()
  209. bench.setRetainInfo(first.sessionId, 2)
  210. expect(bench.retainInfo).toHaveBeenCalledOnce()
  211. expect(current.getSnapshot()).toBe(firstSource.getSnapshot())
  212. bench.setRetainInfo(second.sessionId, 1)
  213. bench.list.update((draft) => {
  214. draft.byId[first.sessionId] = { ...draft.byId[first.sessionId]!, retainedBy: {} }
  215. draft.byId[second.sessionId] = {
  216. ...draft.byId[second.sessionId]!,
  217. retainedBy: { mainView: 1 },
  218. }
  219. })
  220. bench.setRetainInfo(first.sessionId, 0)
  221. expect(current.getSnapshot()).toBe(secondSource.getSnapshot())
  222. bench.retainInfo.mockClear()
  223. bench.setRetainInfo(first.sessionId, 1)
  224. expect(bench.retainInfo).not.toHaveBeenCalled()
  225. bench.list.update((draft) => {
  226. draft.byId[second.sessionId] = { ...draft.byId[second.sessionId]!, retainedBy: {} }
  227. })
  228. bench.setRetainInfo(second.sessionId, 0)
  229. expect(current.getSnapshot().key).toBeUndefined()
  230. bench.retainInfo.mockClear()
  231. bench.setRetainInfo(second.sessionId, 1)
  232. expect(bench.retainInfo).not.toHaveBeenCalled()
  233. expect(changed).toHaveBeenCalledTimes(3)
  234. })
  235. it('shares one stable source between references to the same generation', () => {
  236. const ctx = new Context()
  237. const bench = createSessionsBench(ctx)
  238. const bindStoreScope = vi.fn()
  239. ctx.provide('slots', { bindStoreScope } as never)
  240. const service = new UiSession(ctx, bench.sessions)
  241. const binding = bench.binding(sessionId('s1'))
  242. const firstRef = bench.reference(binding)
  243. const secondRef = bench.reference(binding)
  244. const source = service.bindingSource(firstRef)
  245. const value = source.getSnapshot()
  246. expect(service.bindingSource(secondRef)).toBe(source)
  247. expect(service.adapter.bindingSource(firstRef)).toBe(source)
  248. expect(source.getSnapshot()).toBe(value)
  249. expect(value.key).toBe(binding.sessionId)
  250. expect(value.hooks.session).toBe(binding.session)
  251. expect(value.props.sessionId).toBe(binding.sessionId)
  252. expect(value.keyedHooks.projection?.('status')).toBe(binding.session.projections.faceOf('status'))
  253. expect(bindStoreScope).toHaveBeenCalledOnce()
  254. expect(bindStoreScope).toHaveBeenCalledWith(value)
  255. firstRef.release()
  256. expect(service.bindingSource(secondRef)).toBe(source)
  257. })
  258. it('keeps explicit absence stable and independent from catalog changes', () => {
  259. const ctx = new Context()
  260. const bench = createSessionsBench(ctx)
  261. const service = createUiSession(ctx, bench)
  262. const absent = service.bindingSource(undefined)
  263. const changed = vi.fn()
  264. absent.subscribe(changed)
  265. bench.binding(sessionId('s1'))
  266. expect(service.adapter.bindingSource(undefined)).toBe(absent)
  267. expect(absent.getSnapshot()).toEqual({
  268. key: undefined,
  269. hooks: { session: undefined },
  270. keyedHooks: { projection: undefined },
  271. props: { sessionId: undefined },
  272. })
  273. expect(changed).not.toHaveBeenCalled()
  274. expect(bench.resolveBinding).not.toHaveBeenCalled()
  275. })
  276. it('rejects foreign and released references before resolving UI sources', () => {
  277. const ctx = new Context()
  278. const bench = createSessionsBench(ctx)
  279. const service = createUiSession(ctx, bench)
  280. const binding = bench.binding(sessionId('s1'))
  281. const reference = bench.reference(binding)
  282. const foreign = createSessionsBench(new Context())
  283. const foreignRef = foreign.reference(foreign.binding(binding.sessionId))
  284. expect(() => service.bindingSource(foreignRef)).toThrow('not active in this Controller')
  285. reference.release()
  286. expect(() => service.bindingSource(reference)).toThrow('released')
  287. })
  288. it('publishes disposal only to the ended generation and rejects its reference', async () => {
  289. const ctx = new Context()
  290. const bench = createSessionsBench(ctx)
  291. const service = createUiSession(ctx, bench)
  292. const firstRef = bench.reference(bench.binding(sessionId('s1')))
  293. const secondRef = bench.reference(bench.binding(sessionId('s2')))
  294. const first = service.bindingSource(firstRef)
  295. const second = service.bindingSource(secondRef)
  296. const firstChanged = vi.fn()
  297. const secondChanged = vi.fn()
  298. first.subscribe(firstChanged)
  299. second.subscribe(secondChanged)
  300. const secondSnapshot = second.getSnapshot()
  301. await bench.release(firstRef.sessionId)
  302. expect(first.getSnapshot()).toBe(service.bindingSource(undefined).getSnapshot())
  303. expect(firstChanged).toHaveBeenCalledOnce()
  304. expect(second.getSnapshot()).toBe(secondSnapshot)
  305. expect(secondChanged).not.toHaveBeenCalled()
  306. expect(() => service.bindingSource(firstRef)).toThrow('released')
  307. })
  308. it('does not let old Context cleanup remove a same-id replacement source', async () => {
  309. const ctx = new Context()
  310. const bench = createSessionsBench(ctx)
  311. const service = createUiSession(ctx, bench)
  312. const oldBinding = bench.binding(sessionId('same'))
  313. const oldSource = service.bindingSource(bench.reference(oldBinding))
  314. const replacement = bench.reference(bench.binding(oldBinding.sessionId))
  315. const nextSource = service.bindingSource(replacement)
  316. const nextSnapshot = nextSource.getSnapshot()
  317. const changed = vi.fn()
  318. nextSource.subscribe(changed)
  319. expect(nextSource).not.toBe(oldSource)
  320. expect(oldSource.getSnapshot().key).toBe(oldBinding.sessionId)
  321. await oldBinding.ctx.fiber.dispose()
  322. expect(oldSource.getSnapshot().key).toBeUndefined()
  323. expect(service.bindingSource(replacement)).toBe(nextSource)
  324. expect(nextSource.getSnapshot()).toBe(nextSnapshot)
  325. expect(changed).not.toHaveBeenCalled()
  326. })
  327. it('contains a failing binding subscriber and continues dispatch', () => {
  328. const ctx = new Context()
  329. const bench = createSessionsBench(ctx)
  330. const service = createUiSession(ctx, bench)
  331. const source = service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
  332. const failure = new Error('subscriber failed')
  333. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  334. const off = source.subscribe(() => { throw failure })
  335. const after = vi.fn()
  336. source.subscribe(after)
  337. service.provide({ props: ['extra'], resolve: () => ({ props: { extra: true } }) })
  338. expect(after).toHaveBeenCalledOnce()
  339. expect(report).toHaveBeenCalledWith('[ui-session] Session binding subscriber failed:', failure)
  340. off()
  341. })
  342. it('releases cached sources without rematerializing while the Controller binding stays live', async () => {
  343. const ctx = new Context()
  344. const bench = createSessionsBench(ctx)
  345. const binding = bench.binding(sessionId('s1'))
  346. const reference = bench.reference(binding)
  347. bench.setMainView(binding.sessionId, 1)
  348. const bindStoreScope = vi.fn()
  349. ctx.provide('slots', { bindStoreScope } as never)
  350. let service: UiSession | undefined
  351. const fiber = ctx.plugin({
  352. apply(scope: Context) { service = new UiSession(scope, bench.sessions) },
  353. })
  354. await fiber.await()
  355. if (service === undefined) throw new Error('UiSession did not start')
  356. const source = service.bindingSource(reference)
  357. expect(service.adapter.current.getSnapshot().key).toBe(binding.sessionId)
  358. expect(bindStoreScope).toHaveBeenCalledOnce()
  359. await fiber.dispose()
  360. expect(source.getSnapshot().key).toBeUndefined()
  361. expect(service.bindingSource(reference).getSnapshot().key).toBeUndefined()
  362. expect(bindStoreScope).toHaveBeenCalledOnce()
  363. expect(binding.ctx.fiber.uid).not.toBeNull()
  364. })
  365. it('assembles all descriptor snapshots before notifying independent Session sources', () => {
  366. const ctx = new Context()
  367. const bench = createSessionsBench(ctx)
  368. const service = createUiSession(ctx, bench)
  369. const a = bench.reference(bench.binding(sessionId('a')))
  370. const b = bench.reference(bench.binding(sessionId('b')))
  371. const sourceA = service.bindingSource(a)
  372. const sourceB = service.bindingSource(b)
  373. const absent = service.bindingSource(undefined)
  374. expect(sourceA).not.toBe(sourceB)
  375. const values: unknown[] = []
  376. sourceA.subscribe(() => { values.push(sourceB.getSnapshot().props.feature) })
  377. const changedB = vi.fn()
  378. sourceB.subscribe(changedB)
  379. const custom = createSnapshotStore(1)
  380. const keyed = (key: string): HostObservable<unknown> => createSnapshotStore(key)
  381. const remove = service.provide({
  382. hooks: ['custom'], keyedHooks: ['customKeyed'], props: ['feature'],
  383. resolve: binding => ({
  384. hooks: { custom }, keyedHooks: { customKeyed: keyed }, props: { feature: binding.sessionId },
  385. }),
  386. })
  387. const removeNeighbor = service.provide({
  388. props: ['neighbor'], resolve: () => ({ props: { neighbor: 'kept' } }),
  389. })
  390. expect(service.bindingSource(a)).toBe(sourceA)
  391. expect(service.bindingSource(b)).toBe(sourceB)
  392. expect(sourceA.getSnapshot().props.feature).toBe(a.sessionId)
  393. expect(sourceB.getSnapshot().props.feature).toBe(b.sessionId)
  394. expect(sourceA.getSnapshot().hooks.custom).toBe(custom)
  395. expect(sourceA.getSnapshot().keyedHooks.customKeyed).toBe(keyed)
  396. expect(absent.getSnapshot().hooks).toHaveProperty('custom', undefined)
  397. expect(values).toEqual([b.sessionId, b.sessionId])
  398. remove()
  399. expect(values.at(-1)).toBeUndefined()
  400. expect(sourceA.getSnapshot().props).not.toHaveProperty('feature')
  401. expect(sourceB.getSnapshot().props).not.toHaveProperty('feature')
  402. expect(sourceA.getSnapshot().props.neighbor).toBe('kept')
  403. expect(absent.getSnapshot().hooks).not.toHaveProperty('custom')
  404. remove()
  405. expect(changedB).toHaveBeenCalledTimes(3)
  406. removeNeighbor()
  407. expect(sourceA.getSnapshot().props).not.toHaveProperty('neighbor')
  408. })
  409. it.each([
  410. ['hook', { resolve: () => ({ hooks: { surprise: createSnapshotStore(1) } }) }],
  411. ['keyed hook', { resolve: () => ({ keyedHooks: { surprise: () => createSnapshotStore(1) } }) }],
  412. ['prop', { resolve: () => ({ props: { surprise: 1 } }) }],
  413. ] as const)('rejects an undeclared %s returned by a contribution', (kind, descriptor) => {
  414. const ctx = new Context()
  415. const bench = createSessionsBench(ctx)
  416. const service = createUiSession(ctx, bench)
  417. service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
  418. expect(() => service.provide(descriptor as never)).toThrow(`uiSession.provide: undeclared ${kind} 'surprise'`)
  419. })
  420. it.each([
  421. ['hook', { hooks: ['missing'], resolve: () => ({}) }],
  422. ['keyed hook', { keyedHooks: ['missing'], resolve: () => ({}) }],
  423. ['prop', { props: ['missing'], resolve: () => ({}) }],
  424. ] as const)('rejects a missing declared %s', (kind, descriptor) => {
  425. const ctx = new Context()
  426. const bench = createSessionsBench(ctx)
  427. const service = createUiSession(ctx, bench)
  428. service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
  429. expect(() => service.provide(descriptor)).toThrow(`uiSession.provide: missing ${kind} 'missing'`)
  430. })
  431. it.each([
  432. ['hook', { hooks: ['session'], resolve: () => ({ hooks: { session: createSnapshotStore(1) } }) }],
  433. ['keyed hook', { keyedHooks: ['projection'], resolve: () => ({ keyedHooks: { projection: () => createSnapshotStore(1) } }) }],
  434. ['prop', { props: ['sessionId'], resolve: () => ({ props: { sessionId: 'other' } }) }],
  435. ] as const)('rejects a duplicate declared %s', (kind, descriptor) => {
  436. const ctx = new Context()
  437. const bench = createSessionsBench(ctx)
  438. const service = createUiSession(ctx, bench)
  439. expect(() => service.provide(descriptor)).toThrow(`uiSession.provide: duplicate ${kind}`)
  440. })
  441. it('rejects cross-compartment collisions at the final standard prop name', () => {
  442. const ctx = new Context()
  443. const bench = createSessionsBench(ctx)
  444. const service = createUiSession(ctx, bench)
  445. const source = createSnapshotStore(1)
  446. service.provide({ hooks: ['feature'], resolve: () => ({ hooks: { feature: source } }) })
  447. const absent = service.bindingSource(undefined)
  448. const before = absent.getSnapshot()
  449. expect(() => service.provide({
  450. keyedHooks: ['feature'], resolve: () => ({ keyedHooks: { feature: () => source } }),
  451. })).toThrow("uiSession.provide: duplicate keyed hook 'feature' at prop 'useFeature'")
  452. expect(() => service.provide({
  453. props: ['useFeature'], resolve: () => ({ props: { useFeature: true } }),
  454. })).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'")
  455. expect(absent.getSnapshot()).toBe(before)
  456. })
  457. it('keeps every source unchanged when a later Session contribution fails', () => {
  458. const ctx = new Context()
  459. const bench = createSessionsBench(ctx)
  460. const service = createUiSession(ctx, bench)
  461. const first = service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
  462. const second = service.bindingSource(bench.reference(bench.binding(sessionId('s2'))))
  463. const beforeFirst = first.getSnapshot()
  464. const beforeSecond = second.getSnapshot()
  465. const changed = vi.fn()
  466. first.subscribe(changed)
  467. second.subscribe(changed)
  468. let calls = 0
  469. expect(() => service.provide({
  470. props: ['partial'],
  471. resolve: () => {
  472. calls += 1
  473. if (calls === 2) throw new Error('second binding failed')
  474. return { props: { partial: true } }
  475. },
  476. })).toThrow('second binding failed')
  477. expect(calls).toBe(2)
  478. expect(first.getSnapshot()).toBe(beforeFirst)
  479. expect(second.getSnapshot()).toBe(beforeSecond)
  480. expect(changed).not.toHaveBeenCalled()
  481. })
  482. })
  483. describe('UiSession status', () => {
  484. it('records non-main completions and lets main-view activity acknowledge them', () => {
  485. const ctx = new Context()
  486. const bench = createSessionsBench(ctx)
  487. const id = sessionId('s1')
  488. bench.binding(id)
  489. const service = createUiSession(ctx, bench)
  490. const changed = vi.fn()
  491. const off = service.sessionStatus.subscribe(changed)
  492. expect(service.sessionStatus.getSnapshot().get(id)).toEqual({
  493. running: false,
  494. pendingInteraction: undefined,
  495. completionUnread: false,
  496. })
  497. bench.list.update((draft) => { draft.byId[id]!.running = true })
  498. expect(service.sessionStatus.getSnapshot().get(id)?.running).toBe(true)
  499. bench.list.update((draft) => { draft.byId[id]!.running = false })
  500. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
  501. bench.setMainView(id, 1)
  502. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
  503. bench.setMainView(id, 0)
  504. bench.emitStatus(id, true)
  505. bench.emitStatus(id, false)
  506. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
  507. bench.setMainView(id, 1)
  508. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
  509. bench.emitStatus(id, true)
  510. bench.emitStatus(id, false)
  511. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
  512. bench.setMainView(id, 0)
  513. bench.emitStatus(id, false)
  514. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
  515. bench.emitStatus(id, true)
  516. bench.emitStatus(id, false)
  517. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
  518. bench.emitStatus(id, true)
  519. expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
  520. expect(changed).toHaveBeenCalled()
  521. off()
  522. })
  523. it('records an idle event before the initial catalog baseline and retires absent status later', () => {
  524. const ctx = new Context()
  525. const bench = createSessionsBench(ctx)
  526. const id = sessionId('early')
  527. bench.list.update((draft) => { draft.phase = 'pending' })
  528. const service = createUiSession(ctx, bench)
  529. bench.emitStatus(id, false)
  530. expect(service.sessionStatus.getSnapshot().get(id)).toEqual({
  531. running: false,
  532. pendingInteraction: undefined,
  533. completionUnread: true,
  534. })
  535. bench.list.update((draft) => { draft.phase = 'ready' })
  536. expect(service.sessionStatus.getSnapshot().has(id)).toBe(false)
  537. })
  538. })
  539. describe('UiSession pending interactions', () => {
  540. it('publishes the highest-precedence exact object and removes each source independently', async () => {
  541. const ctx = new Context()
  542. const bench = createSessionsBench(ctx)
  543. const service = createUiSession(ctx, bench)
  544. const id = sessionId('s1')
  545. const listener = vi.fn()
  546. const off = service.sessionStatus.subscribe(listener)
  547. const registerApproval = service.registerPendingInteraction<SessionPendingInteractionBase>(
  548. () => 0,
  549. )
  550. const registerQuestion = service.registerPendingInteraction<SessionPendingInteractionBase>(
  551. interaction => interaction.kind === 'plan-review' ? 2 : 1,
  552. )
  553. const registerBackground = service.registerPendingInteraction<SessionPendingInteractionBase>(
  554. () => -1,
  555. )
  556. listener.mockClear()
  557. const approval = { key: 'approval:1', kind: 'approval', sessionId: id }
  558. const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id }
  559. const question = { key: 'question:1', kind: 'question', sessionId: id }
  560. const plan = { key: 'question:2', kind: 'plan-review', sessionId: id }
  561. const background = { key: 'background:1', kind: 'background', sessionId: id }
  562. const delegate = (): Promise<void> => Promise.resolve()
  563. const removeApproval = registerApproval(approval, delegate)
  564. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(approval)
  565. const removeDuplicate = registerApproval(duplicate, delegate)
  566. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(duplicate)
  567. const removeQuestion = registerQuestion(question, delegate)
  568. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(question)
  569. const removePlan = registerQuestion(plan, delegate)
  570. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(plan)
  571. const removeBackground = registerBackground(background, delegate)
  572. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(plan)
  573. removeBackground()
  574. removeQuestion()
  575. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(plan)
  576. removePlan()
  577. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(duplicate)
  578. removeDuplicate()
  579. expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(approval)
  580. removeApproval()
  581. removeApproval()
  582. expect(service.sessionStatus.getSnapshot().has(id)).toBe(false)
  583. off()
  584. await ctx.fiber.dispose()
  585. })
  586. it('rejects duplicate keys and contains a failing aggregate subscriber', () => {
  587. const ctx = new Context()
  588. const bench = createSessionsBench(ctx)
  589. const service = createUiSession(ctx, bench)
  590. const registerPendingInteraction = service.registerPendingInteraction<SessionPendingInteractionBase>(
  591. () => 1,
  592. )
  593. const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') }
  594. const delegate = () => Promise.resolve()
  595. const remove = registerPendingInteraction(interaction, delegate)
  596. expect(() => { registerPendingInteraction(interaction, delegate) })
  597. .toThrow("ui-session: duplicate pending interaction key 'question:1'")
  598. const failure = new Error('pending subscriber failed')
  599. const report = vi.spyOn(console, 'error').mockImplementation(() => {})
  600. service.sessionStatus.subscribe(() => { throw failure })
  601. const after = vi.fn()
  602. service.sessionStatus.subscribe(after)
  603. remove()
  604. expect(after).toHaveBeenCalledOnce()
  605. expect(report).toHaveBeenCalledWith(
  606. '[ui-session] Session status subscriber failed:',
  607. failure,
  608. )
  609. })
  610. it('removes active values before awaiting their teardown delegation', async () => {
  611. const ctx = new Context()
  612. const bench = createSessionsBench(ctx)
  613. const service = createUiSession(ctx, bench)
  614. const gate = Promise.withResolvers<undefined>()
  615. const delegate = vi.fn(() => gate.promise)
  616. const publish = service.registerPendingInteraction<SessionPendingInteractionBase>(() => 1)
  617. const remove = publish(
  618. { key: 'question:1', kind: 'question', sessionId: sessionId('s1') },
  619. delegate,
  620. )
  621. let disposed = false
  622. const disposal = ctx.fiber.dispose().then(() => { disposed = true })
  623. await vi.waitFor(() => { expect(delegate).toHaveBeenCalledOnce() })
  624. expect(service.sessionStatus.getSnapshot()).toEqual(new Map())
  625. expect(disposed).toBe(false)
  626. remove()
  627. remove()
  628. gate.resolve(undefined)
  629. await disposal
  630. expect(disposed).toBe(true)
  631. })
  632. })
  633. describe('ui-session apply', () => {
  634. it('provides the root sources and installs the Session scope adapter', () => {
  635. const ctx = new Context()
  636. const bench = createSessionsBench(ctx)
  637. const slots = {
  638. provideRoot: vi.fn(),
  639. installScope: vi.fn(),
  640. }
  641. ctx.provide('sessions', bench.sessions)
  642. ctx.provide('slots', slots as never)
  643. apply(ctx)
  644. expect(ctx.uiSession).toBeInstanceOf(UiSession)
  645. expect(slots.provideRoot).toHaveBeenCalledWith({
  646. hooks: {
  647. sessions: bench.sessions.list,
  648. sessionStatus: ctx.uiSession.sessionStatus,
  649. },
  650. keyedHooks: { sessionRetainInfo: expect.any(Function) as unknown },
  651. })
  652. expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter)
  653. const root = slots.provideRoot.mock.calls[0]![0] as RootStandardSourceContribution
  654. expect(root.keyedHooks?.sessionRetainInfo?.(sessionId('s1')))
  655. .toBe(bench.retainInfo(sessionId('s1')))
  656. })
  657. it('keeps the Host loader half inert', () => {
  658. expect(() => { nodeApply() }).not.toThrow()
  659. })
  660. })