| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- import { Context } from '@deepseek-ai/cordis'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import type {
- ISessions, SessionListState, SessionSummary,
- } from '@deepseek-ai/dsh-api-session-controller/client'
- import type {
- IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
- } from '@deepseek-ai/dsh-api-workspace-controller/client'
- import {
- RpcId,
- type DirectoryListing,
- type IApiClient,
- type RpcError,
- type RpcResponse,
- } from '@deepseek-ai/dsh-client-connection/client'
- import { SessionId } from '@deepseek-ai/dsh-session/types'
- import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
- const sid = (id: string): SessionId => SessionId(id)
- const wid = (id: string): WorkspaceId => id as WorkspaceId
- afterEach(() => {
- vi.restoreAllMocks()
- })
- function workspace(
- id: string,
- sessionIds: readonly SessionId[] = [],
- createdAt = '2026-01-01T00:00:00.000Z',
- ): WorkspaceView {
- return {
- workspaceId: wid(id),
- path: `/w/${id}`,
- title: id,
- sessionIds,
- createdAt,
- updatedAt: createdAt,
- }
- }
- function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
- return {
- id: sid(id),
- displayTitle: id,
- running: false,
- blank: false,
- updatedAt: 0,
- ...overrides,
- }
- }
- function sessionState(
- summaries: readonly SessionSummary[] = [],
- current?: SessionId,
- phase: SessionListState['phase'] = 'ready',
- ): SessionListState {
- return {
- ids: summaries.map(item => item.id),
- byId: Object.fromEntries(summaries.map(item => [item.id, item])),
- current,
- phase,
- subagentsByParent: {},
- jobsBySession: {},
- currentAddress: undefined,
- }
- }
- function workspaceState(
- items: WorkspaceSnapshot['items'] = [],
- archivedSessionIds: readonly SessionId[] = [],
- phase: WorkspaceSnapshot['phase'] = 'ready',
- ): WorkspaceSnapshot {
- return {
- items,
- archivedSessionIds,
- phase,
- state: phase === 'ready' ? 'idle' : 'loading',
- error: null,
- }
- }
- class MutableSource<T> {
- private readonly listeners = new Set<() => void>()
- constructor(private value: T) {}
- getSnapshot(): T {
- return this.value
- }
- subscribe(listener: () => void): () => void {
- this.listeners.add(listener)
- return () => { this.listeners.delete(listener) }
- }
- set(value: T): void {
- this.value = value
- for (const listener of [...this.listeners]) listener()
- }
- update(update: (value: T) => T): void {
- this.set(update(this.value))
- }
- listenersSnapshot(): readonly (() => void)[] {
- return [...this.listeners]
- }
- }
- class FakeSessions {
- readonly list: MutableSource<SessionListState>
- readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
- readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
- readonly clear: ReturnType<typeof vi.fn<() => void>>
- constructor(initial: SessionListState) {
- this.list = new MutableSource(initial)
- this.create = vi.fn<ISessions['create']>(async options =>
- options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
- this.open = vi.fn((id: SessionId) => {
- this.list.update(state => ({ ...state, current: id }))
- })
- this.clear = vi.fn(() => {
- this.list.update(state => ({ ...state, current: undefined }))
- })
- }
- }
- class FakeWorkspaces implements IWorkspaces {
- readonly list: MutableSource<WorkspaceSnapshot>
- readonly archiveCalls: SessionId[] = []
- onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
- this.list.update(state => ({
- ...state,
- archivedSessionIds: [...state.archivedSessionIds, sessionId],
- }))
- }
- declare readonly create: IWorkspaces['create']
- declare readonly rename: IWorkspaces['rename']
- declare readonly delete: IWorkspaces['delete']
- declare readonly insertBefore: IWorkspaces['insertBefore']
- declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
- constructor(initial: WorkspaceSnapshot) {
- this.list = new MutableSource(initial)
- }
- archiveSession(sessionId: SessionId): Promise<void> {
- this.archiveCalls.push(sessionId)
- return this.onArchive(sessionId)
- }
- }
- let nextRpcId = 0
- function ok<T>(value: T): RpcResponse<T> {
- return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } }
- }
- function failed<T>(error: RpcError): RpcResponse<T> {
- return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } }
- }
- const listing: DirectoryListing = {
- path: '/home/u',
- home: '/home/u',
- crumbs: [{ name: '/', path: '/', hidden: false }],
- entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
- truncated: false,
- }
- class FakeApiClient implements IApiClient {
- readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = []
- onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({
- version: 'test',
- cwd: '/home/u',
- attachedSessions: 0,
- home: '/home/u',
- canOpenPath: true,
- }))
- onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null }))
- onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing))
- onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' }))
- onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
- declare readonly subagents: IApiClient['subagents']
- declare readonly skills: IApiClient['skills']
- declare readonly agentPresets: IApiClient['agentPresets']
- declare readonly goals: IApiClient['goals']
- declare readonly settings: IApiClient['settings']
- declare readonly credentials: IApiClient['credentials']
- declare readonly llm: IApiClient['llm']
- readonly host: IApiClient['host'] = {
- describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)),
- pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)),
- listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)),
- createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)),
- openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)),
- }
- callsOf(method: string): unknown[] {
- return this.calls.filter(call => call.method === method).map(call => call.payload)
- }
- private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
- this.calls.push({ method, payload })
- return response
- }
- }
- interface BenchOptions {
- readonly workspaces?: WorkspaceSnapshot
- readonly sessions?: SessionListState
- }
- function bench(options: BenchOptions = {}) {
- const ctx = new Context()
- const api = new FakeApiClient()
- const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
- const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
- const uiWorkspace = new UiWorkspaceService(
- ctx,
- api,
- workspaces,
- sessions as unknown as ISessions,
- )
- return { api, ctx, sessions, uiWorkspace, workspaces }
- }
- async function flush(): Promise<void> {
- await Promise.resolve()
- await Promise.resolve()
- }
- describe('UiWorkspaceService', () => {
- it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
- const b = bench()
- const memberBlank = sid('member-blank')
- const archivedBlank = sid('archived-blank')
- const summaries: readonly SessionSummary[] = [
- summary('stray', { blank: true, cwd: '/w/alpha' }),
- summary('member-blank', { blank: true, cwd: '/w/alpha' }),
- summary('active', { cwd: '/w/beta' }),
- summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
- ]
- b.workspaces.list.set(workspaceState([
- workspace('alpha', [memberBlank]),
- workspace('beta', [sid('active')]),
- workspace('gamma', [archivedBlank]),
- ], [archivedBlank]))
- b.sessions.list.set({
- ...sessionState(summaries, memberBlank),
- ids: [sid('missing'), ...summaries.map(item => item.id)],
- })
- await expect(Promise.all([
- b.uiWorkspace.connectWorkspace(wid('alpha')),
- b.uiWorkspace.connectWorkspace(wid('alpha')),
- ])).resolves.toEqual([memberBlank, memberBlank])
- expect(b.sessions.create).not.toHaveBeenCalled()
- const creation = Promise.withResolvers<SessionId>()
- b.sessions.create.mockImplementation(() => creation.promise)
- const first = b.uiWorkspace.connectWorkspace(wid('beta'))
- const second = b.uiWorkspace.connectWorkspace(wid('beta'))
- expect(b.sessions.create).toHaveBeenCalledTimes(1)
- creation.resolve(sid('fresh-beta'))
- await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
- b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
- await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
- expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
- await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
- .rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
- })
- it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
- const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
- const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
- const b = bench({
- sessions: sessionState([current, recent], current.id),
- workspaces: workspaceState([
- workspace('current-home', [current.id]),
- workspace('recent-home', [recent.id]),
- ]),
- })
- b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
- b.uiWorkspace.startSession(wid('recent-home'))
- await vi.waitFor(() => {
- expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
- })
- b.sessions.open(current.id)
- b.uiWorkspace.startSession()
- await vi.waitFor(() => {
- expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
- })
- b.sessions.clear()
- b.uiWorkspace.startSession()
- await vi.waitFor(() => {
- expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
- })
- const empty = bench()
- empty.uiWorkspace.startSession()
- expect(empty.sessions.clear).toHaveBeenCalledOnce()
- const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
- b.uiWorkspace.startSession(wid('recent-home'))
- await vi.waitFor(() => {
- expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
- })
- })
- it('opens the recent Workspace after both baselines arrive', async () => {
- const b = bench()
- b.sessions.create.mockResolvedValue(sid('initial'))
- const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
- const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
- b.workspaces.list.set(workspaceState([stableFirst, recent]))
- expect(b.sessions.create).not.toHaveBeenCalled()
- b.sessions.list.set(sessionState())
- await vi.waitFor(() => {
- expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
- })
- expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
- expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
- wid('stable-first'), wid('recent'),
- ])
- })
- it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
- const b = bench()
- b.sessions.create.mockResolvedValue(sid('initial'))
- b.workspaces.list.set(workspaceState([
- workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
- workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
- workspace('older', [], '2026-01-01T00:00:00.000Z'),
- ]))
- b.sessions.list.set(sessionState())
- await vi.waitFor(() => {
- expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
- })
- expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
- })
- it('retries failed initial selection and never overwrites a later selection', async () => {
- const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- const b = bench()
- let attempts = 0
- b.sessions.create.mockImplementation(() => ++attempts === 1
- ? Promise.reject(new Error('attach exploded'))
- : Promise.resolve(sid('retry')))
- b.workspaces.list.set(workspaceState([workspace('recent')]))
- b.sessions.list.set(sessionState())
- await vi.waitFor(() => {
- expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
- })
- b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
- await vi.waitFor(() => {
- expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
- })
- expect(attempts).toBe(2)
- const changed = bench()
- const pending = Promise.withResolvers<SessionId>()
- changed.sessions.create.mockImplementation(() => pending.promise)
- changed.workspaces.list.set(workspaceState([workspace('recent')]))
- changed.sessions.list.set(sessionState())
- await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
- changed.sessions.open(sid('manual'))
- pending.resolve(sid('automatic'))
- await flush()
- expect(changed.sessions.open).toHaveBeenCalledTimes(1)
- expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
- })
- it('stops initial navigation when its Cordis lifetime is disposed', async () => {
- const success = bench()
- const resolved = Promise.withResolvers<SessionId>()
- success.sessions.create.mockImplementation(() => resolved.promise)
- success.workspaces.list.set(workspaceState([workspace('recent')]))
- success.sessions.list.set(sessionState())
- await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
- await success.ctx.fiber.dispose()
- resolved.resolve(sid('late'))
- await flush()
- expect(success.sessions.open).not.toHaveBeenCalled()
- success.workspaces.list.set(workspaceState([workspace('ignored')]))
- expect(success.sessions.create).toHaveBeenCalledOnce()
- const failure = bench()
- const rejected = Promise.withResolvers<SessionId>()
- failure.sessions.create.mockImplementation(() => rejected.promise)
- failure.workspaces.list.set(workspaceState([workspace('recent')]))
- failure.sessions.list.set(sessionState())
- await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
- const staleReconciles = failure.workspaces.list.listenersSnapshot()
- const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
- await failure.ctx.fiber.dispose()
- rejected.reject(new Error('late failure'))
- await flush()
- for (const reconcile of staleReconciles) reconcile()
- expect(warning).not.toHaveBeenCalled()
- expect(failure.sessions.create).toHaveBeenCalledOnce()
- })
- it('clears a current Session only after it enters the archive baseline', () => {
- const current = summary('current')
- const idle = summary('idle')
- const b = bench({
- sessions: sessionState([current, idle], current.id),
- workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
- })
- b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
- expect(b.sessions.clear).not.toHaveBeenCalled()
- b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
- expect(b.sessions.clear).toHaveBeenCalledOnce()
- b.sessions.open(idle.id)
- b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
- expect(b.sessions.clear).toHaveBeenCalledTimes(2)
- const archived = bench({
- sessions: sessionState([current], current.id),
- workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
- })
- expect(archived.sessions.clear).toHaveBeenCalledOnce()
- })
- it('forwards archive commands and preserves failures', async () => {
- const idle = sid('idle')
- const b = bench()
- await b.uiWorkspace.archiveSession(idle)
- expect(b.workspaces.archiveCalls).toEqual([idle])
- b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
- await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
- expect(b.workspaces.archiveCalls).toEqual([idle, idle])
- })
- it('passes directory operations to the Host and preserves structured browse failures', async () => {
- const b = bench()
- b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
- await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
- b.api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
- await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
- expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}])
- await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
- await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
- expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
- await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
- expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
- await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined()
- expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }])
- b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} }))
- await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
- b.api.onListDirectory = () => Promise.resolve(failed({
- code: 'directory-unreadable', message: 'denied', details: { path: '/private' },
- }))
- const listFailure = b.uiWorkspace.listDirectory('/private')
- await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
- await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
- b.api.onCreateDirectory = () => Promise.resolve(failed({
- code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' },
- }))
- await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
- rpcError: { code: 'directory-exists' },
- })
- b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} }))
- await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom')
- })
- })
|