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

323 lines
13 KiB
TypeScript

import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mkdtempSync, rmSync, 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-preset-registry'
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApiSessionAgentController,
ApiSessionCwdConflict,
ApiSessionNotFound,
inspectApiSession,
} from '../src/agent.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const roots: Context[] = []
/** Session cwd roots created per test, removed after their context settles. */
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
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(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
await ctx.sessionProjections.register(agentPresetProjectionDefinition)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
const agents = new ApiSessionAgentController(ctx)
agents.initialize()
return { ctx, agents }
}
function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
return {
version: SESSION_FORMAT_VERSION,
id: SessionId(id),
createdAt: 1,
isSeeded: false,
...(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 stat = vi.fn(() => Promise.resolve(undefined))
const disposeMissing = providePersistence(ctx, {
list: () => Promise.resolve([]),
stat,
inspect,
})
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
// Absence is decided by the stat preflight; the log itself is never opened.
expect(stat).toHaveBeenCalledOnce()
expect(inspect).not.toHaveBeenCalled()
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, inheritedEventCount: SessionLogOffset(0), events: [] }))
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect,
})
const signal = new AbortController().signal
await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, inheritedEventCount: SessionLogOffset(0), 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'))
await 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.toMatchObject({ code: 'session/not-found' })
})
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 () => {
await 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: 'session/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: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string },
})
})
it('identifies a held Session writer without classifying other resume failures as contention', async () => {
const { ctx, agents } = await harness()
const meta = header('owned-session')
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] }),
})
const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new SessionAlreadyOwnedError(meta.id))
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'session/writer-held', details: { sessionId: meta.id } },
})
resume.mockRejectedValue(Object.assign(new Error('another module copy'), { name: 'SessionAlreadyOwnedError' }))
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'session/writer-held', details: { sessionId: meta.id } },
})
resume.mockRejectedValue(new Error('unrelated failure'))
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'gateway/internal' },
})
})
it('retains resume diagnostics without a persistence service', async () => {
const { ctx, agents } = await harness()
const meta = header('memory-only-resume')
ctx.sessions.create(meta.id, { meta })
vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'gateway/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 creation', () => {
it('rejects a resume that subagent routing adopts before publication', 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: 'session/agent-busy' },
})
})
it('surfaces directory creation failure', async () => {
const { agents } = await harness()
const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
tempDirs.push(parent)
const file = join(parent, 'file')
writeFileSync(file, 'not a directory')
await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true }))
.rejects.toThrow('failed to ensure project directory')
})
})