Files
deepseek-harness/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
T
creatixchu 85ca8be104 feat(host,client): compose directory picking through slots — dual-face -native, no wire advertisement
ui-workspace's two trigger surfaces each declare a single-kind directory-flow
hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow,
same owner contract) and keep only the trigger and the adoption: the Open-local-
folder entry renders while the surface's hole is occupied, and the occupant
reports one picked path per open through the hole's owner conversation
(open/busy/onPicked/onCancel/onError).

directory-picker-native becomes dual-face: its browser half fills both holes
with a renderless occupant driving host.pickDirectory, so the cordis.yml row
that mounts the backend also composes the client interaction — a mismatch is
impossible and a second flow package fails at client load.

With composition wiring both sides, the host.describe.directoryPicker
advertisement and the client's kind branching lose their last consumer:
the field, WorkspacesService.directoryPickerKind(), the DirectoryPickerKind
wire type, and the picker's per-open describe read are deleted. The connection
fixture now serves a deterministic pickDirectory path so the keyless snapshot
drives the full pick-then-adopt flow. ui-workspace's hand-rolled declaration
deferral is replaced by the deferRegistration helper it duplicated.
2026-07-28 21:51:01 +08:00

429 lines
20 KiB
TypeScript

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 'cordis'
import AgentRegistry 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<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
}
function expectOk<T>(response: RpcResponse<T>): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
async function nextHostFrame(
stream: AsyncIterator<RpcRequest<HostFrame>>,
): Promise<RpcRequest<HostFrame>> {
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,
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
followup: () => {},
steer: () => {},
inject: () => {},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
}
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) {
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, {
provider: 'test',
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
return { api, ctx, storageDomain, workspaceRoot }
}
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 }],
}
},
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({}))
expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }))
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' }))).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('refuses the browse RPCs under a native composition', async () => {
const { api } = await harness()
expect((await api.host.listDirectory(request({}))).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 names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()
const responses = await Promise.all([
api.workspace.create(request({ name: 'alpha' })),
api.workspace.create(request({ name: 'alpha' })),
])
const created = responses.find(response => response.result.ok)
const duplicate = responses.find(response => !response.result.ok)
expect(created).toBeDefined()
expect(expectOk(created!)).toMatchObject({
created: true,
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
})
expect(duplicate?.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
})
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
})
it('adopts only existing directories and rejects unsafe names', async () => {
const { api, workspaceRoot } = await harness()
const existing = join(workspaceRoot, 'existing')
mkdirSync(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(workspaceRoot, '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)
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
const invalid = await api.workspace.create(request({ name }))
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
}
})
it('rejects different paths that derive the same Workspace title', async () => {
const { api, workspaceRoot } = await harness()
const first = join(workspaceRoot, 'one', 'project')
const second = join(workspaceRoot, 'two', 'project')
mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true })
expectOk(await api.workspace.create(request({ path: first })))
const conflict = await api.workspace.create(request({ path: second }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'project' } },
})
})
})
describe('session creation and Workspace membership', () => {
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
const { api, ctx } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: '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 } = await harness()
const created = expectOk(await api.workspace.create(request({ name: '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('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = 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<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const workspaceIncrement = nextHostFrame(stream)
const workspace = expectOk(await api.workspace.create(request({ name: '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<HostFrame, { type: 'host/workspace-changed' }> =>
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 } = 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<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const next = stream.next()
const failed = await api.workspace.create(request({ name: '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 } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: '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<RpcRequest<HostFrame>> =
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()
})
})