sessions.ts 20 KB

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