test-remote.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. /** Test-only direct Remote face over the Session Controller's internal controllers. */
  2. import { SessionLogOffset } from '@deepseek-ai/dsh-session'
  3. import type { Context } from '@deepseek-ai/cordis'
  4. import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
  5. import type {
  6. AdmittedPromptContentPart,
  7. AttachmentAdmissionPart,
  8. ImageAttachmentLimits,
  9. } from '@deepseek-ai/dsh-attachment'
  10. import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  11. import {
  12. SessionPersistenceNotFoundError,
  13. SessionPersistenceRevision,
  14. SessionReadOnlyError,
  15. type SessionAccess,
  16. type SessionHandle,
  17. type SessionHandleReadOptions,
  18. type SessionPersistenceListOptions,
  19. type SessionPersistenceOpenOptions,
  20. type SessionPersistenceSnapshot,
  21. type SessionPersistenceStatOptions,
  22. } from '@deepseek-ai/dsh-session-persistence'
  23. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  24. import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
  25. import { vi } from 'vitest'
  26. import {
  27. RemoteError,
  28. remoteErrorOf,
  29. type RemoteResult,
  30. } from '@deepseek-ai/dsh-typert-protocol'
  31. import SessionController from '../src/index.ts'
  32. import type {
  33. ModelCatalog,
  34. SessionAttachmentRequest,
  35. SessionAttachmentValue,
  36. SessionCancelRequest,
  37. SessionCancelValue,
  38. SessionControlFrame,
  39. SessionCreateRequest,
  40. SessionCreateValue,
  41. SessionForkRequest,
  42. SessionForkValue,
  43. SessionFollowFrame,
  44. SessionFollowRequest,
  45. SessionListRequest,
  46. SessionListValue,
  47. SessionOpenWorkspacePathRequest,
  48. SessionOpenWorkspacePathValue,
  49. SessionPage,
  50. SessionPageRequest,
  51. SessionPromptRequest,
  52. SessionPromptValue,
  53. SessionRenameRequest,
  54. SessionRenameValue,
  55. SessionSearchRequest,
  56. SessionSearchValue,
  57. SessionSelectModelRequest,
  58. SessionSelectModelValue,
  59. SessionUpdateQueueRequest,
  60. SessionUpdateQueueValue,
  61. } from '../src/types.ts'
  62. /** Direct test face matching the generated `ctx.remote.session` unary methods. */
  63. export interface TestSessionRemote {
  64. canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
  65. list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
  66. search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
  67. create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
  68. selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
  69. modelCatalog(): Promise<RemoteResult<ModelCatalog>>
  70. rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
  71. fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
  72. prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPromptValue>>
  73. attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
  74. updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
  75. cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
  76. openWorkspacePath(
  77. request: SessionOpenWorkspacePathRequest,
  78. signal?: AbortSignal,
  79. ): Promise<RemoteResult<SessionOpenWorkspacePathValue>>
  80. page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
  81. follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
  82. control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
  83. }
  84. /** Dependencies and policy supplied by a Session Controller unit harness. */
  85. export interface TestSessionRemoteDefaults {
  86. readonly defaultModelSelection: () => AgentModelSelection
  87. readonly cwd: string
  88. readonly nativeOpen?: boolean
  89. readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
  90. readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
  91. readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
  92. readonly canOpenPath?: () => boolean
  93. }
  94. const installed = new WeakMap<Context, SessionController>()
  95. const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({
  96. maxImageBytes: 5 * 1024 * 1024,
  97. maxImagesPerMessage: 20,
  98. maxMessageImageBytes: 100 * 1024 * 1024,
  99. maxImagePixels: 40_000_000,
  100. maxImageDimension: 2000,
  101. mediaTypes: Object.freeze(['image/png'] as const),
  102. })
  103. /** Compact header-and-events point read a persistence double declares per session. */
  104. interface TestSessionInspection {
  105. readonly meta: SessionHeader
  106. readonly events: readonly SessionEvent[]
  107. }
  108. type LegacyTestPersistence = Record<string, unknown> & {
  109. readonly list?: (signal?: AbortSignal) => Promise<readonly SessionHeader[]>
  110. readonly inspect?: (
  111. sessionId: SessionId,
  112. signal?: AbortSignal,
  113. ) => Promise<TestSessionInspection | undefined>
  114. readonly stat?: (
  115. sessionId: SessionId,
  116. options?: SessionPersistenceStatOptions,
  117. ) => Promise<SessionPersistenceSnapshot | undefined>
  118. readonly open?: (
  119. sessionId: SessionId,
  120. access: SessionAccess,
  121. options?: SessionPersistenceOpenOptions,
  122. ) => Promise<SessionHandle>
  123. }
  124. /** One immutable read handle over a double's inspected header and events. */
  125. function testReadHandle(
  126. sessionId: SessionId,
  127. inspection: TestSessionInspection,
  128. ): SessionHandle {
  129. const events = Object.freeze([...inspection.events])
  130. return {
  131. id: sessionId,
  132. header: inspection.meta,
  133. inheritedEventCount: SessionLogOffset(0),
  134. access: 'read',
  135. read: (offset = 0, length?: number, options?: SessionHandleReadOptions) => {
  136. options?.signal?.throwIfAborted()
  137. return Promise.resolve({
  138. eventState: 'detached',
  139. events: structuredClone(events.slice(offset, length === undefined ? undefined : offset + length)),
  140. } as const)
  141. },
  142. append: () => Promise.reject(new SessionReadOnlyError(sessionId, 'append')),
  143. flush: () => Promise.reject(new SessionReadOnlyError(sessionId, 'flush')),
  144. close: () => Promise.resolve(),
  145. [Symbol.asyncDispose]: () => Promise.resolve(),
  146. }
  147. }
  148. /**
  149. * Adapt a compact header/inspect persistence double onto the handle-based
  150. * abstract the production readers consume: `list` snapshots wrap the double's
  151. * headers, `stat` derives a metadata-less snapshot from the listing, and
  152. * `open` serves immutable read handles over the double's `inspect` result.
  153. */
  154. export function testSessionPersistence(
  155. _ctx: Context,
  156. persistence: LegacyTestPersistence,
  157. ): Record<string, unknown> {
  158. const listHeaders = async (signal?: AbortSignal): Promise<readonly SessionHeader[]> =>
  159. await persistence.list?.(signal) ?? []
  160. const adapted: Record<string, unknown> = {
  161. ...persistence,
  162. list: async (options?: SessionPersistenceListOptions) =>
  163. (await listHeaders(options?.signal)).map(header => ({
  164. header,
  165. revision: SessionPersistenceRevision(`test:${header.id}:list`),
  166. })),
  167. }
  168. if (persistence.stat === undefined) {
  169. adapted.stat = async (
  170. sessionId: SessionId,
  171. options?: SessionPersistenceStatOptions,
  172. ): Promise<SessionPersistenceSnapshot | undefined> => {
  173. options?.signal?.throwIfAborted()
  174. const header = (await listHeaders(options?.signal)).find(listed => listed.id === sessionId)
  175. return header === undefined
  176. ? undefined
  177. : { header, revision: SessionPersistenceRevision(`test:${sessionId}:stat`) }
  178. }
  179. }
  180. if (persistence.open === undefined) {
  181. adapted.open = async (
  182. sessionId: SessionId,
  183. access: SessionAccess,
  184. options?: SessionPersistenceOpenOptions,
  185. ): Promise<SessionHandle> => {
  186. options?.signal?.throwIfAborted()
  187. if (access !== 'read') {
  188. throw new Error(`test persistence double only serves read handles (requested "${access}")`)
  189. }
  190. const inspection = await persistence.inspect?.(sessionId, options?.signal)
  191. if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId)
  192. return testReadHandle(sessionId, inspection)
  193. }
  194. }
  195. return adapted
  196. }
  197. /** Concrete point-read query used by Session Controller tests that do not exercise search. */
  198. class TestSessionQuery extends SessionQueryEngine {
  199. override searchSessions(): Promise<never> {
  200. return Promise.reject(new Error('session search is not configured in this test'))
  201. }
  202. override searchEvents(): Promise<never> {
  203. return Promise.reject(new Error('event search is not configured in this test'))
  204. }
  205. }
  206. /** Install the required projection and point-query services for direct controller tests. */
  207. export function installSessionReadTestServices(ctx: Context): void {
  208. if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx)
  209. if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx)
  210. }
  211. function installControllers(
  212. ctx: Context,
  213. defaults: TestSessionRemoteDefaults,
  214. ): SessionController {
  215. const found = installed.get(ctx)
  216. if (found !== undefined) return found
  217. if (ctx.get('typert') === undefined) {
  218. const dispose = (): void => {}
  219. ctx.provide('typert', {
  220. lookups: { configure: () => dispose },
  221. contexts: { configureHost: () => dispose },
  222. } as never)
  223. }
  224. if (ctx.get('agentDefaultModel') === undefined) {
  225. ctx.provide('agentDefaultModel', {
  226. currentSelection: defaults.defaultModelSelection,
  227. saveSelection: async (selection: AgentModelSelection) => {
  228. await defaults.saveDefaultModelSelection?.(selection)
  229. },
  230. } as never)
  231. }
  232. if (ctx.get('llm') === undefined) {
  233. ctx.provide('llm', {
  234. listProviders: () => {
  235. const selection = defaults.defaultModelSelection()
  236. return [{ id: selection.provider, name: selection.provider }]
  237. },
  238. } as never)
  239. }
  240. if (ctx.get('attachments') === undefined) {
  241. ctx.provide('attachments', {
  242. imageLimits: TEST_IMAGE_LIMITS,
  243. admitPromptContent: async (
  244. content: readonly AttachmentAdmissionPart[],
  245. ): Promise<AdmittedPromptContentPart[]> => {
  246. const admitted: AdmittedPromptContentPart[] = []
  247. for (const part of content) {
  248. if (part.type === 'image') throw new Error('test did not configure image persistence')
  249. admitted.push(part)
  250. }
  251. return admitted
  252. },
  253. } as never)
  254. }
  255. if (ctx.get('fileUploads') === undefined) {
  256. ctx.provide('fileUploads', {
  257. registerAgentResolver: () => () => {},
  258. resolve: () => undefined,
  259. bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
  260. retirePrompt: () => {},
  261. } as never)
  262. }
  263. installSessionReadTestServices(ctx)
  264. const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
  265. let controller: SessionController
  266. try {
  267. controller = new SessionController(
  268. ctx,
  269. {
  270. ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen },
  271. },
  272. {
  273. ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
  274. ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath },
  275. ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
  276. },
  277. )
  278. } finally {
  279. cwd.mockRestore()
  280. }
  281. installed.set(ctx, controller)
  282. return controller
  283. }
  284. /** Build or return the production Session Controller for a direct unit harness. */
  285. export function createSessionTestController(
  286. ctx: Context,
  287. defaults: TestSessionRemoteDefaults,
  288. ): SessionController {
  289. return installControllers(ctx, defaults)
  290. }
  291. function remoteResult<T>(
  292. operation: () => T | Promise<T>,
  293. signal?: AbortSignal,
  294. ): Promise<RemoteResult<T>> {
  295. return Promise.resolve()
  296. .then(operation)
  297. .then(value => ({ ok: true as const, value }))
  298. .catch((error: unknown) => ({
  299. ok: false as const,
  300. error: signal?.aborted === true
  301. ? new RemoteError('gateway/cancelled', 'request was aborted', {})
  302. : remoteErrorOf(error)
  303. ?? new RemoteError(
  304. 'gateway/internal',
  305. error instanceof Error ? error.message : String(error),
  306. {},
  307. ),
  308. }))
  309. }
  310. /** Build the generated Session Remote's unary result semantics without a carrier. */
  311. export function createSessionTestRemote(
  312. ctx: Context,
  313. defaults: TestSessionRemoteDefaults,
  314. ): TestSessionRemote {
  315. const direct = createSessionTestController(ctx, defaults)
  316. return {
  317. canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()),
  318. list: (request, signal = new AbortController().signal) => remoteResult(
  319. () => direct.list(request, signal),
  320. signal,
  321. ),
  322. search: (request, signal = new AbortController().signal) => remoteResult(
  323. () => direct.search(request, signal),
  324. signal,
  325. ),
  326. create: request => remoteResult(() => direct.create(request)),
  327. selectModel: request => remoteResult(() => direct.selectModel(request)),
  328. modelCatalog: () => remoteResult(() => direct.modelCatalog()),
  329. rename: request => remoteResult(() => direct.rename(request)),
  330. fork: request => remoteResult(() => direct.fork(request)),
  331. prompt: (request, signal = new AbortController().signal) => remoteResult(
  332. () => direct.prompt(request, signal),
  333. signal,
  334. ),
  335. attachment: request => remoteResult(() => direct.attachment(request)),
  336. updateQueue: request => remoteResult(() => direct.updateQueue(request)),
  337. cancel: request => remoteResult(() => direct.cancel(request)),
  338. openWorkspacePath: (request, signal = new AbortController().signal) => remoteResult(
  339. () => direct.openWorkspacePath(request, signal),
  340. signal,
  341. ),
  342. page: (request, signal = new AbortController().signal) => remoteResult(
  343. () => direct.page(request, signal),
  344. signal,
  345. ),
  346. follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
  347. control: (signal = new AbortController().signal) => direct.control(signal),
  348. }
  349. }