323 lines
13 KiB
TypeScript
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')
|
|
})
|
|
})
|