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