298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
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(SessionProjectionRegistry)
|
|
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()
|
|
})
|
|
})
|