/** Test-only direct Remote face over the Session Controller's internal controllers. */ import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import type { AdmittedPromptContentPart, AttachmentAdmissionPart, ImageAttachmentLimits, } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceNotFoundError, SessionPersistenceRevision, SessionReadOnlyError, type SessionAccess, type SessionHandle, type SessionHandleReadOptions, type SessionPersistenceListOptions, type SessionPersistenceOpenOptions, type SessionPersistenceSnapshot, type SessionPersistenceStatOptions, } from '@deepseek-ai/dsh-session-persistence' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import { vi } from 'vitest' import { RemoteError, remoteErrorOf, type RemoteResult, } from '@deepseek-ai/dsh-typert-protocol' import SessionController from '../src/index.ts' import type { ModelCatalog, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, SessionCancelValue, SessionControlFrame, SessionCreateRequest, SessionCreateValue, SessionForkRequest, SessionForkValue, SessionFollowFrame, SessionFollowRequest, SessionListRequest, SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, SessionPageRequest, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, SessionRenameValue, SessionSearchRequest, SessionSearchValue, SessionSelectModelRequest, SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, } from '../src/types.ts' /** Direct test face matching the generated `ctx.remote.session` unary methods. */ export interface TestSessionRemote { canOpenWorkspacePath(): Promise> list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> create(request: SessionCreateRequest): Promise> selectModel(request: SessionSelectModelRequest): Promise> modelCatalog(): Promise> rename(request: SessionRenameRequest): Promise> fork(request: SessionForkRequest): Promise> prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise> attachment(request: SessionAttachmentRequest): Promise> updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal?: AbortSignal, ): Promise> page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable } /** Dependencies and policy supplied by a Session Controller unit harness. */ export interface TestSessionRemoteDefaults { readonly defaultModelSelection: () => AgentModelSelection readonly cwd: string readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } const installed = new WeakMap() const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ maxImageBytes: 5 * 1024 * 1024, maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, maxImagePixels: 40_000_000, maxImageDimension: 2000, mediaTypes: Object.freeze(['image/png'] as const), }) /** Compact header-and-events point read a persistence double declares per session. */ interface TestSessionInspection { readonly meta: SessionHeader readonly events: readonly SessionEvent[] } type LegacyTestPersistence = Record & { readonly list?: (signal?: AbortSignal) => Promise readonly inspect?: ( sessionId: SessionId, signal?: AbortSignal, ) => Promise readonly stat?: ( sessionId: SessionId, options?: SessionPersistenceStatOptions, ) => Promise readonly open?: ( sessionId: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions, ) => Promise } /** One immutable read handle over a double's inspected header and events. */ function testReadHandle( sessionId: SessionId, inspection: TestSessionInspection, ): SessionHandle { const events = Object.freeze([...inspection.events]) return { id: sessionId, header: inspection.meta, inheritedEventCount: SessionLogOffset(0), access: 'read', read: (offset = 0, length?: number, options?: SessionHandleReadOptions) => { options?.signal?.throwIfAborted() return Promise.resolve({ eventState: 'detached', events: structuredClone(events.slice(offset, length === undefined ? undefined : offset + length)), } as const) }, append: () => Promise.reject(new SessionReadOnlyError(sessionId, 'append')), flush: () => Promise.reject(new SessionReadOnlyError(sessionId, 'flush')), close: () => Promise.resolve(), [Symbol.asyncDispose]: () => Promise.resolve(), } } /** * Adapt a compact header/inspect persistence double onto the handle-based * abstract the production readers consume: `list` snapshots wrap the double's * headers, `stat` derives a metadata-less snapshot from the listing, and * `open` serves immutable read handles over the double's `inspect` result. */ export function testSessionPersistence( _ctx: Context, persistence: LegacyTestPersistence, ): Record { const listHeaders = async (signal?: AbortSignal): Promise => await persistence.list?.(signal) ?? [] const adapted: Record = { ...persistence, list: async (options?: SessionPersistenceListOptions) => (await listHeaders(options?.signal)).map(header => ({ header, revision: SessionPersistenceRevision(`test:${header.id}:list`), })), } if (persistence.stat === undefined) { adapted.stat = async ( sessionId: SessionId, options?: SessionPersistenceStatOptions, ): Promise => { options?.signal?.throwIfAborted() const header = (await listHeaders(options?.signal)).find(listed => listed.id === sessionId) return header === undefined ? undefined : { header, revision: SessionPersistenceRevision(`test:${sessionId}:stat`) } } } if (persistence.open === undefined) { adapted.open = async ( sessionId: SessionId, access: SessionAccess, options?: SessionPersistenceOpenOptions, ): Promise => { options?.signal?.throwIfAborted() if (access !== 'read') { throw new Error(`test persistence double only serves read handles (requested "${access}")`) } const inspection = await persistence.inspect?.(sessionId, options?.signal) if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId) return testReadHandle(sessionId, inspection) } } return adapted } /** Concrete point-read query used by Session Controller tests that do not exercise search. */ class TestSessionQuery extends SessionQueryEngine { override searchSessions(): Promise { return Promise.reject(new Error('session search is not configured in this test')) } override searchEvents(): Promise { return Promise.reject(new Error('event search is not configured in this test')) } } /** Install the required projection and point-query services for direct controller tests. */ export function installSessionReadTestServices(ctx: Context): void { if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx) if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx) } function installControllers( ctx: Context, defaults: TestSessionRemoteDefaults, ): SessionController { const found = installed.get(ctx) if (found !== undefined) return found if (ctx.get('typert') === undefined) { const dispose = (): void => {} ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, } as never) } if (ctx.get('agentDefaultModel') === undefined) { ctx.provide('agentDefaultModel', { currentSelection: defaults.defaultModelSelection, saveSelection: async (selection: AgentModelSelection) => { await defaults.saveDefaultModelSelection?.(selection) }, } as never) } if (ctx.get('llm') === undefined) { ctx.provide('llm', { listProviders: () => { const selection = defaults.defaultModelSelection() return [{ id: selection.provider, name: selection.provider }] }, } as never) } if (ctx.get('attachments') === undefined) { ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS, admitPromptContent: async ( content: readonly AttachmentAdmissionPart[], ): Promise => { const admitted: AdmittedPromptContentPart[] = [] for (const part of content) { if (part.type === 'image') throw new Error('test did not configure image persistence') admitted.push(part) } return admitted }, } as never) } if (ctx.get('fileUploads') === undefined) { ctx.provide('fileUploads', { registerAgentResolver: () => () => {}, resolve: () => undefined, bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) } installSessionReadTestServices(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController try { controller = new SessionController( ctx, { ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) } finally { cwd.mockRestore() } installed.set(ctx, controller) return controller } /** Build or return the production Session Controller for a direct unit harness. */ export function createSessionTestController( ctx: Context, defaults: TestSessionRemoteDefaults, ): SessionController { return installControllers(ctx, defaults) } function remoteResult( operation: () => T | Promise, signal?: AbortSignal, ): Promise> { return Promise.resolve() .then(operation) .then(value => ({ ok: true as const, value })) .catch((error: unknown) => ({ ok: false as const, error: signal?.aborted === true ? new RemoteError('gateway/cancelled', 'request was aborted', {}) : remoteErrorOf(error) ?? new RemoteError( 'gateway/internal', error instanceof Error ? error.message : String(error), {}, ), })) } /** Build the generated Session Remote's unary result semantics without a carrier. */ export function createSessionTestRemote( ctx: Context, defaults: TestSessionRemoteDefaults, ): TestSessionRemote { const direct = createSessionTestController(ctx, defaults) return { canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()), list: (request, signal = new AbortController().signal) => remoteResult( () => direct.list(request, signal), signal, ), search: (request, signal = new AbortController().signal) => remoteResult( () => direct.search(request, signal), signal, ), create: request => remoteResult(() => direct.create(request)), selectModel: request => remoteResult(() => direct.selectModel(request)), modelCatalog: () => remoteResult(() => direct.modelCatalog()), rename: request => remoteResult(() => direct.rename(request)), fork: request => remoteResult(() => direct.fork(request)), prompt: (request, signal = new AbortController().signal) => remoteResult( () => direct.prompt(request, signal), signal, ), attachment: request => remoteResult(() => direct.attachment(request)), updateQueue: request => remoteResult(() => direct.updateQueue(request)), cancel: request => remoteResult(() => direct.cancel(request)), openWorkspacePath: (request, signal = new AbortController().signal) => remoteResult( () => direct.openWorkspacePath(request, signal), signal, ), page: (request, signal = new AbortController().signal) => remoteResult( () => direct.page(request, signal), signal, ), follow: (request, signal = new AbortController().signal) => direct.follow(request, signal), control: (signal = new AbortController().signal) => direct.control(signal), } }