Files
deepseek-harness/packages/api/session-controller/tests/commands-create-fork.host.spec.ts
T
Yichen Jiang 1b8a55116e fix(session): claim named Sessions in arrival order before resolving presets
session.create resolved the requested preset before queueing on the named
identity, so a later request whose preset resolved first could run first, and
an undeclared preset masked the directory conflict the README promises.
Queue first, check the named Session's directory and ownership, then resolve
the preset inside the queue, and report the preset each request left the
Session running instead of reading it after the queue moved on. The no-roster
conflict message now names its actual cause.
2026-10-09 11:29:04 +08:00

296 lines
12 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace'
import { describe, expect, it, vi } from 'vitest'
import {
ApiSessionAgentController,
ApiSessionCwdConflict,
type ApiSessionCreated,
type ApiSessionCreateRequest,
} from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
await expect(operation).rejects.toMatchObject({ code })
}
/** The Agent controller with a fixed Agent composition, and optionally a fixed creation outcome. */
function controllerAgents(
ctx: Context,
fixed: {
readonly composeAgent?: ApiSessionAgentController['composeAgent']
readonly createSession?: ApiSessionAgentController['createSession']
} = {},
): ApiSessionAgentController {
const agents = new ApiSessionAgentController(ctx)
vi.spyOn(agents, 'composeAgent').mockImplementation(fixed.composeAgent ?? (() => Promise.resolve({ setup: () => {} })))
if (fixed.createSession !== undefined) vi.spyOn(agents, 'createSession').mockImplementation(fixed.createSession)
return agents
}
/** A creation outcome that publishes `sessionId` in `cwd` as a stand-in Agent. */
function publishedSession(ctx: Context, sessionId: SessionId) {
return (request: ApiSessionCreateRequest): Promise<ApiSessionCreated> => {
const session = ctx.sessions.create(sessionId, { meta: { cwd: request.cwd } })
return Promise.resolve({ agent: { id: session.id, session, status: 'idle', ctx } as Agent, agentPreset: undefined })
}
}
async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
return ctx
}
describe('Session creation failures', () => {
it('creates in the default cwd without a named identity when no explicit target is supplied', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [], archivedSessionIds: [] } as never)
const createSession = vi.fn(publishedSession(ctx, SessionId('session-minted')))
const controller = new SessionCommandController(ctx, controllerAgents(ctx, { createSession }), '/default-workspace')
const created = await controller.create({})
expect(created).toEqual({ sessionId: 'session-minted' })
expect(createSession).toHaveBeenCalledOnce()
const request = createSession.mock.calls[0]?.[0]
expect(request?.cwd).toBe('/default-workspace')
expect(Object.keys(request ?? {}).sort()).toEqual(['cwd', 'reusable'])
await ctx.fiber.dispose()
})
it('maps missing Workspaces and attachment failures', async () => {
const missing = await baseContext()
missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
const missingController = new SessionCommandController(
missing,
controllerAgents(missing),
'/default',
)
await expectFailure(missingController.create({
workspaceId: 'missing' as WorkspaceId,
}), 'workspace/not-found')
await missing.fiber.dispose()
const failed = await baseContext()
const workspace = {
id: 'workspace-1' as WorkspaceId,
path: '/workspace',
attachSession: () => Promise.reject(new Error('read-only workspace')),
} as unknown as Workspace
failed.provide('workspaceRegistry', {
get: () => workspace,
list: () => [workspace],
} as never)
const failedController = new SessionCommandController(
failed,
controllerAgents(failed, { createSession: publishedSession(failed, SessionId('workspace-session')) }),
'/default',
)
await expectFailure(failedController.create({
sessionId: SessionId('workspace-session'),
workspaceId: workspace.id,
}), 'session/workspace-attach-failed')
await failed.fiber.dispose()
})
it.each([
{
error: new RemoteError(
'agent-preset/invalid',
'agent-presets: preset "broken" failed to mount: invalid composition',
{ agentPreset: 'broken', reason: 'invalid composition' },
),
code: 'agent-preset/invalid',
},
{
error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined),
code: 'session/conflict',
},
{
error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'),
code: 'session/conflict',
},
{
error: new Error('factory unavailable'),
code: 'gateway/internal',
},
])('maps $code creation failures', async ({ error, code }) => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
const controller = new SessionCommandController(
ctx,
controllerAgents(ctx, { createSession: () => Promise.reject(error) }),
'/default',
)
await expectFailure(controller.create({
sessionId: SessionId('failed-create'), cwd: '/requested',
}), code)
await ctx.fiber.dispose()
})
it('rejects contradictory create targets', async () => {
const ctx = await baseContext()
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
await expectFailure(controller.create({
workspaceId: 'workspace-1' as WorkspaceId,
cwd: '/workspace',
}), 'gateway/bad-request')
await ctx.fiber.dispose()
})
})
function completedSession(
ctx: Context,
id: string,
cwd?: string,
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
) {
const session = ctx.sessions.create(SessionId(id), {
meta: { ...(cwd === undefined ? {} : { cwd }), ...lineage },
})
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'work' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return session
}
function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle {
return {
agent: { id: sessionId, status: 'idle', ctx } as Agent,
dispose: () => Promise.resolve(),
}
}
describe('Session fork failures', () => {
it('maps missing cold sources with and without persistence', async () => {
const withoutPersistence = await baseContext()
withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never)
const unavailableController = new SessionCommandController(
withoutPersistence, controllerAgents(withoutPersistence), '/default',
)
await expectFailure(unavailableController.fork({
sessionId: SessionId('missing'),
}), 'session/not-found')
await withoutPersistence.fiber.dispose()
const missing = await baseContext()
missing.provide('workspaceRegistry', { list: () => [] } as never)
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
}) as never)
const missingController = new SessionCommandController(missing, controllerAgents(missing), '/default')
await expectFailure(missingController.fork({
sessionId: SessionId('missing'),
}), 'session/not-found')
await missing.fiber.dispose()
})
it('maps an observation failure to an internal fork error', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal')
await ctx.fiber.dispose()
})
it('rejects a Session with no completed turn', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const source = ctx.sessions.create(SessionId('empty-source'))
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable')
await expect(controller.fork({ sessionId: source.id, atSeq: 0 })).rejects.toMatchObject({
code: 'session/fork-unavailable',
message: 'event 0 does not exist in session "empty-source" (last seq: none)',
})
await ctx.fiber.dispose()
})
it('maps lineage lookup and Agent creation failures', async () => {
const lineage = await baseContext()
lineage.provide('workspaceRegistry', { list: () => [] } as never)
vi.spyOn(lineage.sessionQuery, 'traceSession')
.mockRejectedValue(new Error('lineage unavailable'))
const child = completedSession(lineage, 'subagent-source', '/workspace', {
parentSession: SessionId('parent'),
origin: 'subagent',
})
const lineageController = new SessionCommandController(lineage, controllerAgents(lineage), '/default')
await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal')
await lineage.fiber.dispose()
const creation = await baseContext()
creation.provide('workspaceRegistry', { list: () => [] } as never)
const source = completedSession(creation, 'creation-source', '/workspace')
vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed'))
const creationController = new SessionCommandController(creation, controllerAgents(creation), '/default')
await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal')
await creation.fiber.dispose()
})
it('omits absent cwd and preset metadata before reporting Workspace attachment failure', async () => {
const ctx = await baseContext()
const source = completedSession(ctx, 'workspace-source')
const workspace = {
id: 'workspace-1' as WorkspaceId,
sessionIds: [source.id],
attachSession: () => Promise.reject(new Error('workspace write failed')),
} as unknown as Workspace
ctx.provide('workspaceRegistry', { list: () => [workspace] } as never)
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
(options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
)
const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default')
await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed')
const options = create.mock.calls[0]?.[0]
if (options === undefined) throw new Error('Agent creation was not attempted')
expect(options.meta).not.toHaveProperty('cwd')
expect(options.meta).not.toHaveProperty('agentPreset')
await ctx.fiber.dispose()
})
it('carries the composed Agent preset into the child metadata', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const source = completedSession(ctx, 'preset-source', '/workspace')
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
(options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
)
const controller = new SessionCommandController(ctx, controllerAgents(ctx, {
composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }),
}), '/default')
const forked = await controller.fork({ sessionId: source.id })
expect(forked.sessionId).toMatch(/^session-/)
const options = create.mock.calls[0]?.[0]
if (options === undefined) throw new Error('Agent creation was not attempted')
expect(options.meta?.agentPreset).toBe('minimal')
await ctx.fiber.dispose()
})
})