mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
One list call now materializes at most maxEntries child rows (config, default 1000 - GitHub's web-UI directory-listing bound). Candidates sort before probing so a cut level keeps the name-sorted head and symlink probing stops with the bound, and DirectoryListing carries a required truncated flag on the seam and the wire so clients can state incompleteness instead of silently missing tail entries.
430 lines
20 KiB
TypeScript
430 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 }],
|
|
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({}))
|
|
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()
|
|
})
|
|
})
|