Files
imccyu 99a6c14527 test(session): cover the split controllers and passive follow
Move the facade, listener, state, wait and history tests onto the new
controllers, and cover passive activation, omitted cursors and the
event subscription frames.
2026-10-25 18:14:28 +08:00

403 lines
16 KiB
TypeScript

/** Test-only direct Remote face over the Session Controller's internal controllers. */
import type { SessionControllerInternals } from '../src/index.ts'
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
import { Service, 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 type { SessionProjectionsValue } from '../src/types.ts'
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 RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
import SessionController from '../src/index.ts'
import type {
ModelCatalog,
SessionAttachmentRequest,
SessionAttachmentValue,
SessionCancelRequest,
SessionCancelValue,
SessionControlFrame,
SessionCreateRequest,
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionFollowFrame,
SessionFollowRequest,
SessionListRequest,
SessionListValue,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionWorkspacePathApplication,
SessionEventsPage,
SessionPageRequest,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
SessionRenameValue,
SessionSearchRequest,
SessionSearchValue,
SessionSelectModelRequest,
SessionSelectModelValue,
SessionProjectionsRequest,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from '../src/types.ts'
/** Direct test face for the generated `ctx.remote.session` unary methods these tests exercise. */
export interface TestSessionRemote {
workspacePathApplications(
request: { readonly path: string }, signal?: AbortSignal,
): Promise<RemoteResult<readonly SessionWorkspacePathApplication[]>>
canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
modelCatalog(): Promise<RemoteResult<ModelCatalog>>
rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPromptValue>>
attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
openWorkspacePath(
request: SessionOpenWorkspacePathRequest,
signal?: AbortSignal,
): Promise<RemoteResult<SessionOpenWorkspacePathValue>>
page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionEventsPage>>
projections(request: SessionProjectionsRequest, signal?: AbortSignal): Promise<RemoteResult<SessionProjectionsValue>>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
}
/** 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<void>
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
readonly fileApplications?: SessionControllerInternals['fileApplications']
readonly openFileApplication?: SessionControllerInternals['openFileApplication']
readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
readonly canOpenPath?: () => boolean
readonly listWorkSliceMs?: number
}
const installed = new WeakMap<Context, SessionController>()
/** Image policy for controller fixtures without attachment I/O. */
export 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<string, unknown> & {
readonly list?: (signal?: AbortSignal) => Promise<readonly SessionHeader[]>
readonly inspect?: (
sessionId: SessionId,
signal?: AbortSignal,
) => Promise<TestSessionInspection | undefined>
readonly stat?: (
sessionId: SessionId,
options?: SessionPersistenceStatOptions,
) => Promise<SessionPersistenceSnapshot | undefined>
readonly open?: (
sessionId: SessionId,
access: SessionAccess,
options?: SessionPersistenceOpenOptions,
) => Promise<SessionHandle>
}
/** 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<string, unknown> {
const listHeaders = async (signal?: AbortSignal): Promise<readonly SessionHeader[]> =>
await persistence.list?.(signal) ?? []
const adapted: Record<string, unknown> = {
...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<SessionPersistenceSnapshot | undefined> => {
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<SessionHandle> => {
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<never> {
return Promise.reject(new Error('session search is not configured in this test'))
}
override searchEvents(): Promise<never> {
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)
}
async function installControllers(
ctx: Context,
defaults: TestSessionRemoteDefaults,
): Promise<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 },
resources: { register: () => 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', {
listModels: async () => {
const selection = defaults.defaultModelSelection()
return [{ id: selection.model, name: selection.model }]
},
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<AdmittedPromptContentPart[]> => {
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)
if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx)
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
class TestController extends SessionController {
initialize(): Promise<void> {
return this[Service.init]()
}
}
let controller: TestController
try {
controller = new TestController(
ctx,
{
...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen },
...defaults.listWorkSliceMs === undefined ? {} : { listWorkSliceMs: defaults.listWorkSliceMs },
},
{
...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
...defaults.fileApplications === undefined ? {} : { fileApplications: defaults.fileApplications },
...defaults.openFileApplication === undefined ? {} : { openFileApplication: defaults.openFileApplication },
...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath },
...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
},
)
await controller.initialize()
} finally {
cwd.mockRestore()
}
installed.set(ctx, controller)
return controller
}
/** Build or return the production Session Controller for a direct unit harness. */
export async function createSessionTestController(
ctx: Context,
defaults: TestSessionRemoteDefaults,
): Promise<SessionController> {
return await installControllers(ctx, defaults)
}
function remoteResult<T>(
operation: () => T | Promise<T>,
signal?: AbortSignal,
): Promise<RemoteResult<T>> {
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 async function createSessionTestRemote(
ctx: Context,
defaults: TestSessionRemoteDefaults,
): Promise<TestSessionRemote> {
const direct = await createSessionTestController(ctx, defaults)
return {
workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult(
() => direct.workspacePathApplications(request, signal), signal,
),
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(
// oxlint-disable-next-line typescript/no-deprecated -- Test face keeps the legacy page entry covered.
() => direct.page(request, signal),
signal,
),
projections: (request, signal = new AbortController().signal) => remoteResult(
() => direct.projections(request, signal),
signal,
),
follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
control: (signal = new AbortController().signal) => direct.control(signal),
}
}