test-remote.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. /** Test-only direct Remote face over the Session Controller's internal controllers. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
  4. import type { SessionId } from '@deepseek-ai/dsh-session'
  5. import {
  6. SessionPersistenceCorruptionError,
  7. SessionPersistenceNotFoundError,
  8. SessionPersistenceRevision,
  9. type BorrowedSessionSource,
  10. type SessionInspection,
  11. } from '@deepseek-ai/dsh-session-persistence'
  12. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  13. import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
  14. import { vi } from 'vitest'
  15. import {
  16. RemoteError,
  17. remoteErrorOf,
  18. type RemoteResult,
  19. } from '@deepseek-ai/dsh-typert-protocol'
  20. import SessionController from '../src/index.ts'
  21. import type {
  22. ModelCatalog,
  23. SessionAttachmentRequest,
  24. SessionAttachmentValue,
  25. SessionCancelRequest,
  26. SessionCancelValue,
  27. SessionControlFrame,
  28. SessionCreateRequest,
  29. SessionCreateValue,
  30. SessionForkRequest,
  31. SessionForkValue,
  32. SessionFollowFrame,
  33. SessionFollowRequest,
  34. SessionListRequest,
  35. SessionListValue,
  36. SessionOpenWorkspacePathRequest,
  37. SessionOpenWorkspacePathValue,
  38. SessionPage,
  39. SessionPageRequest,
  40. SessionPromptRequest,
  41. SessionPromptValue,
  42. SessionRenameRequest,
  43. SessionRenameValue,
  44. SessionSearchRequest,
  45. SessionSearchValue,
  46. SessionSelectModelRequest,
  47. SessionSelectModelValue,
  48. SessionUpdateQueueRequest,
  49. SessionUpdateQueueValue,
  50. } from '../src/types.ts'
  51. /** Direct test face matching the generated `ctx.remote.session` unary methods. */
  52. export interface TestSessionRemote {
  53. canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
  54. list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
  55. search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
  56. create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
  57. selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
  58. modelCatalog(): Promise<RemoteResult<ModelCatalog>>
  59. rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
  60. fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
  61. prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPromptValue>>
  62. attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
  63. updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
  64. cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
  65. openWorkspacePath(
  66. request: SessionOpenWorkspacePathRequest,
  67. signal?: AbortSignal,
  68. ): Promise<RemoteResult<SessionOpenWorkspacePathValue>>
  69. page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
  70. follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
  71. control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
  72. }
  73. /** Dependencies and policy supplied by a Session Controller unit harness. */
  74. export interface TestSessionRemoteDefaults {
  75. readonly defaultModelSelection: () => AgentModelSelection
  76. readonly cwd: string
  77. readonly coldBlankProbeMaxBytes?: number
  78. readonly nativeOpen?: boolean
  79. readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
  80. readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
  81. readonly canOpenPath?: () => boolean
  82. }
  83. const installed = new WeakMap<Context, SessionController>()
  84. type LegacyTestPersistence = Record<string, unknown> & {
  85. readonly inspect?: (
  86. sessionId: SessionId,
  87. signal?: AbortSignal,
  88. ) => Promise<SessionInspection | undefined>
  89. readonly borrowSession?: (
  90. sessionId: SessionId,
  91. signal?: AbortSignal,
  92. ) => Promise<BorrowedSessionSource>
  93. }
  94. /** Add the preparation-backed point-read contract to compact persistence doubles. */
  95. export function testSessionPersistence(
  96. ctx: Context,
  97. persistence: LegacyTestPersistence,
  98. ): LegacyTestPersistence {
  99. if (persistence.borrowSession !== undefined) return persistence
  100. return {
  101. ...persistence,
  102. borrowSession: async (sessionId, signal) => {
  103. signal?.throwIfAborted()
  104. const inspection = await persistence.inspect?.(sessionId, signal)
  105. signal?.throwIfAborted()
  106. if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId)
  107. try {
  108. const preparedSession = ctx.sessions.prepare(inspection.meta.id, {
  109. seed: [...inspection.events],
  110. meta: inspection.meta,
  111. seedSource: 'persistence',
  112. })
  113. return {
  114. source: 'prepared',
  115. inspection: {
  116. meta: preparedSession.header,
  117. events: Object.freeze([...inspection.events]),
  118. },
  119. revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`),
  120. preparedSession,
  121. [Symbol.dispose]: () => {},
  122. }
  123. } catch (error: unknown) {
  124. throw new SessionPersistenceCorruptionError(
  125. `test session "${sessionId}" failed validation: ${String(error)}`,
  126. { cause: error },
  127. )
  128. }
  129. },
  130. }
  131. }
  132. /** Concrete point-read query used by Session Controller tests that do not exercise search. */
  133. class TestSessionQuery extends SessionQueryEngine {
  134. override searchSessions(): Promise<never> {
  135. return Promise.reject(new Error('session search is not configured in this test'))
  136. }
  137. override searchEvents(): Promise<never> {
  138. return Promise.reject(new Error('event search is not configured in this test'))
  139. }
  140. }
  141. /** Install the required projection and point-query services for direct controller tests. */
  142. export function installSessionReadTestServices(ctx: Context): void {
  143. if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx)
  144. if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx)
  145. }
  146. function installControllers(
  147. ctx: Context,
  148. defaults: TestSessionRemoteDefaults,
  149. ): SessionController {
  150. const found = installed.get(ctx)
  151. if (found !== undefined) return found
  152. if (ctx.get('typert') === undefined) {
  153. const dispose = (): void => {}
  154. ctx.provide('typert', {
  155. lookups: { configure: () => dispose },
  156. contexts: { configureHost: () => dispose },
  157. } as never)
  158. }
  159. if (ctx.get('agentDefaultModel') === undefined) {
  160. ctx.provide('agentDefaultModel', {
  161. currentSelection: defaults.defaultModelSelection,
  162. saveSelection: async (selection: AgentModelSelection) => {
  163. await defaults.saveDefaultModelSelection?.(selection)
  164. },
  165. } as never)
  166. }
  167. if (ctx.get('llm') === undefined) {
  168. ctx.provide('llm', {
  169. listProviders: () => {
  170. const selection = defaults.defaultModelSelection()
  171. return [{ id: selection.provider, name: selection.provider }]
  172. },
  173. } as never)
  174. }
  175. installSessionReadTestServices(ctx)
  176. const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
  177. let controller: SessionController
  178. try {
  179. controller = new SessionController(
  180. ctx,
  181. {
  182. ...defaults.coldBlankProbeMaxBytes === undefined
  183. ? {}
  184. : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes },
  185. ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen },
  186. },
  187. {
  188. ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
  189. ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
  190. },
  191. )
  192. } finally {
  193. cwd.mockRestore()
  194. }
  195. installed.set(ctx, controller)
  196. return controller
  197. }
  198. /** Build or return the production Session Controller for a direct unit harness. */
  199. export function createSessionTestController(
  200. ctx: Context,
  201. defaults: TestSessionRemoteDefaults,
  202. ): SessionController {
  203. return installControllers(ctx, defaults)
  204. }
  205. function remoteResult<T>(
  206. operation: () => T | Promise<T>,
  207. signal?: AbortSignal,
  208. ): Promise<RemoteResult<T>> {
  209. return Promise.resolve()
  210. .then(operation)
  211. .then(value => ({ ok: true as const, value }))
  212. .catch((error: unknown) => ({
  213. ok: false as const,
  214. error: signal?.aborted === true
  215. ? new RemoteError('gateway/cancelled', 'request was aborted', {})
  216. : remoteErrorOf(error)
  217. ?? new RemoteError(
  218. 'gateway/internal',
  219. error instanceof Error ? error.message : String(error),
  220. {},
  221. ),
  222. }))
  223. }
  224. /** Build the generated Session Remote's unary result semantics without a carrier. */
  225. export function createSessionTestRemote(
  226. ctx: Context,
  227. defaults: TestSessionRemoteDefaults,
  228. ): TestSessionRemote {
  229. const direct = createSessionTestController(ctx, defaults)
  230. return {
  231. canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()),
  232. list: (request, signal = new AbortController().signal) => remoteResult(
  233. () => direct.list(request, signal),
  234. signal,
  235. ),
  236. search: (request, signal = new AbortController().signal) => remoteResult(
  237. () => direct.search(request, signal),
  238. signal,
  239. ),
  240. create: request => remoteResult(() => direct.create(request)),
  241. selectModel: request => remoteResult(() => direct.selectModel(request)),
  242. modelCatalog: () => remoteResult(() => direct.modelCatalog()),
  243. rename: request => remoteResult(() => direct.rename(request)),
  244. fork: request => remoteResult(() => direct.fork(request)),
  245. prompt: (request, signal = new AbortController().signal) => remoteResult(
  246. () => direct.prompt(request, signal),
  247. signal,
  248. ),
  249. attachment: request => remoteResult(() => direct.attachment(request)),
  250. updateQueue: request => remoteResult(() => direct.updateQueue(request)),
  251. cancel: request => remoteResult(() => direct.cancel(request)),
  252. openWorkspacePath: (request, signal = new AbortController().signal) => remoteResult(
  253. () => direct.openWorkspacePath(request, signal),
  254. signal,
  255. ),
  256. page: (request, signal = new AbortController().signal) => remoteResult(
  257. () => direct.page(request, signal),
  258. signal,
  259. ),
  260. follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
  261. control: (signal = new AbortController().signal) => direct.control(signal),
  262. }
  263. }