Files
deepseek-harness/packages/api/session-controller/tests/agent.host.spec.ts
T

436 lines
17 KiB
TypeScript

import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApiSessionAgentController,
ApiSessionCwdConflict,
ApiSessionNotFound,
ApiSessionSubagentOwnership,
inspectApiSession,
} from '../src/agent.ts'
import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const roots: Context[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
})
async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(agentPresetProjectionDefinition)
installModelSelectionProjection(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
return { ctx, agents: new ApiSessionAgentController(ctx) }
}
function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
return {
version: 0,
id: SessionId(id),
createdAt: 1,
...(cwd === null ? {} : { cwd }),
}
}
function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
}
function agent(ctx: Context, meta: SessionHeader): Agent {
const session = ctx.sessions.create(meta.id, { meta })
return { id: meta.id, session, status: 'idle', ctx } as Agent
}
function unpublishedAgent(ctx: Context, meta: SessionHeader): Agent {
return {
id: meta.id,
session: { id: meta.id, header: meta, events: [] },
status: 'idle',
ctx,
} as unknown as Agent
}
describe('ApiSession identity failures', () => {
it('describes cwd conflicts with and without a recorded cwd', () => {
expect(new ApiSessionCwdConflict(SessionId('missing-cwd'), '/wanted', undefined).message)
.toContain('records no cwd')
expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message)
.toContain('belongs to "/existing"')
})
it('maps absent and cwd-less point observations to not found', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
await expect(inspectApiSession(ctx, SessionId('missing')))
.rejects.toBeInstanceOf(ApiSessionNotFound)
const inspect = vi.fn(() => Promise.resolve(undefined))
const disposeMissing = providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect,
})
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
expect(inspect).toHaveBeenCalledOnce()
disposeMissing()
const listed = header('cwd-less-catalog', null)
const disposeListed = providePersistence(ctx, {
list: () => Promise.resolve([listed]),
inspect: () => Promise.resolve({ meta: listed, events: [] }),
})
await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
disposeListed()
const catalog = header('cwd-less-inspect')
const inspected = header('cwd-less-inspect', null)
providePersistence(ctx, {
list: () => Promise.resolve([catalog]),
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
})
await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
})
it('forwards an explicit inspection signal', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
const meta = header('signalled-inspection')
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] }))
providePersistence(ctx, { inspect })
const signal = new AbortController().signal
await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] })
expect(inspect).toHaveBeenCalledWith(meta.id, signal)
})
})
describe('ApiSession Agent lookup and recovery', () => {
it('resumes directly from a retained observation and rejects an invalid observed header', async () => {
const { ctx, agents } = await harness()
const meta = header('observed-resume')
const resumed = unpublishedAgent(ctx, meta)
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
agent: resumed,
dispose: () => Promise.resolve(),
})
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
await expect(agents.resolveObservedAgent(observed)).resolves.toEqual({ agent: resumed })
expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
const invalid = {
...observed,
header: header('observed-without-cwd', null),
} as SessionObservation
await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
})
it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
const { ctx } = await harness()
const live = agent(ctx, header('live'))
ctx.agents.register(live)
providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
})
const host = ctx.typert.contexts.getHost('agent')
if (host === undefined) throw new Error('Agent Context resolver was not registered')
await expect(host.resolve(live.id)).resolves.toBe(live.ctx)
await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure)
})
it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
const ordinary = await harness()
const ordinaryMeta = header('ordinary-race')
providePersistence(ordinary.ctx, {
list: () => Promise.resolve([ordinaryMeta]),
inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }),
})
const winner = agent(ordinary.ctx, ordinaryMeta)
vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
ordinary.ctx.agents.register(winner)
throw new Error('raced publication')
})
await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner })
const child = await harness()
const childMeta = header('child-race')
providePersistence(child.ctx, {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
})
vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => {
child.ctx.sessions.create(childMeta.id, {
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
})
throw new Error('raced child publication')
})
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
error: { code: 'agent-busy' },
})
})
it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
const missing = await harness()
providePersistence(missing.ctx, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
})
await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
const failed = await harness()
const meta = header('failed')
providePersistence(failed.ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] }),
})
vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
})
})
it('requires projected observations before activation', async () => {
const { agents } = await harness()
const meta = header('unprojected-observation')
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
expect(() => agents.presetForObservation(observed)).toThrow(
'Agent activation requires a projected Session observation',
)
})
})
describe('ApiSession model selection', () => {
it('requires the model-selection projection', async () => {
const { ctx, agents } = await harness()
const live = agent(ctx, header('missing-model-projection'))
vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection')
})
it('reads a reasoning-free request and consumes only the exact pending selection', async () => {
const { ctx, agents } = await harness()
const logged = agent(ctx, header('logged-model'))
logged.session.append('request/header', {
header: { config: { provider: 'logged-provider', model: 'logged-model' } },
reason: 'initial',
})
expect(agents.selectionFor(logged).current).toEqual({
provider: 'logged-provider',
model: 'logged-model',
})
const pending = agent(ctx, header('pending-model'))
const selection = agents.selectionFor(pending)
agents.selectForNextRequest(pending, {
provider: 'selected-provider',
model: 'selected-model',
reasoningEffort: 'high' as never,
})
expect(selection.current).toMatchObject({
provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high',
})
expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true)
expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' })
const untouched = agent(ctx, header('uninstalled-model'))
expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false)
})
})
describe('ApiSession create or adoption', () => {
it('shares one in-flight creation between concurrent callers', async () => {
const { ctx, agents } = await harness()
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-'))
const meta = header('concurrent-create', cwd)
const created = unpublishedAgent(ctx, meta)
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => {
await gate
return { agent: created, dispose: () => Promise.resolve() }
})
const first = agents.ensureSession(meta.id, cwd, false)
const second = agents.ensureSession(meta.id, cwd, false)
release()
await expect(Promise.all([first, second])).resolves.toEqual([created, created])
expect(create).toHaveBeenCalledOnce()
})
it('accepts a raced ordinary creation and rejects a raced attached child', async () => {
const ordinary = await harness()
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-'))
const ordinaryMeta = header('create-race', cwd)
const winner = agent(ordinary.ctx, ordinaryMeta)
vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
ordinary.ctx.agents.register(winner)
throw new Error('raced creation')
})
await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false))
.resolves.toBe(winner)
const child = await harness()
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-'))
const childId = SessionId('create-child-race')
vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => {
child.ctx.sessions.create(childId, {
meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' },
})
throw new Error('raced child creation')
})
await expect(child.agents.ensureSession(childId, childCwd, false))
.rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
})
it('validates ownership and cwd on the Agent returned by creation', async () => {
const child = await harness()
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-'))
const childMeta = {
...header('returned-child', childCwd),
parentSession: SessionId('parent'),
origin: 'subagent' as const,
}
const childAgent = unpublishedAgent(child.ctx, childMeta)
vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({
agent: childAgent,
dispose: () => Promise.resolve(),
})
await expect(child.agents.ensureSession(childMeta.id, childCwd, false))
.rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
const wrong = await harness()
const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-'))
const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other'))
vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({
agent: wrongAgent,
dispose: () => Promise.resolve(),
})
await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false))
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
})
it('resumes a matching persisted identity and preserves its selected preset', async () => {
const { ctx, agents } = await harness()
const meta = { ...header('stored'), agentPreset: 'minimal' }
const events = [{
type: 'agent-preset/selected',
seq: 0,
time: 1,
data: { agentPreset: 'minimal' },
}] as SessionEvent[]
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
})
ctx.provide('agentPresets', {
resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }),
mount: () => Promise.resolve(),
} as never)
const resumed = {
id: meta.id,
session: { id: meta.id, header: meta, events },
status: 'idle',
ctx,
} as unknown as Agent
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
agent: resumed,
dispose: () => Promise.resolve(),
})
await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed)
expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
})
it('rejects an ownership race before resume and a persisted cwd conflict', async () => {
const child = await harness()
const childMeta = header('resume-child-race')
providePersistence(child.ctx, {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
})
child.ctx.provide('agentPresets', {
resolve: () => {
child.ctx.sessions.create(childMeta.id, {
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
})
return Promise.resolve({ id: 'standard' })
},
mount: () => Promise.resolve(),
} as never)
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
error: { code: 'agent-busy' },
})
const conflict = await harness()
const stored = header('stored-cwd-conflict', '/stored')
providePersistence(conflict.ctx, {
list: () => Promise.resolve([stored]),
inspect: () => Promise.resolve({ meta: stored, events: [] }),
})
await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
})
it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => {
const { agents } = await harness()
const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
const file = join(parent, 'file')
writeFileSync(file, 'not a directory')
await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))
.rejects.toThrow('failed to ensure project directory')
const composition = await agents.composeAgent(undefined)
expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent')
})
})