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 = { getPendingRemoteEvents: () => [] } ctx.provide('typertGateway', gateway as TypertGatewayService) const session = ctx.sessions.create(SessionId('assistant-interleave'), { meta: { cwd: '/workspace' } }) const partial: Partial = { id: session.id, session, status: 'running', ctx } const agent = partial as Agent const entered = Promise.withResolvers() const gate = Promise.withResolvers() const original = ctx.sessionProjections.register.bind(ctx.sessionProjections) let first = true vi.spyOn(ctx.sessionProjections, 'register').mockImplementation(async (...args: Parameters) => { 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 = { 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) | undefined ctx.provide('fileUploads', { registerAgentResolver: (resolve: (sessionId: SessionId) => Promise) => { 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 => { 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() const release = Promise.withResolvers() 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 }) }) })