sessions.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. /** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
  2. import type { Context } from 'cordis'
  3. import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
  4. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  5. import type {
  6. ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
  7. SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
  8. } from '@deepseek-ai/dsh-client-runtime/client'
  9. import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
  10. import { conversationSnapshot } from './fixtures.ts'
  11. import type { SessionFixture, Stabilizer } from './fixtures.ts'
  12. /**
  13. * The fixture-backed session face: conversation reads delegate to the
  14. * fixture's snapshot store; ISession verbs are fail-loud stubs unless the
  15. * fixture supplies them (the runtime never fakes behavior a test did not
  16. * declare — an unstubbed call names itself instead of half-working). Extra
  17. * fixture methods are grafted verbatim for feature-side casts.
  18. */
  19. export class FixtureSession implements SessionFace {
  20. /**
  21. * The useProjection seat: identity-stable per-key faces over the fixture's
  22. * projection values (set via {@link TestSessions.setProjection}).
  23. */
  24. readonly projections: ProjectionsFace & { set(key: string, value: unknown): void }
  25. /**
  26. * @param sessionId - host identity (branded view of the fixture id).
  27. * @param store - conversation snapshot store (updateSnapshot writes it).
  28. * @param overrides - fixture-declared behavior face, grafted over the stubs.
  29. */
  30. constructor(
  31. readonly sessionId: SessionId,
  32. private readonly store: SnapshotStore<ConversationSnapshot>,
  33. overrides: Record<string, unknown>,
  34. ) {
  35. const values = new Map<string, unknown>()
  36. const listeners = new Map<string, Set<() => void>>()
  37. const faces = new Map<string, ObservableSnapshot<unknown>>()
  38. this.projections = {
  39. faceOf: (key: string) => {
  40. let face = faces.get(key)
  41. if (face === undefined) {
  42. face = {
  43. getSnapshot: () => values.get(key),
  44. subscribe: (fn: () => void) => {
  45. const set = listeners.get(key) ?? new Set()
  46. set.add(fn)
  47. listeners.set(key, set)
  48. return () => { set.delete(fn) }
  49. },
  50. }
  51. faces.set(key, face)
  52. }
  53. return face
  54. },
  55. set: (key: string, value: unknown) => {
  56. values.set(key, value)
  57. for (const fn of [...(listeners.get(key) ?? [])]) fn()
  58. },
  59. }
  60. Object.assign(this, overrides)
  61. }
  62. /** @returns the fixture conversation snapshot (useSession read side). */
  63. getSnapshot(): ConversationSnapshot {
  64. return this.store.getSnapshot()
  65. }
  66. /**
  67. * Subscribe to fixture snapshot changes.
  68. * @param fn - change callback.
  69. * @returns unsubscribe.
  70. */
  71. subscribe(fn: () => void): () => void {
  72. return this.store.subscribe(fn)
  73. }
  74. /**
  75. * Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
  76. * @returns never — always throws.
  77. */
  78. prompt(): never {
  79. throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
  80. }
  81. /**
  82. * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
  83. * @returns never — always throws.
  84. */
  85. updateQueue(): never {
  86. throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`)
  87. }
  88. /**
  89. * Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
  90. * @returns never — always throws.
  91. */
  92. cancel(): never {
  93. throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
  94. }
  95. /**
  96. * Fail-loud stub; supply `command` on the fixture's session face to exercise it.
  97. * @returns never — always throws.
  98. */
  99. command(): never {
  100. throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`)
  101. }
  102. /**
  103. * Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
  104. * @returns never — always throws.
  105. */
  106. loadOlder(): never {
  107. throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
  108. }
  109. /**
  110. * Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
  111. * @returns never — always throws.
  112. */
  113. rename(): never {
  114. throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`)
  115. }
  116. }
  117. /** One live test session: fixture-derived stores plus its minted scope state. */
  118. interface SessionRecord {
  119. summary: SessionSummary
  120. snapshot: SnapshotStore<ConversationSnapshot>
  121. session: FixtureSession
  122. scope: Context | undefined
  123. scopeFiber: { dispose(): Promise<void> } | undefined
  124. /** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
  125. provideInfo: SessionProvideInfo | undefined
  126. }
  127. /** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */
  128. export interface TestSessionBinding {
  129. readonly sessionId: SessionId
  130. readonly session: FixtureSession
  131. readonly ctx: Context
  132. }
  133. /**
  134. * Sessions test double behind the renderer host and feature injects: owns the
  135. * list/current observable, the standard-props provide channel (the runtime's
  136. * `useSession` contribution included), scope minting through the production
  137. * `createScope`, and the session behavior face supplied per fixture.
  138. *
  139. * Implements the same ISessions face features receive as `ctx.sessions`, so
  140. * a production face change breaks this double at compile time; the extra
  141. * members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
  142. * legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
  143. */
  144. export class TestSessions implements ISessions {
  145. /** The useSessions standard feed (list rows + current selection). */
  146. readonly list: SnapshotStore<SessionListState>
  147. /**
  148. * Atomic current-session provide projection (production SessionsService
  149. * mirror): selection changes and provider-roster changes publish through
  150. * this one source — the member the SlotsService host face hands the
  151. * renderer's SessionProvider.
  152. */
  153. readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
  154. private readonly records = new Map<SessionId, SessionRecord>()
  155. /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
  156. private readonly channel: SessionProvideChannel
  157. /** Calls observed on the service-level face (open/clear), newest last. */
  158. readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
  159. /**
  160. * @param stabilize - the owning runtime's act wrapper.
  161. * @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
  162. */
  163. constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
  164. this.list = createSnapshotStore<SessionListState>({
  165. ids: [], byId: {}, current: undefined, phase: 'ready',
  166. })
  167. this.channel = new SessionProvideChannel({
  168. rebuildBundles: () => {
  169. for (const record of this.records.values()) {
  170. if (record.provideInfo !== undefined) {
  171. record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record))
  172. }
  173. }
  174. },
  175. resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
  176. })
  177. this.currentProvideInfo = this.channel.currentProvideInfo
  178. // The projection follows every current write, as in production.
  179. this.list.subscribe(() => { this.channel.publishCurrent() })
  180. }
  181. /**
  182. * Add a session from a fixture and (by default) make it current.
  183. * @param fixture - identity + snapshot/summary overrides + behavior face.
  184. * @param opts - pass `current: false` to add without selecting.
  185. * @returns the stable session id (branded view of `fixture.id`).
  186. */
  187. async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise<SessionId> {
  188. const id = fixture.id as SessionId
  189. if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
  190. const summary: SessionSummary = {
  191. id,
  192. displayTitle: fixture.id,
  193. running: false,
  194. waitingApproval: false,
  195. blank: false,
  196. updatedAt: this.records.size + 1,
  197. ...fixture.summary,
  198. }
  199. const snapshot = createSnapshotStore<ConversationSnapshot>({
  200. ...conversationSnapshot(id),
  201. ...fixture.snapshot,
  202. })
  203. this.records.set(id, {
  204. summary,
  205. snapshot,
  206. session: new FixtureSession(id, snapshot, fixture.session ?? {}),
  207. scope: undefined,
  208. scopeFiber: undefined,
  209. provideInfo: undefined,
  210. })
  211. await this.stabilize(() => {
  212. this.list.update((draft) => {
  213. draft.ids.push(id)
  214. draft.byId[id] = summary
  215. if (opts?.current !== false) draft.current = id
  216. })
  217. })
  218. return id
  219. }
  220. /**
  221. * Update a session's conversation snapshot through an immer draft (the
  222. * live-stream stand-in: components subscribed via useSession re-render).
  223. * @param id - session id.
  224. * @param mutate - draft mutator.
  225. */
  226. async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise<void> {
  227. const record = this.require(id)
  228. await this.stabilize(() => { record.snapshot.update(mutate) })
  229. }
  230. /**
  231. * Update a session's list row (the wire-echo stand-in: title settles,
  232. * running flips — components subscribed via useSessions re-render).
  233. * @param id - session id.
  234. * @param patch - summary fields to merge over the row.
  235. */
  236. async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
  237. const record = this.require(id)
  238. record.summary = { ...record.summary, ...patch }
  239. await this.stabilize(() => {
  240. this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
  241. })
  242. }
  243. /**
  244. * Switch the current selection (undefined = the no-session empty state).
  245. * @param id - session id to select, or undefined to clear.
  246. */
  247. async setCurrent(id: string | undefined): Promise<void> {
  248. if (id !== undefined) this.require(id)
  249. await this.stabilize(() => {
  250. this.list.update((draft) => { draft.current = id as SessionId | undefined })
  251. })
  252. }
  253. /**
  254. * Remove a session: list row, scope fiber, and per-session store instances
  255. * (with persisted state) die together — the same single lifecycle axis the
  256. * production SessionsService drives on session death, minus staging.
  257. * @param id - session id.
  258. */
  259. async remove(id: string): Promise<void> {
  260. const record = this.require(id)
  261. this.records.delete(id as SessionId)
  262. await this.stabilize(async () => {
  263. this.list.update((draft) => {
  264. draft.ids = draft.ids.filter(existing => existing !== id)
  265. const { [id as SessionId]: _dead, ...rest } = draft.byId
  266. draft.byId = rest
  267. if (draft.current === id) draft.current = undefined
  268. })
  269. if (record.scopeFiber !== undefined) await record.scopeFiber.dispose()
  270. this.rootCtx.get('slots')?.pruneStoreScope(id)
  271. })
  272. }
  273. /**
  274. * Register a per-session standard-props provider (production `provide`
  275. * contract: hooks become `use<Name>` selector hooks on the render side,
  276. * props spread verbatim; duplicate names fail loud at materialization).
  277. * @param descriptor - static member roster plus per-session resolver.
  278. * @returns disposer removing the provider.
  279. */
  280. provide(descriptor: SessionProvideDescriptor): () => void {
  281. return this.channel.provide(descriptor)
  282. }
  283. /**
  284. * Resolve the definite per-session standard-props bundle (host face member).
  285. * @param id - session id.
  286. * @returns the identity-stable bundle, or undefined for unknown sessions.
  287. */
  288. provideInfo(id: string): SessionProvideInfo | undefined {
  289. const record = this.records.get(id as SessionId)
  290. if (record === undefined) return undefined
  291. record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record))
  292. return record.provideInfo
  293. }
  294. /**
  295. * Resolve the current-session-optional standard kit (host face member):
  296. * unknown or absent ids return the static no-session projection.
  297. * @param id - current session id, when selected.
  298. * @returns a definite or no-session provide bundle.
  299. */
  300. maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
  301. return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo
  302. }
  303. /**
  304. * Resolve (mint on first touch) the session-scoped Cordis context through
  305. * the production `createScope`, so real `scopeOf`/scope-addressed services
  306. * resolve it.
  307. * @param id - session id.
  308. * @returns the scoped context, or undefined for unknown sessions.
  309. */
  310. scope(id: string): Context | undefined {
  311. const record = this.records.get(id as SessionId)
  312. if (record === undefined) return undefined
  313. if (record.scope === undefined) {
  314. const handle = createScope(this.rootCtx, id as SessionId)
  315. record.scope = handle.ctx
  316. record.scopeFiber = handle.fiber
  317. }
  318. return record.scope
  319. }
  320. /**
  321. * Session assembly binding (inject factories and provide resolvers receive it).
  322. * @param id - session id.
  323. * @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
  324. */
  325. binding(id: string): TestSessionBinding | undefined {
  326. const record = this.records.get(id as SessionId)
  327. if (record === undefined) return undefined
  328. return this.bindingOf(id as SessionId, record)
  329. }
  330. /**
  331. * Read the session scope tag off a context (service-method seam mirror).
  332. * @param ctx - any client context.
  333. * @returns the session id, or undefined on root contexts.
  334. */
  335. scopeOf(ctx: Context): SessionId | undefined {
  336. return scopeOf(ctx)
  337. }
  338. /**
  339. * Resolve the scoped session face off a context (production `sessionOf`
  340. * mirror).
  341. * @param ctx - any client context.
  342. * @returns the fixture session face, or undefined off-scope.
  343. */
  344. sessionOf(ctx: Context): SessionFace | undefined {
  345. const id = scopeOf(ctx)
  346. if (id === undefined) return undefined
  347. return this.records.get(id)?.session
  348. }
  349. /**
  350. * Service-level selection call (recorded, then applied to the list store
  351. * synchronously — inject callbacks call this outside any act window; the
  352. * store notify is microtask-batched so the next stabilized step observes it).
  353. * @param id - session id.
  354. */
  355. open(id: SessionId): void {
  356. this.calls.push({ method: 'open', args: [id] })
  357. this.require(id)
  358. this.list.update((draft) => { draft.current = id })
  359. }
  360. /** Clear the current selection (recorded; the production no-session flow). */
  361. clear(): void {
  362. this.calls.push({ method: 'clear', args: [] })
  363. this.list.update((draft) => { draft.current = undefined })
  364. }
  365. /**
  366. * The session face of a fixture (typed view for assertions; fixture
  367. * behavior methods are grafted onto it).
  368. * @param id - session id.
  369. * @returns the FixtureSession the binding and provide channel carry.
  370. */
  371. behavior(id: string): FixtureSession {
  372. return this.require(id).session
  373. }
  374. /** Dispose minted scope fibers (runtime dispose path). */
  375. async disposeScopes(): Promise<void> {
  376. for (const record of this.records.values()) {
  377. if (record.scopeFiber !== undefined) {
  378. await record.scopeFiber.dispose()
  379. record.scope = undefined
  380. record.scopeFiber = undefined
  381. }
  382. }
  383. }
  384. private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding {
  385. const ctx = this.scope(id)
  386. /* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
  387. * always resolves; kept so a future caller cannot mint a ctx-less binding. */
  388. if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`)
  389. return { sessionId: id, session: record.session, ctx }
  390. }
  391. private require(id: string): SessionRecord {
  392. const record = this.records.get(id as SessionId)
  393. if (record === undefined) throw new Error(`test session "${id}" is not added`)
  394. return record
  395. }
  396. }