workspaces-service.client.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import type {
  4. ISessions, SessionListState, SessionReference, SessionSummary,
  5. } from '@deepseek-ai/dsh-api-session-controller/client'
  6. import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
  7. import type {
  8. IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
  9. } from '@deepseek-ai/dsh-api-workspace-controller/client'
  10. import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client'
  11. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  12. import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
  13. import { SessionId } from '@deepseek-ai/dsh-session/types'
  14. import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
  15. import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
  16. import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
  17. const sid = (id: string): SessionId => SessionId(id)
  18. const wid = (id: string): WorkspaceId => id as WorkspaceId
  19. const contexts: Context[] = []
  20. afterEach(async () => {
  21. for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
  22. vi.unstubAllGlobals()
  23. vi.restoreAllMocks()
  24. })
  25. function persistSelection(selection: {
  26. readonly sessionId?: SessionId
  27. readonly subagentAddress?: SubagentAddress
  28. }): Map<string, string> {
  29. const backing = new Map([['dsh.sessions.current', JSON.stringify(selection)]])
  30. vi.stubGlobal('localStorage', {
  31. getItem: (key: string) => backing.get(key) ?? null,
  32. setItem: (key: string, value: string) => { backing.set(key, value) },
  33. removeItem: (key: string) => { backing.delete(key) },
  34. })
  35. return backing
  36. }
  37. function workspace(
  38. id: string,
  39. sessionIds: readonly SessionId[] = [],
  40. createdAt = '2026-01-01T00:00:00.000Z',
  41. ): WorkspaceView {
  42. return {
  43. workspaceId: wid(id),
  44. path: `/w/${id}`,
  45. title: id,
  46. sessionIds,
  47. createdAt,
  48. updatedAt: createdAt,
  49. }
  50. }
  51. function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
  52. return {
  53. id: sid(id),
  54. displayTitle: id,
  55. running: false,
  56. blank: false,
  57. updatedAt: 0,
  58. ...overrides,
  59. retainedBy: overrides.retainedBy ?? {},
  60. }
  61. }
  62. function sessionState(
  63. summaries: readonly SessionSummary[] = [],
  64. phase: SessionListState['phase'] = 'ready',
  65. ): SessionListState {
  66. return {
  67. ids: summaries.map(item => item.id),
  68. byId: Object.fromEntries(summaries.map(item => [item.id, item])),
  69. phase,
  70. subagentsByParent: {},
  71. jobsBySession: {},
  72. }
  73. }
  74. function workspaceState(
  75. items: WorkspaceSnapshot['items'] = [],
  76. archivedSessionIds: readonly SessionId[] = [],
  77. phase: WorkspaceSnapshot['phase'] = 'ready',
  78. ): WorkspaceSnapshot {
  79. return {
  80. items,
  81. archivedSessionIds,
  82. phase,
  83. state: phase === 'ready' ? 'idle' : 'loading',
  84. error: null,
  85. }
  86. }
  87. class MutableSource<T> {
  88. private readonly listeners = new Set<() => void>()
  89. constructor(private value: T) {}
  90. getSnapshot(): T {
  91. return this.value
  92. }
  93. subscribe(listener: () => void): () => void {
  94. this.listeners.add(listener)
  95. return () => { this.listeners.delete(listener) }
  96. }
  97. set(value: T): void {
  98. this.value = value
  99. for (const listener of [...this.listeners]) listener()
  100. }
  101. update(update: (value: T) => T): void {
  102. this.set(update(this.value))
  103. }
  104. listenersSnapshot(): readonly (() => void)[] {
  105. return [...this.listeners]
  106. }
  107. }
  108. interface RetainedSession {
  109. readonly reference: SessionReference
  110. readonly release: ReturnType<typeof vi.fn<() => void>>
  111. }
  112. class FakeSessions implements ISessions {
  113. readonly list: MutableSource<SessionListState>
  114. readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
  115. readonly fork = vi.fn<ISessions['fork']>(async () => sid('forked'))
  116. readonly retained: RetainedSession[] = []
  117. readonly refreshSubagents = vi.fn<ISessions['refreshSubagents']>(() => Promise.resolve())
  118. readonly retain = vi.fn<ISessions['retain']>((target) => {
  119. const release = vi.fn<() => void>()
  120. const sessionId = typeof target === 'string' ? target : target.childSessionId
  121. const binding = { sessionId } as SessionReference['binding']
  122. const reference: SessionReference = {
  123. sessionId,
  124. binding,
  125. ready: Promise.resolve(binding),
  126. release,
  127. [Symbol.dispose]: release,
  128. }
  129. this.retained.push({ reference, release })
  130. return reference
  131. })
  132. readonly subagentAddress = vi.fn<ISessions['subagentAddress']>()
  133. declare readonly using: ISessions['using']
  134. declare readonly retainInfo: ISessions['retainInfo']
  135. declare readonly searchResultLimit: ISessions['searchResultLimit']
  136. declare readonly setSubagentCatalogOpen: ISessions['setSubagentCatalogOpen']
  137. declare readonly refresh: ISessions['refresh']
  138. declare readonly search: ISessions['search']
  139. declare readonly scope: ISessions['scope']
  140. declare readonly scopeOf: ISessions['scopeOf']
  141. declare readonly sessionOf: ISessions['sessionOf']
  142. declare readonly binding: ISessions['binding']
  143. constructor(initial: SessionListState) {
  144. this.list = new MutableSource(initial)
  145. this.create = vi.fn<ISessions['create']>(async options =>
  146. options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
  147. }
  148. }
  149. class FakeWorkspaces implements IWorkspaces {
  150. readonly list: MutableSource<WorkspaceSnapshot>
  151. readonly archiveCalls: SessionId[] = []
  152. readonly unarchiveCalls: SessionId[] = []
  153. onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
  154. this.list.update(state => ({
  155. ...state,
  156. archivedSessionIds: [...state.archivedSessionIds, sessionId],
  157. }))
  158. }
  159. onUnarchive: IWorkspaces['unarchiveSession'] = async (sessionId) => {
  160. this.list.update(state => ({
  161. ...state,
  162. archivedSessionIds: state.archivedSessionIds.filter(id => id !== sessionId),
  163. }))
  164. }
  165. declare readonly create: IWorkspaces['create']
  166. declare readonly rename: IWorkspaces['rename']
  167. declare readonly delete: IWorkspaces['delete']
  168. declare readonly insertBefore: IWorkspaces['insertBefore']
  169. declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
  170. constructor(initial: WorkspaceSnapshot) {
  171. this.list = new MutableSource(initial)
  172. }
  173. archiveSession(sessionId: SessionId): Promise<void> {
  174. this.archiveCalls.push(sessionId)
  175. return this.onArchive(sessionId)
  176. }
  177. unarchiveSession(sessionId: SessionId): Promise<void> {
  178. this.unarchiveCalls.push(sessionId)
  179. return this.onUnarchive(sessionId)
  180. }
  181. }
  182. const listing: DirectoryListing = {
  183. path: '/home/u',
  184. home: '/home/u',
  185. crumbs: [{ name: '/', path: '/', hidden: false }],
  186. entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
  187. truncated: false,
  188. }
  189. /** The directory-picking Remote namespace, recorded and scripted per case. */
  190. class FakeDirectoryPicker {
  191. readonly calls: { method: string; payload: unknown }[] = []
  192. onPick: () => Promise<RemoteResult<string | null>> = () => Promise.resolve({ ok: true, value: null })
  193. onList: () => Promise<RemoteResult<DirectoryListing>> = () => Promise.resolve({ ok: true, value: listing })
  194. onCreateDirectory: () => Promise<RemoteResult<string>> =
  195. () => Promise.resolve({ ok: true, value: '/home/u/new' })
  196. readonly remote: ClientRemote['directoryPicker'] = {
  197. pick: () => this.record('pick', {}, this.onPick()),
  198. list: (path?: string) => this.record('list', { path }, this.onList()),
  199. createDirectory: (path: string, name: string) =>
  200. this.record('createDirectory', { path, name }, this.onCreateDirectory()),
  201. }
  202. callsOf(method: string): unknown[] {
  203. return this.calls.filter(call => call.method === method).map(call => call.payload)
  204. }
  205. private record<T>(method: string, payload: unknown, result: Promise<T>): Promise<T> {
  206. this.calls.push({ method, payload })
  207. return result
  208. }
  209. }
  210. interface BenchOptions {
  211. readonly workspaces?: WorkspaceSnapshot
  212. readonly sessions?: SessionListState
  213. readonly configureSessions?: (sessions: FakeSessions) => void
  214. }
  215. function bench(options: BenchOptions = {}) {
  216. const ctx = new Context()
  217. contexts.push(ctx)
  218. const layout = new LayoutController({
  219. selectPanel: vi.fn(), retainMainPanels: vi.fn(),
  220. setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(),
  221. setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(),
  222. }, () => true)
  223. const selectPanel = vi.spyOn(layout, 'selectPanel')
  224. ctx.provide('layout', layout)
  225. ctx.effect(() => () => { layout.dispose() })
  226. const directoryPicker = new FakeDirectoryPicker()
  227. const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
  228. const sessions = new FakeSessions(options.sessions ?? sessionState([], 'pending'))
  229. options.configureSessions?.(sessions)
  230. const uiWorkspace = new UiWorkspaceService(
  231. ctx,
  232. directoryPicker.remote,
  233. workspaces,
  234. sessions,
  235. )
  236. return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel }
  237. }
  238. describe('UiWorkspaceService', () => {
  239. it('retains an explicit main target before revealing its Conversation', () => {
  240. const b = bench()
  241. b.uiWorkspace.openSession(sid('target'))
  242. expect(b.selectPanel).toHaveBeenCalledWith(null)
  243. expect(b.sessions.retain).toHaveBeenCalledWith(sid('target'), { source: 'mainView' })
  244. expect(b.sessions.refreshSubagents).toHaveBeenCalledWith(sid('target'))
  245. })
  246. it('keeps the current panel when retaining the target fails', () => {
  247. const b = bench()
  248. b.sessions.retain.mockImplementationOnce(() => { throw new Error('open failed') })
  249. expect(() => { b.uiWorkspace.openSession(sid('target')) }).toThrow('open failed')
  250. expect(b.selectPanel).not.toHaveBeenCalled()
  251. })
  252. it('releases a newly retained target when Workspace preparation throws', async () => {
  253. const b = bench({
  254. workspaces: workspaceState([workspace('a')]),
  255. sessions: sessionState([], 'pending'),
  256. })
  257. b.uiWorkspace.openSession(sid('current'))
  258. const failure = new Error('preparation failed')
  259. await expect(b.uiWorkspace.openWorkspace(wid('a'), () => { throw failure })).rejects.toBe(failure)
  260. expect(b.sessions.retained.map(item => item.reference.sessionId)).toEqual([sid('current'), sid('created-a')])
  261. expect(b.sessions.retained[0]!.release).not.toHaveBeenCalled()
  262. expect(b.sessions.retained[1]!.release).toHaveBeenCalledOnce()
  263. })
  264. it('opens only the latest Workspace when creation finishes out of order', async () => {
  265. const b = bench({ workspaces: workspaceState([workspace('a'), workspace('b')]) })
  266. const first = Promise.withResolvers<SessionId>()
  267. const second = Promise.withResolvers<SessionId>()
  268. b.sessions.create.mockImplementation(options => options?.workspaceId === wid('a') ? first.promise : second.promise)
  269. const prepareA = vi.fn()
  270. const prepareB = vi.fn()
  271. const openingA = b.uiWorkspace.openWorkspace(wid('a'), prepareA)
  272. const openingB = b.uiWorkspace.openWorkspace(wid('b'), prepareB)
  273. second.resolve(sid('newer'))
  274. await openingB
  275. first.resolve(sid('older'))
  276. await openingA
  277. expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('newer'), { source: 'mainView' })
  278. expect(prepareB).toHaveBeenCalledExactlyOnceWith(sid('newer'))
  279. expect(prepareA).not.toHaveBeenCalled()
  280. })
  281. it('does not reopen a Workspace after a later panel or Session navigation', async () => {
  282. for (const panel of [true, false]) {
  283. const b = bench({ workspaces: workspaceState([workspace('a')]) })
  284. const created = Promise.withResolvers<SessionId>()
  285. b.sessions.create.mockReturnValueOnce(created.promise)
  286. const opening = b.uiWorkspace.openWorkspace(wid('a'))
  287. if (panel) b.layout.selectPanel('other-panel' as MainPanelId)
  288. else b.uiWorkspace.openSession(sid('chosen'))
  289. created.resolve(sid('late'))
  290. await opening
  291. expect(b.sessions.retain.mock.calls.map(args => args[0])).toEqual(panel ? [] : [sid('chosen')])
  292. }
  293. })
  294. it('does not deliver pending Workspace and fork targets after disposal', async () => {
  295. for (const kind of ['workspace', 'fork'] as const) {
  296. const b = bench({ workspaces: workspaceState([workspace('a')]) })
  297. const created = Promise.withResolvers<SessionId>()
  298. b.sessions.create.mockReturnValueOnce(created.promise)
  299. b.sessions.fork.mockReturnValueOnce(created.promise)
  300. const pending = kind === 'workspace' ? b.uiWorkspace.openWorkspace(wid('a')) : b.uiWorkspace.forkSession(sid('source'))
  301. await b.ctx.fiber.dispose()
  302. created.resolve(sid('late'))
  303. await pending
  304. expect(b.sessions.retain).not.toHaveBeenCalled()
  305. }
  306. })
  307. it('ignores a rejected startup selection and stale catalog callbacks after disposal', async () => {
  308. const created = Promise.withResolvers<SessionId>()
  309. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  310. const b = bench({
  311. workspaces: workspaceState([workspace('a')], [], 'ready'),
  312. sessions: sessionState([], 'pending'),
  313. configureSessions: (sessions) => { sessions.create.mockReturnValue(created.promise) },
  314. })
  315. const staleReconcile = b.sessions.list.listenersSnapshot()[0]!
  316. b.sessions.list.set(sessionState())
  317. await b.ctx.fiber.dispose()
  318. staleReconcile()
  319. created.reject(new Error('late failure'))
  320. await Promise.resolve()
  321. expect(warning).not.toHaveBeenCalled()
  322. })
  323. it('does not run startup selection after a main Session was chosen while catalogs loaded', () => {
  324. const b = bench()
  325. b.uiWorkspace.openSession(sid('chosen'))
  326. b.workspaces.list.set(workspaceState([workspace('a')]))
  327. b.sessions.list.set(sessionState())
  328. expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('chosen'), { source: 'mainView' })
  329. expect(b.sessions.create).not.toHaveBeenCalled()
  330. })
  331. it('forwards fork policy and rejects a failed fork', async () => {
  332. const b = bench()
  333. await b.uiWorkspace.forkSession(sid('source'))
  334. expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true })
  335. expect(b.sessions.retain).toHaveBeenCalledWith(sid('forked'), { source: 'mainView' })
  336. b.sessions.fork.mockRejectedValueOnce(new Error('fork failed'))
  337. await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed')
  338. })
  339. it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
  340. const b = bench({
  341. sessions: sessionState([
  342. summary('stray', { blank: true, cwd: '/w/a' }),
  343. summary('blank', { blank: true, cwd: '/w/a' }),
  344. summary('archived', { blank: true, cwd: '/w/b' }),
  345. ]),
  346. workspaces: workspaceState([workspace('a', [sid('blank')]), workspace('b', [sid('archived')])], [sid('archived')]),
  347. })
  348. await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('blank'))
  349. expect(b.sessions.create).not.toHaveBeenCalled()
  350. b.sessions.retain.mockClear()
  351. const created = Promise.withResolvers<SessionId>()
  352. b.sessions.create.mockReturnValue(created.promise)
  353. const first = b.uiWorkspace.connectWorkspace(wid('b'))
  354. const second = b.uiWorkspace.connectWorkspace(wid('b'))
  355. expect(b.sessions.create).toHaveBeenCalledOnce()
  356. created.resolve(sid('new'))
  357. await expect(Promise.all([first, second])).resolves.toEqual([sid('new'), sid('new')])
  358. await expect(b.uiWorkspace.connectWorkspace(wid('missing'))).rejects.toThrow('unknown workspace')
  359. expect(b.sessions.retain).not.toHaveBeenCalled()
  360. })
  361. it('uses only an explicit Workspace or the recent-Workspace policy for new Sessions', async () => {
  362. const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
  363. const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
  364. const b = bench({
  365. sessions: sessionState([current, recent]),
  366. workspaces: workspaceState([
  367. workspace('old'),
  368. workspace('current-home', [current.id]),
  369. workspace('recent-home', [recent.id]),
  370. ]),
  371. })
  372. b.uiWorkspace.startSession(wid('old'))
  373. await vi.waitFor(() => {
  374. expect(b.sessions.retain).toHaveBeenLastCalledWith(sid('created-old'), { source: 'mainView' })
  375. })
  376. b.uiWorkspace.openSession(current.id)
  377. b.uiWorkspace.startSession()
  378. await vi.waitFor(() => {
  379. expect(b.sessions.retain).toHaveBeenLastCalledWith(sid('created-current-home'), { source: 'mainView' })
  380. })
  381. const recentOnly = bench({
  382. sessions: sessionState([current, recent]),
  383. workspaces: workspaceState([
  384. workspace('current-home', [current.id]),
  385. workspace('recent-home', [recent.id]),
  386. ]),
  387. })
  388. recentOnly.uiWorkspace.startSession()
  389. await vi.waitFor(() => {
  390. expect(recentOnly.sessions.retain).toHaveBeenLastCalledWith(sid('created-recent-home'), { source: 'mainView' })
  391. })
  392. b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
  393. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  394. b.uiWorkspace.startSession(wid('recent-home'))
  395. await vi.waitFor(() => { expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error)) })
  396. const empty = bench()
  397. empty.uiWorkspace.startSession()
  398. expect(empty.selectPanel).toHaveBeenCalledWith(null)
  399. const missingMember = bench({
  400. sessions: sessionState(),
  401. workspaces: workspaceState([
  402. workspace('older', [sid('missing')], '2026-01-01T00:00:00.000Z'),
  403. workspace('newer', [], '2026-02-01T00:00:00.000Z'),
  404. ]),
  405. })
  406. missingMember.uiWorkspace.startSession()
  407. await vi.waitFor(() => {
  408. expect(missingMember.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newer') })
  409. })
  410. })
  411. it('releases a prepared Workspace target when synchronous preparation supersedes it', async () => {
  412. const b = bench({ workspaces: workspaceState([workspace('a')]) })
  413. await b.uiWorkspace.openWorkspace(wid('a'), () => {
  414. b.uiWorkspace.openSession(sid('override'))
  415. })
  416. expect(b.sessions.retained.map(item => item.reference.sessionId)).toEqual([
  417. sid('created-a'), sid('override'),
  418. ])
  419. expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
  420. expect(b.sessions.retained[1]!.release).not.toHaveBeenCalled()
  421. })
  422. it('opens the most recent Workspace after both startup catalogs become ready', async () => {
  423. const b = bench()
  424. b.workspaces.list.set(workspaceState([
  425. workspace('newest', [], '2026-03-01T00:00:00.000Z'),
  426. workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
  427. workspace('older', [], '2026-01-01T00:00:00.000Z'),
  428. ]))
  429. b.sessions.list.set(sessionState())
  430. await vi.waitFor(() => {
  431. expect(b.sessions.retain).toHaveBeenCalledWith(sid('created-newest'), { source: 'mainView' })
  432. })
  433. })
  434. it('does not let a pending startup Workspace replace a manual Session selection', async () => {
  435. const created = Promise.withResolvers<SessionId>()
  436. const b = bench({
  437. workspaces: workspaceState([workspace('a')]),
  438. sessions: sessionState(),
  439. configureSessions: (sessions) => { sessions.create.mockReturnValue(created.promise) },
  440. })
  441. b.uiWorkspace.openSession(sid('chosen'))
  442. created.resolve(sid('automatic'))
  443. await b.uiWorkspace.connectWorkspace(wid('a'))
  444. expect(b.sessions.retain.mock.calls.map(([target]) => target)).toEqual([sid('chosen')])
  445. })
  446. it('restores a persisted subagent address without a parent catalog', () => {
  447. const address: SubagentAddress = {
  448. parentSessionId: sid('parent'),
  449. childSessionId: sid('child'),
  450. mode: 'continuable',
  451. }
  452. persistSelection({ sessionId: address.childSessionId, subagentAddress: address })
  453. const b = bench({
  454. workspaces: workspaceState(),
  455. sessions: sessionState(),
  456. })
  457. expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(address, { source: 'mainView' })
  458. expect(b.sessions.refreshSubagents.mock.calls).toEqual([
  459. [address.parentSessionId],
  460. [address.childSessionId],
  461. ])
  462. })
  463. it('persists a catalog-resolved address after string subagent navigation', () => {
  464. const address: SubagentAddress = {
  465. parentSessionId: sid('parent'),
  466. childSessionId: sid('child'),
  467. mode: 'continuable',
  468. }
  469. const backing = persistSelection({})
  470. const b = bench({
  471. configureSessions: (sessions) => { sessions.subagentAddress.mockReturnValue(address) },
  472. })
  473. b.uiWorkspace.openSession(address.childSessionId)
  474. expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({
  475. sessionId: address.childSessionId,
  476. subagentAddress: address,
  477. })
  478. })
  479. it('reports and retries a failed persisted Session restoration', () => {
  480. const failure = new Error('restore failed')
  481. const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
  482. const sessions = sessionState([summary('saved')])
  483. persistSelection({ sessionId: sid('saved') })
  484. const b = bench({
  485. workspaces: workspaceState(),
  486. sessions,
  487. configureSessions: (face) => {
  488. face.retain.mockImplementationOnce(() => { throw failure })
  489. },
  490. })
  491. expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure)
  492. b.sessions.list.set(sessions)
  493. expect(b.sessions.retain).toHaveBeenCalledTimes(2)
  494. expect(b.sessions.retained).toHaveLength(1)
  495. expect(b.sessions.retained[0]!.reference.sessionId).toBe(sid('saved'))
  496. })
  497. it('clears a selected Session when an external archive snapshot arrives', () => {
  498. const b = bench()
  499. b.uiWorkspace.openSession(sid('current'))
  500. b.workspaces.list.set(workspaceState([], [sid('current')]))
  501. expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
  502. expect(b.selectPanel).toHaveBeenCalledTimes(2)
  503. })
  504. it('clears a selected Session after archiving it without an intervening snapshot', async () => {
  505. const b = bench()
  506. b.workspaces.onArchive = async () => {}
  507. b.uiWorkspace.openSession(sid('current'))
  508. await b.uiWorkspace.archiveSession(sid('current'))
  509. expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
  510. expect(b.selectPanel).toHaveBeenCalledTimes(2)
  511. })
  512. it('forwards archive commands and preserves failures', async () => {
  513. const idle = sid('idle')
  514. const b = bench()
  515. await b.uiWorkspace.archiveSession(idle)
  516. expect(b.workspaces.archiveCalls).toEqual([idle])
  517. b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
  518. await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
  519. expect(b.workspaces.archiveCalls).toEqual([idle, idle])
  520. })
  521. it('forwards unarchive commands and preserves failures', async () => {
  522. const idle = sid('idle')
  523. const b = bench()
  524. await b.uiWorkspace.unarchiveSession(idle)
  525. expect(b.workspaces.unarchiveCalls).toEqual([idle])
  526. b.workspaces.onUnarchive = () => Promise.reject(new Error('unarchive rejected'))
  527. await expect(b.uiWorkspace.unarchiveSession(idle)).rejects.toThrow('unarchive rejected')
  528. expect(b.workspaces.unarchiveCalls).toEqual([idle, idle])
  529. })
  530. it('passes directory operations to the Host and preserves structured browse failures', async () => {
  531. const b = bench()
  532. b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' })
  533. await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
  534. b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null })
  535. await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
  536. expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}])
  537. await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
  538. await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
  539. expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }])
  540. await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
  541. expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
  542. b.directoryPicker.onPick = () => Promise.resolve({
  543. ok: false, error: new RemoteError('gateway/internal', 'no chooser', {}),
  544. })
  545. await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
  546. b.directoryPicker.onList = () => Promise.resolve({
  547. ok: false, error: new RemoteError('directory-picker/unreadable', 'denied', { path: '/private' }),
  548. })
  549. const listFailure = b.uiWorkspace.listDirectory('/private')
  550. await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
  551. await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-picker/unreadable' } })
  552. b.directoryPicker.onCreateDirectory = () => Promise.resolve({
  553. ok: false, error: new RemoteError('directory-picker/exists', 'taken', { path: '/home/u/new' }),
  554. })
  555. await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
  556. rpcError: { code: 'directory-picker/exists' },
  557. })
  558. })
  559. })