workspaces-service.client.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
  4. import { SessionRuntime } from '../src/client/sessions/service.ts'
  5. import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
  6. import { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from '../src/client/workspaces/service.ts'
  7. import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.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 adopts the durable 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(['old', 'new'])
  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 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({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
  64. expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/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('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => {
  72. const api = new FakeApiClient()
  73. api.onWorkspaceList = () => Promise.resolve(ok({
  74. items: [workspace('one'), workspace('two'), workspace('three')] as never[],
  75. }))
  76. const manager = new WorkspaceManager(api)
  77. await manager.refresh()
  78. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
  79. api.onWorkspaceInsertBefore = () => gate.promise
  80. const pending = manager.insertBefore(wid('three'), wid('one'))
  81. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
  82. manager.handleHostEnvelope({
  83. rpcId: 'newer-order' as never,
  84. payload: {
  85. type: 'host/workspace-order-changed',
  86. workspaceIds: [wid('one'), wid('three'), wid('two')],
  87. },
  88. })
  89. gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
  90. await pending
  91. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
  92. api.onWorkspaceInsertBefore = () => Promise.resolve(err({
  93. code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' },
  94. }))
  95. const rejected = manager.insertBefore(wid('three'))
  96. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
  97. await expect(rejected).resolves.toMatchObject({ ok: false })
  98. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
  99. api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down'))
  100. const disconnected = manager.insertBefore(wid('three'), wid('one'))
  101. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
  102. await expect(disconnected).rejects.toThrow('transport down')
  103. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
  104. })
  105. it('rolls overlapping rejected reorders back to the last Host-confirmed order', async () => {
  106. const api = new FakeApiClient()
  107. api.onWorkspaceList = () => Promise.resolve(ok({
  108. items: [workspace('one'), workspace('two'), workspace('three')] as never[],
  109. }))
  110. const manager = new WorkspaceManager(api)
  111. await manager.refresh()
  112. const firstGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
  113. const secondGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
  114. let request = 0
  115. api.onWorkspaceInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
  116. const first = manager.insertBefore(wid('three'), wid('one'))
  117. const second = manager.insertBefore(wid('two'), wid('three'))
  118. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  119. firstGate.resolve(err({
  120. code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: 'three' },
  121. }))
  122. await expect(first).resolves.toMatchObject({ ok: false })
  123. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
  124. secondGate.resolve(err({
  125. code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: 'two' },
  126. }))
  127. await expect(second).resolves.toMatchObject({ ok: false })
  128. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
  129. })
  130. it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
  131. const api = new FakeApiClient()
  132. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  133. api.onWorkspaceList = () => gate.promise
  134. const manager = new WorkspaceManager(api)
  135. const hydration = manager.refresh()
  136. manager.handleHostEnvelope({
  137. rpcId: 'removed' as never,
  138. payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
  139. })
  140. gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
  141. await hydration
  142. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
  143. manager.handleHostEnvelope({
  144. rpcId: 'late-change' as never,
  145. payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
  146. })
  147. manager.handleHostEnvelope({
  148. rpcId: 'duplicate-remove' as never,
  149. payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
  150. })
  151. expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
  152. })
  153. it('removes from the unary delete echo while a refresh is in flight', async () => {
  154. const api = new FakeApiClient()
  155. api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
  156. const manager = new WorkspaceManager(api)
  157. await manager.refresh()
  158. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  159. api.onWorkspaceList = () => gate.promise
  160. const refresh = manager.refresh()
  161. await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
  162. expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
  163. expect(manager.getSnapshot().items).toEqual([])
  164. gate.resolve(ok({ items: [workspace('gone')] as never[] }))
  165. await refresh
  166. expect(manager.getSnapshot().items).toEqual([])
  167. })
  168. })
  169. describe('WorkspaceRuntime', () => {
  170. it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
  171. const ctx = new Context()
  172. const api = new FakeApiClient()
  173. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  174. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  175. api.onWorkspaceList = () => Promise.resolve(ok({
  176. items: [
  177. workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
  178. workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
  179. ] as never[],
  180. }))
  181. await workspaces.refresh()
  182. await Promise.resolve()
  183. expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
  184. api.onList = () => Promise.resolve(ok({
  185. items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[],
  186. }))
  187. await sessions.refresh()
  188. await Promise.resolve()
  189. await Promise.resolve()
  190. expect(workspaces.list.getSnapshot()).toMatchObject({
  191. baselinesReady: true,
  192. recentWorkspaceId: 'active',
  193. })
  194. expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
  195. })
  196. it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
  197. const ctx = new Context()
  198. const api = new FakeApiClient()
  199. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  200. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  201. api.onWorkspaceList = () => Promise.resolve(ok({
  202. items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
  203. }))
  204. api.onList = () => Promise.resolve(ok({
  205. items: [
  206. // Stray blank at alpha's path but NOT accounted under alpha (a CLI
  207. // session birthed at the host cwd), sorted before the member blank:
  208. // the scan must skip it and keep looking for a member hit.
  209. { sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' },
  210. // Blank session parked in alpha (cwd == workspace path canon AND
  211. // accounted under alpha): the reuse hit.
  212. { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
  213. // Non-blank sibling in beta must never be reused.
  214. { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
  215. // Stray blank at gamma's path but NOT accounted under gamma (a CLI
  216. // session birthed at the host cwd): cwd alone must not hijack it —
  217. // reuse would open a session gamma cannot show, so New Session mints
  218. // a fresh accounted one instead.
  219. { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
  220. ] as never[],
  221. }))
  222. api.onCreate = payload => Promise.resolve(ok({
  223. sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'),
  224. }))
  225. await Promise.all([workspaces.refresh(), sessions.refresh()])
  226. await Promise.resolve()
  227. // Hit: same workspace → the parked member blank is explicitly adopted
  228. // after the earlier cwd-matching non-member stray is skipped.
  229. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
  230. expect(api.callsOf('session.create')).toEqual([{
  231. workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true,
  232. }])
  233. // Resolution guarantee: the id is binding-resolvable synchronously.
  234. expect(sessions.binding(sid('s-blank'))).toBeDefined()
  235. // Miss: beta has only a non-blank session → host create with workspaceId.
  236. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
  237. await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
  238. expect(api.callsOf('session.create')).toEqual([
  239. { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true },
  240. { workspaceId: 'beta' },
  241. ])
  242. // Same guarantee on the create arm (draft hand-off writes the machine pre-open).
  243. expect(sessions.binding(sid('s-fresh'))).toBeDefined()
  244. // Miss: the stray blank matches gamma's path but is not a gamma member →
  245. // never reused, a fresh accounted session is created instead.
  246. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
  247. await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
  248. expect(api.callsOf('session.create')).toEqual([
  249. { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true },
  250. { workspaceId: 'beta' },
  251. { workspaceId: 'gamma' },
  252. ])
  253. // Unknown workspace fails loud instead of silently creating in nowhere.
  254. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
  255. // An archived blank is never reused: no surface can show it, so New
  256. // Session mints a fresh one for alpha instead.
  257. await workspaces.archiveSession(sid('s-blank'))
  258. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
  259. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
  260. })
  261. it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
  262. const ctx = new Context()
  263. const api = new FakeApiClient()
  264. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  265. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  266. api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
  267. api.onList = () => Promise.resolve(ok({
  268. items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
  269. }))
  270. api.onCreate = payload => Promise.resolve(ok({
  271. sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'),
  272. }))
  273. await Promise.all([workspaces.refresh(), sessions.refresh()])
  274. await Promise.resolve()
  275. const session = sessions.binding(sid('s-blank'))!.session
  276. api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
  277. await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
  278. await Promise.resolve()
  279. // Failure leaves blank intact, so the same session is still the reuse hit.
  280. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
  281. expect(api.callsOf('session.create')).toEqual([{
  282. workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true,
  283. }])
  284. })
  285. it('returns created Workspaces and preserves Host business errors', async () => {
  286. const ctx = new Context()
  287. const api = new FakeApiClient()
  288. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  289. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  290. api.onWorkspaceCreate = () => Promise.resolve(ok({
  291. workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
  292. }))
  293. await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
  294. expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
  295. expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
  296. api.onWorkspaceCreate = () => Promise.resolve(err({
  297. code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
  298. }))
  299. const rejected = workspaces.create({ path: '/missing' })
  300. await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/)
  301. await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError)
  302. })
  303. it('passes native directory selection and cancellation through without local state', async () => {
  304. const ctx = new Context()
  305. const api = new FakeApiClient()
  306. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  307. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  308. api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
  309. await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
  310. api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
  311. await expect(workspaces.pickDirectory()).resolves.toBeNull()
  312. expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
  313. api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
  314. await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
  315. })
  316. it('passes listings and creation through the browse wire, wrapping business failures', async () => {
  317. const ctx = new Context()
  318. const api = new FakeApiClient()
  319. const workspaces = new WorkspaceRuntime(ctx, api, new SessionRuntime(ctx, api, fakeRemote()))
  320. const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
  321. api.onListDirectory = () => Promise.resolve(ok(listing))
  322. await expect(workspaces.listDirectory()).resolves.toEqual(listing)
  323. await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
  324. // The optional path is omitted from the payload, not sent as undefined.
  325. expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
  326. api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
  327. const listFailure = workspaces.listDirectory('/x')
  328. await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
  329. await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
  330. await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
  331. expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
  332. api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
  333. await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
  334. })
  335. it('opens a filesystem path through the host without local state', async () => {
  336. const ctx = new Context()
  337. const api = new FakeApiClient()
  338. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  339. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  340. await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
  341. expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
  342. api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
  343. await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
  344. })
  345. it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
  346. const ctx = new Context()
  347. const api = new FakeApiClient()
  348. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  349. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  350. api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
  351. await workspaces.refresh()
  352. await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
  353. expect(workspaces.list.getSnapshot().items).toEqual([])
  354. api.onWorkspaceDelete = () => Promise.resolve(err({
  355. code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
  356. }))
  357. await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
  358. })
  359. it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
  360. const ctx = new Context()
  361. const api = new FakeApiClient()
  362. const workspaces = new WorkspaceRuntime(ctx, api, new SessionRuntime(ctx, api, fakeRemote()))
  363. api.onWorkspaceList = () => Promise.resolve(ok({
  364. items: [workspace('one'), workspace('two')] as never[],
  365. }))
  366. await workspaces.refresh()
  367. api.onWorkspaceInsertBefore = () => Promise.resolve(ok({
  368. workspaceIds: [wid('two'), wid('one')],
  369. }))
  370. await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
  371. expect(api.callsOf('workspace.insertBefore')).toEqual([{
  372. workspaceId: 'two', beforeWorkspaceId: 'one',
  373. }])
  374. expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
  375. api.onWorkspaceInsertBefore = () => Promise.resolve(err({
  376. code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
  377. }))
  378. await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
  379. })
  380. it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
  381. const ctx = new Context()
  382. const api = new FakeApiClient()
  383. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  384. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  385. api.onWorkspaceList = () => Promise.resolve(ok({
  386. items: [
  387. workspace('current-home', [sid('current')]),
  388. workspace('recent-home', [sid('recent')]),
  389. ] as never[],
  390. }))
  391. api.onList = () => Promise.resolve(ok({ items: [
  392. { sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
  393. { sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
  394. ] as never[] }))
  395. await Promise.all([workspaces.refresh(), sessions.refresh()])
  396. await Promise.resolve()
  397. sessions.open(sid('current'))
  398. const unresolved = new Promise<SessionId>(() => {})
  399. const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
  400. workspaces.startSession(wid('recent-home'))
  401. await Promise.resolve()
  402. expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
  403. workspaces.startSession()
  404. await Promise.resolve()
  405. expect(connect).toHaveBeenLastCalledWith(wid('current-home'))
  406. sessions.clear()
  407. workspaces.startSession()
  408. await Promise.resolve()
  409. expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
  410. const emptyCtx = new Context()
  411. const emptyApi = new FakeApiClient()
  412. const emptySessions = new SessionRuntime(emptyCtx, emptyApi, fakeRemote())
  413. const emptyWorkspaces = new WorkspaceRuntime(emptyCtx, emptyApi, emptySessions)
  414. const clear = vi.spyOn(emptySessions, 'clear')
  415. emptyWorkspaces.startSession()
  416. expect(clear).toHaveBeenCalledOnce()
  417. })
  418. it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
  419. const ctx = new Context()
  420. const api = new FakeApiClient()
  421. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  422. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  423. api.onList = () => Promise.resolve(ok({
  424. items: [
  425. { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
  426. { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
  427. ],
  428. }) as never)
  429. await sessions.refresh()
  430. sessions.open(sid('s-open'))
  431. // Archiving a non-current session installs the unary echo and keeps the selection.
  432. await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
  433. expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
  434. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
  435. expect(sessions.list.getSnapshot().current).toBe('s-open')
  436. // Archiving the current session clears it into the New Session view state.
  437. api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
  438. await workspaces.archiveSession(sid('s-open'))
  439. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
  440. expect(sessions.list.getSnapshot().current).toBeUndefined()
  441. // A Host failure leaves the set and the selection untouched.
  442. api.onWorkspaceArchiveSession = () => Promise.resolve(err({
  443. code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
  444. }))
  445. await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
  446. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
  447. // The changed frame and the list baseline both re-install the full set.
  448. workspaces.handleHostEnvelope({
  449. rpcId: 'frame' as never,
  450. payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] },
  451. } as never)
  452. // Frame installs ride the notifier's microtask batch before projecting.
  453. await new Promise(resolve => setTimeout(resolve, 0))
  454. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
  455. api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never)
  456. await workspaces.refresh()
  457. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
  458. })
  459. it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
  460. const ctx = new Context()
  461. const api = new FakeApiClient()
  462. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  463. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  464. api.onList = () => Promise.resolve(ok({
  465. items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
  466. }) as never)
  467. await sessions.refresh()
  468. sessions.open(sid('s-open'))
  469. // A stale baseline is in flight (older, empty set) when another tab's
  470. // archive frame lands: the frame clears the current selection and its
  471. // set survives the baseline's later resolution.
  472. const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
  473. api.onWorkspaceList = () => gate.promise
  474. const hydration = workspaces.refresh()
  475. workspaces.handleHostEnvelope({
  476. rpcId: 'frame' as never,
  477. payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
  478. } as never)
  479. await new Promise(resolve => setTimeout(resolve, 0))
  480. expect(sessions.list.getSnapshot().current).toBeUndefined()
  481. gate.resolve(ok({ items: [], archivedSessionIds: [] }))
  482. await hydration
  483. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
  484. // The next (fresh) baseline is authoritative again.
  485. api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
  486. await workspaces.refresh()
  487. expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
  488. })
  489. })
  490. describe('startInitialSelection', () => {
  491. function bench() {
  492. const ctx = new Context()
  493. const api = new FakeApiClient()
  494. const sessions = new SessionRuntime(ctx, api, fakeRemote())
  495. const workspaces = new WorkspaceRuntime(ctx, api, sessions)
  496. return { api, sessions, workspaces }
  497. }
  498. it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
  499. const b = bench()
  500. const stop = b.workspaces.startInitialSelection()
  501. // Nothing happens before both baselines land.
  502. expect(b.api.callsOf('session.create')).toHaveLength(0)
  503. b.api.onWorkspaceList = () => Promise.resolve(ok({
  504. items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
  505. }))
  506. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
  507. await b.workspaces.refresh()
  508. await b.sessions.refresh()
  509. // Store notifications and the connect round trip are microtask-batched.
  510. await new Promise(resolve => setTimeout(resolve, 0))
  511. expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
  512. expect(b.sessions.list.getSnapshot().current).toBe('s-new')
  513. stop()
  514. })
  515. it('stays idle when a session is already current or no recent Workspace exists', async () => {
  516. const withCurrent = bench()
  517. withCurrent.api.onList = () => Promise.resolve(ok({
  518. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
  519. }))
  520. await withCurrent.sessions.refresh()
  521. withCurrent.sessions.open(sid('s1'))
  522. withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
  523. const stopCurrent = withCurrent.workspaces.startInitialSelection()
  524. await withCurrent.workspaces.refresh()
  525. await new Promise(resolve => setTimeout(resolve, 0))
  526. expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
  527. stopCurrent()
  528. const noRecent = bench()
  529. const stopEmpty = noRecent.workspaces.startInitialSelection()
  530. await noRecent.workspaces.refresh()
  531. await noRecent.sessions.refresh()
  532. await new Promise(resolve => setTimeout(resolve, 0))
  533. expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
  534. expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
  535. stopEmpty()
  536. })
  537. it('a failed connect returns to waiting and retries on the next list change', async () => {
  538. const b = bench()
  539. b.api.onWorkspaceList = () => Promise.resolve(ok({
  540. items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
  541. }))
  542. b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
  543. const stop = b.workspaces.startInitialSelection()
  544. await b.workspaces.refresh()
  545. await b.sessions.refresh()
  546. await new Promise(resolve => setTimeout(resolve, 0))
  547. expect(b.api.callsOf('session.create')).toHaveLength(1)
  548. expect(b.sessions.list.getSnapshot().current).toBeUndefined()
  549. // Recovery: the next workspace-list change re-runs the reconcile.
  550. b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
  551. await b.workspaces.refresh()
  552. await new Promise(resolve => setTimeout(resolve, 0))
  553. expect(b.api.callsOf('session.create')).toHaveLength(2)
  554. expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
  555. stop()
  556. })
  557. })