test-remote.ts 9.8 KB

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