test-remote.ts 11 KB

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