workspaces-service.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import { Context } from 'cordis'
  2. import { describe, expect, it } from 'vitest'
  3. import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
  4. import { SessionsService } from '../src/client/sessions/service.ts'
  5. import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
  6. import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
  7. import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
  8. const sid = (id: string): SessionId => id as SessionId
  9. const wid = (id: string): WorkspaceId => id as WorkspaceId
  10. function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
  11. return {
  12. workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds,
  13. createdAt, updatedAt: createdAt,
  14. }
  15. }
  16. describe('WorkspaceManager', () => {
  17. it('replays changed frames over hydration and keeps established order on refresh', async () => {
  18. const api = new FakeApiClient()
  19. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  20. api.onWorkspaceList = () => gate.promise
  21. const manager = new WorkspaceManager(api)
  22. const hydration = manager.refresh()
  23. manager.handleHostEnvelope({
  24. rpcId: 'changed' as never,
  25. payload: { type: 'host/workspace-changed', workspace: workspace('new') },
  26. })
  27. gate.resolve(ok({ items: [workspace('old')] as never[] }))
  28. await hydration
  29. expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' })
  30. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
  31. api.onWorkspaceList = () => Promise.resolve(ok({
  32. items: [workspace('old'), workspace('new')] as never[],
  33. }))
  34. await manager.refresh()
  35. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
  36. })
  37. it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
  38. const api = new FakeApiClient()
  39. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  40. api.onWorkspaceList = () => gate.promise
  41. const manager = new WorkspaceManager(api)
  42. const first = manager.refresh()
  43. const second = manager.refresh()
  44. expect(manager.getSnapshot().state).toBe('loading')
  45. gate.resolve(ok({ items: [] }))
  46. await Promise.all([first, second])
  47. expect(api.callsOf('workspace.list')).toHaveLength(1)
  48. api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
  49. await manager.refresh()
  50. expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } })
  51. api.onWorkspaceList = () => Promise.reject(new Error('wire down'))
  52. await manager.refresh()
  53. expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
  54. })
  55. it('creates by name/path, prepends a new row, and folds failures', async () => {
  56. const api = new FakeApiClient()
  57. const manager = new WorkspaceManager(api)
  58. api.onWorkspaceCreate = payload => Promise.resolve(ok({
  59. workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
  60. created: true,
  61. payload,
  62. } as never))
  63. await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
  64. expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
  65. expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
  66. api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
  67. await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({
  68. ok: false, error: { code: 'internal', message: 'create transport' },
  69. })
  70. })
  71. it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
  72. const api = new FakeApiClient()
  73. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  74. api.onWorkspaceList = () => gate.promise
  75. const manager = new WorkspaceManager(api)
  76. const hydration = manager.refresh()
  77. manager.handleHostEnvelope({
  78. rpcId: 'removed' as never,
  79. payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
  80. })
  81. gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
  82. await hydration
  83. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
  84. manager.handleHostEnvelope({
  85. rpcId: 'late-change' as never,
  86. payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
  87. })
  88. manager.handleHostEnvelope({
  89. rpcId: 'duplicate-remove' as never,
  90. payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
  91. })
  92. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
  93. })
  94. it('removes from the unary delete echo while a refresh is in flight', async () => {
  95. const api = new FakeApiClient()
  96. api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
  97. const manager = new WorkspaceManager(api)
  98. await manager.refresh()
  99. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  100. api.onWorkspaceList = () => gate.promise
  101. const refresh = manager.refresh()
  102. await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
  103. expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
  104. expect(manager.getSnapshot().items).toEqual([])
  105. gate.resolve(ok({ items: [workspace('gone')] as never[] }))
  106. await refresh
  107. expect(manager.getSnapshot().items).toEqual([])
  108. })
  109. })
  110. describe('WorkspacesService', () => {
  111. it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
  112. const ctx = new Context()
  113. const api = new FakeApiClient()
  114. const sessions = new SessionsService(ctx, api)
  115. const workspaces = new WorkspacesService(ctx, api, sessions)
  116. api.onWorkspaceList = () => Promise.resolve(ok({
  117. items: [
  118. workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
  119. workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
  120. ] as never[],
  121. }))
  122. await workspaces.refresh()
  123. await Promise.resolve()
  124. expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
  125. api.onList = () => Promise.resolve(ok({
  126. items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[],
  127. }))
  128. await sessions.refresh()
  129. await Promise.resolve()
  130. await Promise.resolve()
  131. expect(workspaces.list.getSnapshot()).toMatchObject({
  132. baselinesReady: true,
  133. recentWorkspaceId: 'active',
  134. })
  135. expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
  136. })
  137. it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
  138. const ctx = new Context()
  139. const api = new FakeApiClient()
  140. const sessions = new SessionsService(ctx, api)
  141. const workspaces = new WorkspacesService(ctx, api, sessions)
  142. api.onWorkspaceList = () => Promise.resolve(ok({
  143. items: [workspace('alpha'), workspace('beta')] as never[],
  144. }))
  145. api.onList = () => Promise.resolve(ok({
  146. items: [
  147. // Blank session already parked in alpha (cwd == workspace path canon).
  148. { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
  149. // Non-blank sibling in beta must never be reused.
  150. { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
  151. ] as never[],
  152. }))
  153. await Promise.all([workspaces.refresh(), sessions.refresh()])
  154. await Promise.resolve()
  155. // Hit: same workspace → the parked blank session comes back, no create RPC.
  156. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
  157. expect(api.callsOf('session.create')).toEqual([])
  158. // Resolution guarantee: the id is binding-resolvable synchronously.
  159. expect(sessions.binding(sid('s-blank'))).toBeDefined()
  160. // Miss: beta has only a non-blank session → host create with workspaceId.
  161. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
  162. await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
  163. expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
  164. // Same guarantee on the create arm (draft hand-off writes the machine pre-open).
  165. expect(sessions.binding(sid('s-fresh'))).toBeDefined()
  166. // Unknown workspace fails loud instead of silently creating in nowhere.
  167. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
  168. // An archived blank is never reused: no surface can show it, so New
  169. // Session mints a fresh one for alpha instead.
  170. await workspaces.archiveSession(sid('s-blank'))
  171. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
  172. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
  173. })
  174. it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
  175. const ctx = new Context()
  176. const api = new FakeApiClient()
  177. const sessions = new SessionsService(ctx, api)
  178. const workspaces = new WorkspacesService(ctx, api, sessions)
  179. api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
  180. api.onList = () => Promise.resolve(ok({
  181. items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
  182. }))
  183. await Promise.all([workspaces.refresh(), sessions.refresh()])
  184. await Promise.resolve()
  185. const session = sessions.binding(sid('s-blank'))!.session
  186. api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
  187. await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
  188. await Promise.resolve()
  189. // Failure leaves blank intact, so the same session is still the reuse hit.
  190. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
  191. expect(api.callsOf('session.create')).toEqual([])
  192. })
  193. it('returns created Workspaces and preserves Host business errors', async () => {
  194. const ctx = new Context()
  195. const api = new FakeApiClient()
  196. const sessions = new SessionsService(ctx, api)
  197. const workspaces = new WorkspacesService(ctx, api, sessions)
  198. api.onWorkspaceCreate = () => Promise.resolve(ok({
  199. workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
  200. }))
  201. await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
  202. expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
  203. expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
  204. api.onWorkspaceCreate = () => Promise.resolve(err({
  205. code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
  206. }))
  207. const rejected = workspaces.create({ path: '/missing' })
  208. await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/)
  209. await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError)
  210. })
  211. it('passes native directory selection and cancellation through without local state', async () => {
  212. const ctx = new Context()
  213. const api = new FakeApiClient()
  214. const sessions = new SessionsService(ctx, api)
  215. const workspaces = new WorkspacesService(ctx, api, sessions)
  216. api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
  217. await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
  218. api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
  219. await expect(workspaces.pickDirectory()).resolves.toBeNull()
  220. expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
  221. api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
  222. await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
  223. })
  224. it('passes listings and creation through the browse wire, wrapping business failures', async () => {
  225. const ctx = new Context()
  226. const api = new FakeApiClient()
  227. const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
  228. const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
  229. api.onListDirectory = () => Promise.resolve(ok(listing))
  230. await expect(workspaces.listDirectory()).resolves.toEqual(listing)
  231. await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
  232. // The optional path is omitted from the payload, not sent as undefined.
  233. expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
  234. api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
  235. const listFailure = workspaces.listDirectory('/x')
  236. await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
  237. await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
  238. await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
  239. expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
  240. api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
  241. await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
  242. })
  243. it('opens a filesystem path through the host without local state', async () => {
  244. const ctx = new Context()
  245. const api = new FakeApiClient()
  246. const sessions = new SessionsService(ctx, api)
  247. const workspaces = new WorkspacesService(ctx, api, sessions)
  248. await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
  249. expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
  250. api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
  251. await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
  252. })
  253. it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
  254. const ctx = new Context()
  255. const api = new FakeApiClient()
  256. const sessions = new SessionsService(ctx, api)
  257. const workspaces = new WorkspacesService(ctx, api, sessions)
  258. api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
  259. await workspaces.refresh()
  260. await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
  261. expect(workspaces.list.getSnapshot().items).toEqual([])
  262. api.onWorkspaceDelete = () => Promise.resolve(err({
  263. code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
  264. }))
  265. await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
  266. })
  267. it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
  268. const ctx = new Context()
  269. const api = new FakeApiClient()
  270. const sessions = new SessionsService(ctx, api)
  271. const workspaces = new WorkspacesService(ctx, api, sessions)
  272. api.onList = () => Promise.resolve(ok({
  273. items: [
  274. { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
  275. { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
  276. ],
  277. }) as never)
  278. await sessions.refresh()
  279. sessions.open(sid('s-open'))
  280. // Archiving a non-current session installs the unary echo and keeps the selection.
  281. await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
  282. expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
  283. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
  284. expect(sessions.list.getSnapshot().current).toBe('s-open')
  285. // Archiving the current session clears it into the New Session view state.
  286. api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
  287. await workspaces.archiveSession(sid('s-open'))
  288. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
  289. expect(sessions.list.getSnapshot().current).toBeUndefined()
  290. // A Host failure leaves the set and the selection untouched.
  291. api.onWorkspaceArchiveSession = () => Promise.resolve(err({
  292. code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
  293. }))
  294. await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
  295. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
  296. // The changed frame and the list baseline both re-install the full set.
  297. workspaces.handleHostEnvelope({
  298. rpcId: 'frame' as never,
  299. payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] },
  300. } as never)
  301. // Frame installs ride the notifier's microtask batch before projecting.
  302. await new Promise(resolve => setTimeout(resolve, 0))
  303. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
  304. api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never)
  305. await workspaces.refresh()
  306. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
  307. })
  308. it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
  309. const ctx = new Context()
  310. const api = new FakeApiClient()
  311. const sessions = new SessionsService(ctx, api)
  312. const workspaces = new WorkspacesService(ctx, api, sessions)
  313. api.onList = () => Promise.resolve(ok({
  314. items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
  315. }) as never)
  316. await sessions.refresh()
  317. sessions.open(sid('s-open'))
  318. // A stale baseline is in flight (older, empty set) when another tab's
  319. // archive frame lands: the frame clears the current selection and its
  320. // set survives the baseline's later resolution.
  321. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  322. api.onWorkspaceList = () => gate.promise
  323. const hydration = workspaces.refresh()
  324. workspaces.handleHostEnvelope({
  325. rpcId: 'frame' as never,
  326. payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
  327. } as never)
  328. await new Promise(resolve => setTimeout(resolve, 0))
  329. expect(sessions.list.getSnapshot().current).toBeUndefined()
  330. gate.resolve(ok({ items: [], archivedSessionIds: [] }))
  331. await hydration
  332. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
  333. // The next (fresh) baseline is authoritative again.
  334. api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
  335. await workspaces.refresh()
  336. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
  337. })
  338. })
  339. describe('startInitialSelection', () => {
  340. function bench() {
  341. const ctx = new Context()
  342. const api = new FakeApiClient()
  343. const sessions = new SessionsService(ctx, api)
  344. const workspaces = new WorkspacesService(ctx, api, sessions)
  345. return { api, sessions, workspaces }
  346. }
  347. it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
  348. const b = bench()
  349. const stop = b.workspaces.startInitialSelection()
  350. // Nothing happens before both baselines land.
  351. expect(b.api.callsOf('session.create')).toHaveLength(0)
  352. b.api.onWorkspaceList = () => Promise.resolve(ok({
  353. items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
  354. }))
  355. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
  356. await b.workspaces.refresh()
  357. await b.sessions.refresh()
  358. // Store notifications and the connect round trip are microtask-batched.
  359. await new Promise(resolve => setTimeout(resolve, 0))
  360. expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
  361. expect(b.sessions.list.getSnapshot().current).toBe('s-new')
  362. stop()
  363. })
  364. it('stays idle when a session is already current or no recent Workspace exists', async () => {
  365. const withCurrent = bench()
  366. withCurrent.api.onList = () => Promise.resolve(ok({
  367. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
  368. }))
  369. await withCurrent.sessions.refresh()
  370. withCurrent.sessions.open(sid('s1'))
  371. withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
  372. const stopCurrent = withCurrent.workspaces.startInitialSelection()
  373. await withCurrent.workspaces.refresh()
  374. await new Promise(resolve => setTimeout(resolve, 0))
  375. expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
  376. stopCurrent()
  377. const noRecent = bench()
  378. const stopEmpty = noRecent.workspaces.startInitialSelection()
  379. await noRecent.workspaces.refresh()
  380. await noRecent.sessions.refresh()
  381. await new Promise(resolve => setTimeout(resolve, 0))
  382. expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
  383. expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
  384. stopEmpty()
  385. })
  386. it('a failed connect returns to waiting and retries on the next list change', async () => {
  387. const b = bench()
  388. b.api.onWorkspaceList = () => Promise.resolve(ok({
  389. items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
  390. }))
  391. b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
  392. const stop = b.workspaces.startInitialSelection()
  393. await b.workspaces.refresh()
  394. await b.sessions.refresh()
  395. await new Promise(resolve => setTimeout(resolve, 0))
  396. expect(b.api.callsOf('session.create')).toHaveLength(1)
  397. expect(b.sessions.list.getSnapshot().current).toBeUndefined()
  398. // Recovery: the next workspace-list change re-runs the reconcile.
  399. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
  400. await b.workspaces.refresh()
  401. await new Promise(resolve => setTimeout(resolve, 0))
  402. expect(b.api.callsOf('session.create')).toHaveLength(2)
  403. expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
  404. stop()
  405. })
  406. })