sessions.ts 20 KB

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