sessions-service.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * SessionsService: list store projection (manager → {ids, byId, current}
  3. * with derived titles), the migrated current-selection account (open
  4. * validation, persisted mask semantics, cell resolution), scope-tree
  5. * lifecycle (lazy mint / frozen survival / removed teardown with staged
  6. * deferral — the stage follows list.current), binding identity, ancestry
  7. * walk, create.
  8. */
  9. import { Context } from 'cordis'
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
  12. import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
  13. import { FakeApiClient, ok } from './fake-api.ts'
  14. const sid = (s: string): SessionId => s as SessionId
  15. interface Bench {
  16. ctx: Context
  17. api: FakeApiClient
  18. svc: SessionsService
  19. }
  20. function bench(): Bench {
  21. const ctx = new Context()
  22. const api = new FakeApiClient()
  23. const svc = new SessionsService(ctx, api)
  24. return { ctx, api, svc }
  25. }
  26. /** Refresh the manager list from programmable rows and flush the microtask batch. */
  27. async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
  28. b.api.onList = () => Promise.resolve(ok({
  29. items: rows.map(r => ({
  30. sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
  31. ...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
  32. ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
  33. })),
  34. }) as never)
  35. await b.svc.manager.refreshList()
  36. await Promise.resolve() // manager notifier flush
  37. }
  38. describe('list store projection', () => {
  39. it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
  40. const b = bench()
  41. b.svc.manager.handleMuxEnvelope({
  42. rpcId: 'title' as never,
  43. payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
  44. })
  45. await feedList(b, [
  46. { id: 's1', cwd: '/home/u/proj-a/' },
  47. { id: 's2', parentId: 's1', running: true },
  48. ])
  49. const state = b.svc.list.getSnapshot()
  50. expect(state.ids).toEqual(['s1', 's2'])
  51. expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
  52. expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
  53. expect(state.byId[sid('s2')]?.title).toBeUndefined()
  54. })
  55. it('reflects live increments (host stream via manager) into the store', async () => {
  56. const b = bench()
  57. await feedList(b, [{ id: 's1' }])
  58. b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
  59. await Promise.resolve()
  60. expect(b.svc.list.getSnapshot().ids).toContain('s2')
  61. })
  62. })
  63. describe('scope tree', () => {
  64. it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
  65. const b = bench()
  66. await feedList(b, [{ id: 's1' }])
  67. expect(b.svc.scope(sid('unknown'))).toBeUndefined()
  68. const scoped = b.svc.scope(sid('s1'))
  69. expect(scoped).toBeDefined()
  70. expect(scopeOf(scoped as Context)).toBe('s1')
  71. expect(scopeOf(b.ctx)).toBeUndefined()
  72. const binding = b.svc.binding(sid('s1'))
  73. expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
  74. expect(b.svc.binding(sid('s1'))).toBe(binding)
  75. expect(binding?.ctx).toBe(scoped)
  76. })
  77. it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
  78. const b = bench()
  79. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  80. const ctx1 = b.svc.scope(sid('s1'))
  81. b.svc.open(sid('s1')) // s1 staged (current)
  82. b.svc.scope(sid('s2')) // s2 scoped but off stage
  83. await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
  84. expect(b.svc.scope(sid('s2'))).toBeUndefined()
  85. await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
  86. expect(b.svc.scope(sid('s1'))).toBe(ctx1)
  87. await feedList(b, [{ id: 's3' }])
  88. b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
  89. expect(b.svc.scope(sid('s1'))).toBeUndefined()
  90. })
  91. it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => {
  92. const b = bench()
  93. await feedList(b, [{ id: 's1', running: true }])
  94. const scoped = b.svc.scope(sid('s1'))
  95. await feedList(b, [{ id: 's1', running: false }])
  96. expect(b.svc.scope(sid('s1'))).toBe(scoped)
  97. })
  98. it('cancels a deferred teardown when the id reappears in the list', async () => {
  99. const b = bench()
  100. await feedList(b, [{ id: 's1' }])
  101. const scoped = b.svc.scope(sid('s1'))
  102. b.svc.open(sid('s1'))
  103. await feedList(b, []) // removed while staged → deferred
  104. await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
  105. b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
  106. expect(b.svc.scope(sid('s1'))).toBe(scoped)
  107. })
  108. })
  109. describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
  110. afterEach(() => { vi.unstubAllGlobals() })
  111. it('open() writes list.current; unknown ids fail loud', async () => {
  112. const b = bench()
  113. await feedList(b, [{ id: 's1' }])
  114. expect(b.svc.list.getSnapshot().current).toBeUndefined()
  115. b.svc.open(sid('s1'))
  116. expect(b.svc.list.getSnapshot().current).toBe('s1')
  117. expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
  118. expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
  119. })
  120. it('clear() blanks list.current and the persisted selection', async () => {
  121. const storage = new Map<string, string>()
  122. vi.stubGlobal('localStorage', {
  123. getItem: (k: string) => storage.get(k) ?? null,
  124. setItem: (k: string, v: string) => { storage.set(k, v) },
  125. removeItem: (k: string) => { storage.delete(k) },
  126. clear: () => { storage.clear() },
  127. })
  128. const b = bench()
  129. await feedList(b, [{ id: 's1' }])
  130. b.svc.open(sid('s1'))
  131. expect(storage.get('dsh.sessions.current')).toContain('s1')
  132. b.svc.clear()
  133. expect(b.svc.list.getSnapshot().current).toBeUndefined()
  134. // Persisted wipe: a fresh service with the same storage stays on empty.
  135. const again = bench()
  136. await feedList(again, [{ id: 's1' }])
  137. expect(again.svc.list.getSnapshot().current).toBeUndefined()
  138. })
  139. it('masks (not destroys) the selection while its session is off the list', async () => {
  140. const b = bench()
  141. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  142. b.svc.open(sid('s1'))
  143. await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
  144. expect(b.svc.list.getSnapshot().current).toBeUndefined()
  145. await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
  146. expect(b.svc.list.getSnapshot().current).toBe('s1')
  147. })
  148. it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
  149. const storage = new Map<string, string>()
  150. vi.stubGlobal('localStorage', {
  151. getItem: (k: string) => storage.get(k) ?? null,
  152. setItem: (k: string, v: string) => { storage.set(k, v) },
  153. })
  154. const first = bench()
  155. await feedList(first, [{ id: 's1' }])
  156. first.svc.open(sid('s1'))
  157. expect(storage.get('dsh.sessions.current')).toContain('s1')
  158. // A fresh boot (same storage) recovers the selection once the list holds the session.
  159. const second = bench()
  160. await feedList(second, [{ id: 's1' }])
  161. expect(second.svc.list.getSnapshot().current).toBe('s1')
  162. })
  163. })
  164. describe('cell (render-layer session kit)', () => {
  165. it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
  166. const b = bench()
  167. await feedList(b, [{ id: 's1' }])
  168. const cell = b.svc.cell('s1')
  169. expect(cell).toBeDefined()
  170. expect(cell?.sessionId).toBe('s1')
  171. // Bare-source form (store migration): the cell carries the Session
  172. // observable itself; hook binding happens in the React machinery.
  173. expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
  174. expect(b.svc.cell('s1')).toBe(cell)
  175. expect(b.svc.cell('ghost')).toBeUndefined()
  176. })
  177. it('cell()/binding() are pure resolution: no staging, no deferred sweep', async () => {
  178. const b = bench()
  179. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  180. b.svc.open(sid('s1')) // staged
  181. b.svc.cell('s2') // resolution only — must NOT move the stage
  182. b.svc.binding(sid('s2'))
  183. await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
  184. expect(b.svc.scope(sid('s1'))).toBeDefined()
  185. })
  186. it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
  187. const b = bench()
  188. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  189. const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
  190. // Resolution is addressing, not staging: no window pull.
  191. b.svc.scope(sid('s1'))
  192. b.svc.cell('s1')
  193. b.svc.binding(sid('s1'))
  194. expect(historyCalls()).toHaveLength(0)
  195. b.svc.open(sid('s1'))
  196. expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
  197. // Same current again: no second pull.
  198. b.svc.open(sid('s1'))
  199. expect(historyCalls()).toHaveLength(1)
  200. // Stage moves: the new occupant opens.
  201. b.svc.open(sid('s2'))
  202. expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
  203. })
  204. it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
  205. const storage = new Map<string, string>([
  206. ['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
  207. ])
  208. vi.stubGlobal('localStorage', {
  209. getItem: (k: string) => storage.get(k) ?? null,
  210. setItem: (k: string, v: string) => { storage.set(k, v) },
  211. })
  212. try {
  213. const b = bench()
  214. expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
  215. await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
  216. const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
  217. expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
  218. } finally {
  219. vi.unstubAllGlobals()
  220. }
  221. })
  222. })
  223. describe('slot-store scope prune hook', () => {
  224. it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => {
  225. const b = bench()
  226. const pruneStoreScope = vi.fn()
  227. b.ctx.reflect.provide('slots', { pruneStoreScope })
  228. await feedList(b, [{ id: 's1' }, { id: 's2' }])
  229. b.svc.scope(sid('s1'))
  230. b.svc.scope(sid('s2'))
  231. b.svc.open(sid('s2')) // s2 staged
  232. await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred
  233. expect(pruneStoreScope).toHaveBeenCalledWith('s1')
  234. expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
  235. await feedList(b, [{ id: 's3' }])
  236. b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
  237. expect(pruneStoreScope).toHaveBeenCalledWith('s2')
  238. })
  239. it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => {
  240. const b = bench()
  241. await feedList(b, [{ id: 's1' }])
  242. b.svc.scope(sid('s1'))
  243. await feedList(b, []) // teardown without ctx.slots must not throw
  244. expect(b.svc.scope(sid('s1'))).toBeUndefined()
  245. })
  246. })
  247. describe('ancestry', () => {
  248. it('walks parentId links root-first including self; broken links stop the walk', async () => {
  249. const b = bench()
  250. await feedList(b, [
  251. { id: 'root', cwd: '/w/app' },
  252. { id: 'mid', parentId: 'root' },
  253. { id: 'leaf', parentId: 'mid' },
  254. { id: 'orphan', parentId: 'ghost' },
  255. ])
  256. expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
  257. expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
  258. expect(b.svc.ancestry(sid('ghost'))).toEqual([])
  259. })
  260. })
  261. describe('create', () => {
  262. it('returns the new id on ok and throws a coded error on failure', async () => {
  263. const b = bench()
  264. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
  265. await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
  266. b.api.onCreate = () => Promise.resolve({
  267. rpcId: 'e' as never,
  268. result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
  269. } as never)
  270. await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
  271. })
  272. })
  273. describe('createWorkspace', () => {
  274. it('joins host.describe cwd with the name and creates there', async () => {
  275. const b = bench()
  276. b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
  277. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
  278. await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
  279. expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
  280. })
  281. it('rejects empty names and path separators; surfaces describe failures', async () => {
  282. const b = bench()
  283. await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
  284. await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
  285. b.api.onDescribe = () => Promise.resolve({
  286. rpcId: 'e' as never,
  287. result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
  288. } as never)
  289. await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
  290. })
  291. })
  292. describe('coverage tails (branch duals)', () => {
  293. it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
  294. const b = bench()
  295. await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
  296. const { byId } = b.svc.list.getSnapshot()
  297. expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
  298. expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
  299. expect(byId[sid('no-base')]?.title).toBeUndefined()
  300. })
  301. it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
  302. const b = bench()
  303. await feedList(b, [{ id: 's1' }])
  304. b.svc.open(sid('s1'))
  305. expect(b.svc.binding(sid('ghost'))).toBeUndefined()
  306. // Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
  307. await feedList(b, [])
  308. expect(b.svc.scope(sid('s1'))).toBeDefined()
  309. })
  310. it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
  311. const b = bench()
  312. await feedList(b, [{ id: 's1' }])
  313. b.svc.open(sid('s1'))
  314. const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
  315. expect(historyCalls()).toHaveLength(1)
  316. await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
  317. expect(b.svc.scope(sid('s1'))).toBeDefined()
  318. // Resurfacing re-projects current = s1: same stage occupant, no second pull.
  319. await feedList(b, [{ id: 's1' }])
  320. expect(historyCalls()).toHaveLength(1)
  321. expect(b.svc.list.getSnapshot().current).toBe('s1')
  322. })
  323. it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
  324. const b = bench()
  325. await feedList(b, [{ id: 'a' }, { id: 'b' }])
  326. b.svc.scope(sid('a'))
  327. b.svc.open(sid('b')) // stage: b; both scoped
  328. await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
  329. // Move the stage to a THIRD id while b stays deferred: sweep walks a set
  330. // containing b (torn).
  331. await feedList(b, [{ id: 'c' }])
  332. b.svc.open(sid('c'))
  333. expect(b.svc.scope(sid('b'))).toBeUndefined()
  334. // Deferral for an id whose record was never minted: force the deferral
  335. // via removed list state — sweep must tolerate the missing record.
  336. await feedList(b, []) // c removed while staged → deferred (scope exists)
  337. await feedList(b, [{ id: 'd' }])
  338. b.svc.open(sid('d')) // sweep tears c
  339. expect(b.svc.scope(sid('c'))).toBeUndefined()
  340. })
  341. })