workspaces-service.client.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import type {
  4. ISessions, SessionListState, SessionSummary,
  5. } from '@deepseek-ai/dsh-api-session-controller/client'
  6. import type {
  7. IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
  8. } from '@deepseek-ai/dsh-api-workspace-controller/client'
  9. import {
  10. RpcId,
  11. type DirectoryListing,
  12. type IApiClient,
  13. type RpcError,
  14. type RpcResponse,
  15. } from '@deepseek-ai/dsh-client-connection/client'
  16. import { SessionId } from '@deepseek-ai/dsh-session/types'
  17. import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
  18. const sid = (id: string): SessionId => SessionId(id)
  19. const wid = (id: string): WorkspaceId => id as WorkspaceId
  20. afterEach(() => {
  21. vi.restoreAllMocks()
  22. })
  23. function workspace(
  24. id: string,
  25. sessionIds: readonly SessionId[] = [],
  26. createdAt = '2026-01-01T00:00:00.000Z',
  27. ): WorkspaceView {
  28. return {
  29. workspaceId: wid(id),
  30. path: `/w/${id}`,
  31. title: id,
  32. sessionIds,
  33. createdAt,
  34. updatedAt: createdAt,
  35. }
  36. }
  37. function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
  38. return {
  39. id: sid(id),
  40. displayTitle: id,
  41. running: false,
  42. blank: false,
  43. updatedAt: 0,
  44. ...overrides,
  45. }
  46. }
  47. function sessionState(
  48. summaries: readonly SessionSummary[] = [],
  49. current?: SessionId,
  50. phase: SessionListState['phase'] = 'ready',
  51. ): SessionListState {
  52. return {
  53. ids: summaries.map(item => item.id),
  54. byId: Object.fromEntries(summaries.map(item => [item.id, item])),
  55. current,
  56. phase,
  57. subagentsByParent: {},
  58. jobsBySession: {},
  59. currentAddress: undefined,
  60. }
  61. }
  62. function workspaceState(
  63. items: WorkspaceSnapshot['items'] = [],
  64. archivedSessionIds: readonly SessionId[] = [],
  65. phase: WorkspaceSnapshot['phase'] = 'ready',
  66. ): WorkspaceSnapshot {
  67. return {
  68. items,
  69. archivedSessionIds,
  70. phase,
  71. state: phase === 'ready' ? 'idle' : 'loading',
  72. error: null,
  73. }
  74. }
  75. class MutableSource<T> {
  76. private readonly listeners = new Set<() => void>()
  77. constructor(private value: T) {}
  78. getSnapshot(): T {
  79. return this.value
  80. }
  81. subscribe(listener: () => void): () => void {
  82. this.listeners.add(listener)
  83. return () => { this.listeners.delete(listener) }
  84. }
  85. set(value: T): void {
  86. this.value = value
  87. for (const listener of [...this.listeners]) listener()
  88. }
  89. update(update: (value: T) => T): void {
  90. this.set(update(this.value))
  91. }
  92. listenersSnapshot(): readonly (() => void)[] {
  93. return [...this.listeners]
  94. }
  95. }
  96. class FakeSessions {
  97. readonly list: MutableSource<SessionListState>
  98. readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
  99. readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
  100. readonly clear: ReturnType<typeof vi.fn<() => void>>
  101. constructor(initial: SessionListState) {
  102. this.list = new MutableSource(initial)
  103. this.create = vi.fn<ISessions['create']>(async options =>
  104. options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
  105. this.open = vi.fn((id: SessionId) => {
  106. this.list.update(state => ({ ...state, current: id }))
  107. })
  108. this.clear = vi.fn(() => {
  109. this.list.update(state => ({ ...state, current: undefined }))
  110. })
  111. }
  112. }
  113. class FakeWorkspaces implements IWorkspaces {
  114. readonly list: MutableSource<WorkspaceSnapshot>
  115. readonly archiveCalls: SessionId[] = []
  116. onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
  117. this.list.update(state => ({
  118. ...state,
  119. archivedSessionIds: [...state.archivedSessionIds, sessionId],
  120. }))
  121. }
  122. declare readonly create: IWorkspaces['create']
  123. declare readonly rename: IWorkspaces['rename']
  124. declare readonly delete: IWorkspaces['delete']
  125. declare readonly insertBefore: IWorkspaces['insertBefore']
  126. declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
  127. constructor(initial: WorkspaceSnapshot) {
  128. this.list = new MutableSource(initial)
  129. }
  130. archiveSession(sessionId: SessionId): Promise<void> {
  131. this.archiveCalls.push(sessionId)
  132. return this.onArchive(sessionId)
  133. }
  134. }
  135. let nextRpcId = 0
  136. function ok<T>(value: T): RpcResponse<T> {
  137. return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } }
  138. }
  139. function failed<T>(error: RpcError): RpcResponse<T> {
  140. return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } }
  141. }
  142. const listing: DirectoryListing = {
  143. path: '/home/u',
  144. home: '/home/u',
  145. crumbs: [{ name: '/', path: '/', hidden: false }],
  146. entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
  147. truncated: false,
  148. }
  149. class FakeApiClient implements IApiClient {
  150. readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = []
  151. onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({
  152. version: 'test',
  153. cwd: '/home/u',
  154. attachedSessions: 0,
  155. home: '/home/u',
  156. canOpenPath: true,
  157. }))
  158. onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null }))
  159. onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing))
  160. onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' }))
  161. onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
  162. declare readonly subagents: IApiClient['subagents']
  163. declare readonly skills: IApiClient['skills']
  164. declare readonly agentPresets: IApiClient['agentPresets']
  165. declare readonly goals: IApiClient['goals']
  166. declare readonly settings: IApiClient['settings']
  167. declare readonly credentials: IApiClient['credentials']
  168. declare readonly llm: IApiClient['llm']
  169. readonly host: IApiClient['host'] = {
  170. describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)),
  171. pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)),
  172. listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)),
  173. createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)),
  174. openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)),
  175. }
  176. callsOf(method: string): unknown[] {
  177. return this.calls.filter(call => call.method === method).map(call => call.payload)
  178. }
  179. private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
  180. this.calls.push({ method, payload })
  181. return response
  182. }
  183. }
  184. interface BenchOptions {
  185. readonly workspaces?: WorkspaceSnapshot
  186. readonly sessions?: SessionListState
  187. }
  188. function bench(options: BenchOptions = {}) {
  189. const ctx = new Context()
  190. const api = new FakeApiClient()
  191. const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
  192. const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
  193. const uiWorkspace = new UiWorkspaceService(
  194. ctx,
  195. api,
  196. workspaces,
  197. sessions as unknown as ISessions,
  198. )
  199. return { api, ctx, sessions, uiWorkspace, workspaces }
  200. }
  201. async function flush(): Promise<void> {
  202. await Promise.resolve()
  203. await Promise.resolve()
  204. }
  205. describe('UiWorkspaceService', () => {
  206. it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
  207. const b = bench()
  208. const memberBlank = sid('member-blank')
  209. const archivedBlank = sid('archived-blank')
  210. const summaries: readonly SessionSummary[] = [
  211. summary('stray', { blank: true, cwd: '/w/alpha' }),
  212. summary('member-blank', { blank: true, cwd: '/w/alpha' }),
  213. summary('active', { cwd: '/w/beta' }),
  214. summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
  215. ]
  216. b.workspaces.list.set(workspaceState([
  217. workspace('alpha', [memberBlank]),
  218. workspace('beta', [sid('active')]),
  219. workspace('gamma', [archivedBlank]),
  220. ], [archivedBlank]))
  221. b.sessions.list.set({
  222. ...sessionState(summaries, memberBlank),
  223. ids: [sid('missing'), ...summaries.map(item => item.id)],
  224. })
  225. await expect(Promise.all([
  226. b.uiWorkspace.connectWorkspace(wid('alpha')),
  227. b.uiWorkspace.connectWorkspace(wid('alpha')),
  228. ])).resolves.toEqual([memberBlank, memberBlank])
  229. expect(b.sessions.create).not.toHaveBeenCalled()
  230. const creation = Promise.withResolvers<SessionId>()
  231. b.sessions.create.mockImplementation(() => creation.promise)
  232. const first = b.uiWorkspace.connectWorkspace(wid('beta'))
  233. const second = b.uiWorkspace.connectWorkspace(wid('beta'))
  234. expect(b.sessions.create).toHaveBeenCalledTimes(1)
  235. creation.resolve(sid('fresh-beta'))
  236. await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
  237. b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
  238. await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
  239. expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
  240. await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
  241. .rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
  242. })
  243. it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
  244. const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
  245. const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
  246. const b = bench({
  247. sessions: sessionState([current, recent], current.id),
  248. workspaces: workspaceState([
  249. workspace('current-home', [current.id]),
  250. workspace('recent-home', [recent.id]),
  251. ]),
  252. })
  253. b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
  254. b.uiWorkspace.startSession(wid('recent-home'))
  255. await vi.waitFor(() => {
  256. expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
  257. })
  258. b.sessions.open(current.id)
  259. b.uiWorkspace.startSession()
  260. await vi.waitFor(() => {
  261. expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
  262. })
  263. b.sessions.clear()
  264. b.uiWorkspace.startSession()
  265. await vi.waitFor(() => {
  266. expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
  267. })
  268. const empty = bench()
  269. empty.uiWorkspace.startSession()
  270. expect(empty.sessions.clear).toHaveBeenCalledOnce()
  271. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  272. b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
  273. b.uiWorkspace.startSession(wid('recent-home'))
  274. await vi.waitFor(() => {
  275. expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
  276. })
  277. })
  278. it('opens the recent Workspace after both baselines arrive', async () => {
  279. const b = bench()
  280. b.sessions.create.mockResolvedValue(sid('initial'))
  281. const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
  282. const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
  283. b.workspaces.list.set(workspaceState([stableFirst, recent]))
  284. expect(b.sessions.create).not.toHaveBeenCalled()
  285. b.sessions.list.set(sessionState())
  286. await vi.waitFor(() => {
  287. expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
  288. })
  289. expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
  290. expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
  291. wid('stable-first'), wid('recent'),
  292. ])
  293. })
  294. it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
  295. const b = bench()
  296. b.sessions.create.mockResolvedValue(sid('initial'))
  297. b.workspaces.list.set(workspaceState([
  298. workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
  299. workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
  300. workspace('older', [], '2026-01-01T00:00:00.000Z'),
  301. ]))
  302. b.sessions.list.set(sessionState())
  303. await vi.waitFor(() => {
  304. expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
  305. })
  306. expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
  307. })
  308. it('retries failed initial selection and never overwrites a later selection', async () => {
  309. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  310. const b = bench()
  311. let attempts = 0
  312. b.sessions.create.mockImplementation(() => ++attempts === 1
  313. ? Promise.reject(new Error('attach exploded'))
  314. : Promise.resolve(sid('retry')))
  315. b.workspaces.list.set(workspaceState([workspace('recent')]))
  316. b.sessions.list.set(sessionState())
  317. await vi.waitFor(() => {
  318. expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
  319. })
  320. b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
  321. await vi.waitFor(() => {
  322. expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
  323. })
  324. expect(attempts).toBe(2)
  325. const changed = bench()
  326. const pending = Promise.withResolvers<SessionId>()
  327. changed.sessions.create.mockImplementation(() => pending.promise)
  328. changed.workspaces.list.set(workspaceState([workspace('recent')]))
  329. changed.sessions.list.set(sessionState())
  330. await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
  331. changed.sessions.open(sid('manual'))
  332. pending.resolve(sid('automatic'))
  333. await flush()
  334. expect(changed.sessions.open).toHaveBeenCalledTimes(1)
  335. expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
  336. })
  337. it('stops initial navigation when its Cordis lifetime is disposed', async () => {
  338. const success = bench()
  339. const resolved = Promise.withResolvers<SessionId>()
  340. success.sessions.create.mockImplementation(() => resolved.promise)
  341. success.workspaces.list.set(workspaceState([workspace('recent')]))
  342. success.sessions.list.set(sessionState())
  343. await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
  344. await success.ctx.fiber.dispose()
  345. resolved.resolve(sid('late'))
  346. await flush()
  347. expect(success.sessions.open).not.toHaveBeenCalled()
  348. success.workspaces.list.set(workspaceState([workspace('ignored')]))
  349. expect(success.sessions.create).toHaveBeenCalledOnce()
  350. const failure = bench()
  351. const rejected = Promise.withResolvers<SessionId>()
  352. failure.sessions.create.mockImplementation(() => rejected.promise)
  353. failure.workspaces.list.set(workspaceState([workspace('recent')]))
  354. failure.sessions.list.set(sessionState())
  355. await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
  356. const staleReconciles = failure.workspaces.list.listenersSnapshot()
  357. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  358. await failure.ctx.fiber.dispose()
  359. rejected.reject(new Error('late failure'))
  360. await flush()
  361. for (const reconcile of staleReconciles) reconcile()
  362. expect(warning).not.toHaveBeenCalled()
  363. expect(failure.sessions.create).toHaveBeenCalledOnce()
  364. })
  365. it('clears a current Session only after it enters the archive baseline', () => {
  366. const current = summary('current')
  367. const idle = summary('idle')
  368. const b = bench({
  369. sessions: sessionState([current, idle], current.id),
  370. workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
  371. })
  372. b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
  373. expect(b.sessions.clear).not.toHaveBeenCalled()
  374. b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
  375. expect(b.sessions.clear).toHaveBeenCalledOnce()
  376. b.sessions.open(idle.id)
  377. b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
  378. expect(b.sessions.clear).toHaveBeenCalledTimes(2)
  379. const archived = bench({
  380. sessions: sessionState([current], current.id),
  381. workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
  382. })
  383. expect(archived.sessions.clear).toHaveBeenCalledOnce()
  384. })
  385. it('forwards archive commands and preserves failures', async () => {
  386. const idle = sid('idle')
  387. const b = bench()
  388. await b.uiWorkspace.archiveSession(idle)
  389. expect(b.workspaces.archiveCalls).toEqual([idle])
  390. b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
  391. await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
  392. expect(b.workspaces.archiveCalls).toEqual([idle, idle])
  393. })
  394. it('passes directory operations to the Host and preserves structured browse failures', async () => {
  395. const b = bench()
  396. b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
  397. await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
  398. b.api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
  399. await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
  400. expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}])
  401. await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
  402. await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
  403. expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
  404. await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
  405. expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
  406. await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined()
  407. expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }])
  408. b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} }))
  409. await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
  410. b.api.onListDirectory = () => Promise.resolve(failed({
  411. code: 'directory-unreadable', message: 'denied', details: { path: '/private' },
  412. }))
  413. const listFailure = b.uiWorkspace.listDirectory('/private')
  414. await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
  415. await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
  416. b.api.onCreateDirectory = () => Promise.resolve(failed({
  417. code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' },
  418. }))
  419. await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
  420. rpcError: { code: 'directory-exists' },
  421. })
  422. b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} }))
  423. await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom')
  424. })
  425. })