mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
The goal domain has been served by GoalService's @Remote namespace since it shipped; the API Proxy copy was a second implementation of the same six mutations. Remove the goals contract, schemas, route rows, IApiClient stub, host implementation, and the fixture's compatibility face, leaving ctx.remote.goals as the only path. The fixture's goal fold keeps its coverage through the Goal Remotes: its lifecycle case moves out of the unary-dispatch test, which no longer has goal rows to cover.
487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
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 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')
|
|
})
|
|
})
|