test-remote.ts 9.1 KB

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