test-remote.ts 11 KB

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