sessions.ts 19 KB

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