Move the facade, listener, state, wait and history tests onto the new controllers, and cover passive activation, omitted cursors and the event subscription frames.
306 lines
14 KiB
TypeScript
306 lines
14 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 } from '@deepseek-ai/dsh-agent'
|
|
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
|
|
import { LlmAttemptId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type TypertGatewayService from '@deepseek-ai/dsh-api-gateway'
|
|
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
|
|
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
import SessionController from '../src/index.ts'
|
|
import type { ApiSessionAgentController } from '../src/agent.ts'
|
|
import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
|
|
|
|
const defaults = {
|
|
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
|
|
cwd: '/tmp',
|
|
}
|
|
|
|
describe('SessionController facade', () => {
|
|
it('does not require the Tools service', () => {
|
|
expect(SessionController.inject).not.toContain('tools')
|
|
})
|
|
|
|
it('keeps assistant stream observation live while list initialization awaits', async () => {
|
|
const ctx = new Context()
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
await mountAgentLoopTestHarness(ctx)
|
|
const gateway: Partial<TypertGatewayService> = { getPendingRemoteEvents: () => [] }
|
|
ctx.provide('typertGateway', gateway as TypertGatewayService)
|
|
const session = ctx.sessions.create(SessionId('assistant-interleave'), { meta: { cwd: '/workspace' } })
|
|
const partial: Partial<Agent> = { id: session.id, session, status: 'running', ctx }
|
|
const agent = partial as Agent
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const gate = Promise.withResolvers<undefined>()
|
|
const original = ctx.sessionProjections.register.bind(ctx.sessionProjections)
|
|
let first = true
|
|
vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (...args: Parameters<typeof original>) => {
|
|
if (first) {
|
|
first = false
|
|
entered.resolve(undefined)
|
|
await gate.promise
|
|
}
|
|
return original(...args)
|
|
})
|
|
const mounting = createSessionTestController(ctx, defaults)
|
|
await entered.promise
|
|
// An Agent start racing the list read must not be lost before the controller publishes.
|
|
ctx.emit('agent/assistant-stream', {
|
|
agent,
|
|
frame: { type: 'start', attemptId: LlmAttemptId('boot-attempt'), revision: 1, turn: 1, step: 1 },
|
|
})
|
|
gate.resolve(undefined)
|
|
const controller = await mounting
|
|
const frames = controller.follow({
|
|
address: { kind: 'session', sessionId: session.id }, assistantStream: true,
|
|
}, new AbortController().signal)[Symbol.asyncIterator]()
|
|
await expect(frames.next()).resolves.toMatchObject({
|
|
value: { type: 'snapshot', assistantStream: { revision: 1 } },
|
|
})
|
|
await frames.return?.(undefined)
|
|
})
|
|
|
|
it('serves passive reads, subscriptions and waits through its plain controllers', async () => {
|
|
const ctx = new Context()
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
await mountAgentLoopTestHarness(ctx)
|
|
const gateway: Partial<TypertGatewayService> = { getPendingRemoteEvents: () => [] }
|
|
ctx.provide('typertGateway', gateway as TypertGatewayService)
|
|
const controller = await createSessionTestController(ctx, defaults)
|
|
const sessionId = SessionId('passive-facade')
|
|
ctx.sessions.create(sessionId, { meta: { cwd: '/workspace' } })
|
|
const address = { kind: 'session' as const, sessionId }
|
|
const signal = new AbortController().signal
|
|
|
|
await expect(controller.events({ address }, signal)).resolves.toMatchObject({ records: [], hasMore: false })
|
|
const historyFrames = controller.observeEvent({ address }, signal)[Symbol.asyncIterator]()
|
|
await expect(historyFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } })
|
|
await historyFrames.return?.(undefined)
|
|
const projectionFrames = controller.observeProjections({ sessionId }, signal)[Symbol.asyncIterator]()
|
|
await expect(projectionFrames.next()).resolves.toMatchObject({ value: { type: 'ready' } })
|
|
await projectionFrames.return?.(undefined)
|
|
await expect(controller.state({ sessionId }, signal))
|
|
.resolves.toMatchObject({ active: false, running: false, queue: null })
|
|
const stateFrames = controller.observeState({ sessionId }, signal)[Symbol.asyncIterator]()
|
|
await expect(stateFrames.next()).resolves.toMatchObject({ value: { active: false } })
|
|
await stateFrames.return?.(undefined)
|
|
await expect(controller.wait({ sessionId, afterSeq: -1, maxMessages: 10, timeoutMs: 50 }, signal))
|
|
.resolves.toMatchObject({ reason: 'idle' })
|
|
})
|
|
|
|
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
|
|
const ctx = new Context()
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
await mountAgentLoopTestHarness(ctx)
|
|
const sessionId = SessionId('controller-session')
|
|
const header: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: sessionId,
|
|
createdAt: 1,
|
|
cwd: '/workspace',
|
|
isSeeded: false,
|
|
}
|
|
const events: SessionEvent[] = []
|
|
const inspect = vi.fn(() => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events,
|
|
}))
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect,
|
|
}) as never)
|
|
let uploadResolver: ((sessionId: SessionId) => Promise<Agent>) | undefined
|
|
ctx.provide('fileUploads', {
|
|
registerAgentResolver: (resolve: (sessionId: SessionId) => Promise<Agent>) => {
|
|
uploadResolver = resolve
|
|
return () => {}
|
|
},
|
|
resolve: () => undefined,
|
|
bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
|
|
retirePrompt: () => {},
|
|
} as never)
|
|
const controller = await createSessionTestController(ctx, defaults)
|
|
const status = vi.fn()
|
|
const failure = vi.fn()
|
|
const activity = vi.fn()
|
|
ctx.on('api-session/status', status)
|
|
ctx.on('api-session/error', failure)
|
|
ctx.on('api-session/activity', activity)
|
|
|
|
const session = ctx.sessions.create(sessionId, { meta: header })
|
|
const agent = {
|
|
id: sessionId,
|
|
session,
|
|
status: 'idle',
|
|
ctx,
|
|
} as Agent
|
|
await ctx.agents.register(agent)
|
|
const resolveUploadAgent = (id: SessionId): Promise<Agent> => {
|
|
if (uploadResolver === undefined) throw new Error('file upload resolver was not registered')
|
|
return uploadResolver(id)
|
|
}
|
|
await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent)
|
|
const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId })
|
|
vi.spyOn(
|
|
(controller as unknown as { agents: ApiSessionAgentController }).agents,
|
|
'resolveAgent',
|
|
).mockResolvedValueOnce({ error: activationError })
|
|
await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError)
|
|
|
|
await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
|
|
ctx.emit('agent/status', { agent, status: 'running' })
|
|
ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') })
|
|
session.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
source: { kind: 'user' },
|
|
}), { surfaceOp: 'append' })
|
|
session.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'browser prompt' }],
|
|
source: { kind: 'user', rpcId: 'controller-rpc' as never },
|
|
}), { surfaceOp: 'append' })
|
|
expect(status).toHaveBeenCalledWith(sessionId, true)
|
|
expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
|
|
expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
|
|
session.append('request/header', {
|
|
header: { config: { provider: 'fixture', model: 'fixture-model' } },
|
|
reason: 'initial',
|
|
})
|
|
expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({
|
|
lastUsed: { provider: 'fixture', model: 'fixture-model' }, next: { provider: 'fixture', model: 'fixture-model' },
|
|
})
|
|
const unowned = ctx.sessions.create(SessionId('controller-unowned'), {
|
|
meta: { cwd: '/workspace' },
|
|
})
|
|
unowned.append('request/header', {
|
|
header: { config: { provider: 'fixture', model: 'other-model' } },
|
|
reason: 'initial',
|
|
})
|
|
expect(ctx.sessionProjections.snapshot(unowned).values.modelSelection).toEqual({
|
|
lastUsed: { provider: 'fixture', model: 'other-model' }, next: { provider: 'fixture', model: 'other-model' },
|
|
})
|
|
|
|
const abort = new AbortController()
|
|
const iterator = controller.follow({
|
|
address: { kind: 'session', sessionId },
|
|
}, abort.signal)[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toMatchObject({
|
|
done: false,
|
|
value: { type: 'snapshot', cursor: 2 },
|
|
})
|
|
abort.abort()
|
|
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
|
|
it.each(['success', 'domain-error', 'throw'] as const)(
|
|
'promotes a prepared follow observation in the background: %s',
|
|
async (outcome) => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
const sessionId = SessionId(`background-${outcome}`)
|
|
const header: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false,
|
|
}
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect: () => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events: [],
|
|
}),
|
|
}) as never)
|
|
const controller = await createSessionTestController(ctx, defaults)
|
|
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
|
|
const apiError = vi.fn()
|
|
ctx.on('api-session/error', apiError)
|
|
const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
|
|
const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent
|
|
const resolve = vi.spyOn(agents, 'resolveObservedAgent')
|
|
if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
|
|
else if (outcome === 'domain-error') {
|
|
resolve.mockResolvedValue({
|
|
error: new RemoteError('gateway/internal', 'activation unavailable', {}),
|
|
})
|
|
} else {
|
|
resolve.mockRejectedValue(new Error('activation crashed'))
|
|
}
|
|
const abort = new AbortController()
|
|
const iterator = controller.follow({
|
|
address: { kind: 'session', sessionId },
|
|
activate: true,
|
|
}, abort.signal)[Symbol.asyncIterator]()
|
|
|
|
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
|
|
const waiting = iterator.next()
|
|
await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() })
|
|
if (outcome === 'domain-error') {
|
|
await vi.waitFor(() => {
|
|
expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable')
|
|
})
|
|
} else if (outcome === 'throw') {
|
|
await vi.waitFor(() => {
|
|
expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed'))
|
|
})
|
|
} else {
|
|
expect(apiError).not.toHaveBeenCalled()
|
|
}
|
|
abort.abort()
|
|
await expect(waiting).resolves.toMatchObject({ done: true })
|
|
await ctx.fiber.dispose()
|
|
},
|
|
)
|
|
|
|
it('waits for an admitted background promotion during teardown', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
const sessionId = SessionId('background-disposal')
|
|
const header: SessionHeader = {
|
|
version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false,
|
|
}
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect: () => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events: [],
|
|
}),
|
|
}) as never)
|
|
const controller = await createSessionTestController(ctx, defaults)
|
|
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
|
|
const started = Promise.withResolvers<undefined>()
|
|
const release = Promise.withResolvers<undefined>()
|
|
vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => {
|
|
started.resolve(undefined)
|
|
await release.promise
|
|
return {
|
|
agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent,
|
|
}
|
|
})
|
|
const iterator = controller.follow({
|
|
address: { kind: 'session', sessionId },
|
|
activate: true,
|
|
}, new AbortController().signal)[Symbol.asyncIterator]()
|
|
|
|
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
|
|
const waiting = iterator.next()
|
|
await started.promise
|
|
let disposed = false
|
|
const disposal = ctx.fiber.dispose().then(() => { disposed = true })
|
|
await Promise.resolve()
|
|
expect(disposed).toBe(false)
|
|
|
|
release.resolve(undefined)
|
|
await disposal
|
|
await expect(waiting).resolves.toMatchObject({ done: true })
|
|
})
|
|
})
|