import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' let nextRpc = 1 function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload } } function expectOk(response: RpcResponse): T { expect(response.result.ok).toBe(true) if (!response.result.ok) throw new Error('unreachable') return response.result.value } async function nextHostFrame( stream: AsyncIterator>, ): Promise> { const next = await stream.next() if (next.done === true) throw new Error('Host stream ended before the expected increment') return next.value } function stubAgent(session: Session): Agent { return { id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx: new Context(), send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), inject: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) await ctx.plugin(Storage) ctx.storage.backend.register('memory', new MemoryStorageBackend()) const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) ctx.storage.mount('domain', storageDomain) ctx.provide('storageDomain', storageDomain) ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) await ctx.plugin(WorkspaceRegistry) const factory: AgentFactory = { async createAgent(_ownerCtx, options) { const session = ctx.sessions.create( options.sessionId, options.meta === undefined ? {} : { meta: options.meta }, ) const agent = stubAgent(session) const unregister = ctx.agents.register(agent) return { agent, dispose: () => { unregister() return Promise.resolve() }, } }, async resume() { throw new Error('test harness has no persisted sessions') }, } ctx.agents.setFactory(factory) // Structural picker fake: the gateway only reads capability(); a stable // object per harness mirrors the seam's stability contract. ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd: root, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) return { api, ctx, storageDomain, root } } /** Stage one directory under the harness root for path adoption. */ function stageDir(root: string, name: string): string { const path = join(root, name) mkdirSync(path) return path } describe('host.pickDirectory', () => { it('returns a selected path or explicit cancellation from the native capability', async () => { const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' }) expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: '/tmp/project' } }) const cancelled = await harness(undefined, { kind: 'native', pick: async () => null }) expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: null } }) }) it('propagates abort into the native capability as a cancelled RPC error', async () => { const { api } = await harness(undefined, { kind: 'native', pick: signal => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), }) const abort = new AbortController() const pending = api.host.pickDirectory(request({}), abort.signal) abort.abort() expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) it('folds a non-abort native-chooser failure into an internal error', async () => { const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) const response = await api.host.pickDirectory(request({}), new AbortController().signal) expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) }) it('refuses the native RPC under a browse composition', async () => { const { api } = await harness(undefined, BROWSE_STUB) const response = await api.host.pickDirectory(request({}), new AbortController().signal) expect(response.result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, }) }) }) /** Canned browse capability: one listing, one created path, typed failures on demand. */ const BROWSE_STUB: DirectoryPickerCapability = { kind: 'browse', list: async (path) => { if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') const target = path ?? '/home/user' return { path: target, home: '/home/user', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], truncated: false, } }, createDirectory: async (path, name) => { if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') if (name === 'unwritable') throw new Error('disk detached') return `${path}/${name}` }, } describe('host.listDirectory / host.createDirectory', () => { it('serves listings and creation through the browse capability, defaulting to home', async () => { const { api } = await harness(undefined, BROWSE_STUB) const home = await api.host.listDirectory(request({}), new AbortController().signal) expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal) expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) }) it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => { const { api } = await harness(undefined, BROWSE_STUB) expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } }, }) expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({ ok: false, error: { code: 'directory-exists' }, }) expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({ ok: false, error: { code: 'internal' }, }) }) it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => { const { api } = await harness(undefined, { kind: 'browse', list: (_path, signal) => new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) }), createDirectory: async () => '/never', }) const abort = new AbortController() const pending = api.host.listDirectory(request({}), abort.signal) abort.abort() expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) it('refuses the browse RPCs under a native composition', async () => { const { api } = await harness() expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) }) }) describe('host.openPath', () => { it('opens through the injected native boundary', async () => { const opened: string[] = [] const { api } = await harness(undefined, undefined, { openPath: async (path) => { opened.push(path) }, }) expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result) .toEqual({ ok: true, value: { opened: true } }) expect(opened).toEqual(['/tmp/a.txt']) }) it('propagates abort into the native boundary as a cancelled RPC error', async () => { const { api } = await harness(undefined, undefined, { openPath: (_path, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), }) const abort = new AbortController() const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal) abort.abort() expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) }) describe('workspace.create', () => { it('serializes concurrent creates of one path into a single registration', async () => { const { api, root } = await harness() const target = stageDir(root, 'alpha') const responses = await Promise.all([ api.workspace.create(request({ path: target })), api.workspace.create(request({ path: target })), ]) const values = responses.map(response => expectOk(response)) const created = values.find(value => value.created) const resolved = values.find(value => !value.created) expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } }) expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1) }) it('adopts only existing directories', async () => { const { api, root } = await harness() const existing = stageDir(root, 'existing') const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } }) expectOk(await api.workspace.rename(request({ workspaceId: first.workspace.workspaceId, title: 'renamed-existing', }))) const reopened = expectOk(await api.workspace.create(request({ path: existing }))) expect(reopened.workspace.title).toBe('renamed-existing') const missing = join(root, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) expect(existsSync(missing)).toBe(false) }) it('adopts different paths that derive the same Workspace title', async () => { const { api, root } = await harness() const first = join(root, 'one', 'project') const second = join(root, 'two', 'project') mkdirSync(first, { recursive: true }) mkdirSync(second, { recursive: true }) const firstResult = expectOk(await api.workspace.create(request({ path: first }))) const secondResult = expectOk(await api.workspace.create(request({ path: second }))) expect(firstResult).toMatchObject({ created: true, workspace: { path: first, title: 'project' }, }) expect(secondResult).toMatchObject({ created: true, workspace: { path: second, title: 'project' }, }) expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId) expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path)) .toEqual([second, first]) }) }) describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { const { api, ctx, root } = await harness() const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const sessionId = SessionId('session-workspace-preallocated') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1) const ungrouped = SessionId('session-cwd-only') expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped }))) expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped) const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId })) expect(conflict.result).toMatchObject({ ok: false, error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } }, }) const missing = await api.sessions.create(request({ workspaceId: 'missing-workspace' as WorkspaceId, sessionId: SessionId('session-missing-workspace'), })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) }) it('retains a published session when attachment fails and repairs it on retry', async () => { const { api, ctx, root } = await harness() const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const workspace = ctx.workspace.list()[0] if (workspace === undefined) throw new Error('workspace missing from registry') vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) const sessionId = SessionId('session-attach-retry') const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })) expect(failed.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } }, }) expect(ctx.agents.get(sessionId)).toBeDefined() expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId]) }) }) describe('Host Workspace increments', () => { it('projects subagent origin in attached summaries and creation increments', async () => { const { api, ctx } = await harness() const abort = new AbortController() const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const pending = nextHostFrame(stream) const childId = SessionId('session-subagent-child') ctx.sessions.create(childId, { meta: { cwd: '/tmp', parentSession: SessionId('session-parent'), origin: 'subagent', }, }) expect(await pending).toMatchObject({ payload: { type: 'host/session-added', sessionId: childId, parentSessionId: 'session-parent', origin: 'subagent', }, }) expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual( expect.objectContaining({ sessionId: childId, origin: 'subagent' }), ) abort.abort() }) it('streams committed Workspace and Session increments after empty baselines', async () => { const { api, root } = await harness() expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) const abort = new AbortController() const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const workspaceIncrement = nextHostFrame(stream) const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace expect(await workspaceIncrement).toMatchObject({ payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, }) const sessionId = SessionId('session-streamed-workspace') const pending = nextHostFrame(stream) expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) const increments: HostFrame[] = [] increments.push((await pending).payload) while (increments.length < 2) { const next = await stream.next() if (next.done === true) throw new Error('Host stream ended before both increments') increments.push(next.value.payload) } expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({ // A just-created session has no events: the frame constantly carries blank:true. type: 'host/session-added', sessionId, blank: true, cwd: workspace.path, }) const workspaceChanged = increments.find( (increment): increment is Extract => increment.type === 'host/workspace-changed', ) expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId]) abort.abort() }) it('does not publish a Workspace whose registry-order commit fails', async () => { const { api, storageDomain, root } = await harness() const domain = storageDomain.get('workspace') if (domain === undefined) throw new Error('workspace domain is not open') vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) const abort = new AbortController() const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const next = stream.next() const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') })) expect(failed.result.ok).toBe(false) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) abort.abort() expect(await next).toMatchObject({ done: true }) }) it('deletes the registration, keeps its session and folder, and streams one removal', async () => { const { api, ctx, root } = await harness() const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace const sessionId = SessionId('session-kept-after-workspace-delete') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) const abort = new AbortController() const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const removed = nextHostFrame(stream) expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))) expect(await removed).toMatchObject({ payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId }, }) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) expect(ctx.agents.get(sessionId)).toBeDefined() expect(existsSync(workspace.path)).toBe(true) const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } }, }) const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace expect(reregistered.workspaceId).not.toBe(workspace.workspaceId) expect(reregistered.path).toBe(workspace.path) expect(reregistered.sessionIds).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) abort.abort() }) it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { const { api, root } = await harness() const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace const sessionId = SessionId('session-to-archive') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) const abort = new AbortController() const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const changed = nextHostFrame(stream) expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) .toEqual([sessionId]) expect(await changed).toMatchObject({ payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] }, }) // Accounting and the session itself are untouched; list re-baselines the set. const listed = expectOk(await api.workspace.list(request({}))) expect(listed.archivedSessionIds).toEqual([sessionId]) expect(listed.items[0]?.sessionIds).toEqual([sessionId]) expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) // The idempotent repeat emits no second frame: the next observed frame is // the workspace-changed of a later attach, not another archive snapshot. const after = nextHostFrame(stream) expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) .toEqual([sessionId]) const otherSession = SessionId('session-after-archive') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession }))) expect((await after).payload.type).not.toBe('host/archived-sessions-changed') const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } }, }) abort.abort() }) })