test-remote.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. /** Test-only direct Remote face over the Session Controller's internal controllers. */
  2. import { SessionLogOffset } from '@deepseek-ai/dsh-session'
  3. import type { Context } from '@deepseek-ai/cordis'
  4. import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
  5. import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  6. import {
  7. SessionPersistenceNotFoundError,
  8. SessionPersistenceRevision,
  9. SessionReadOnlyError,
  10. type SessionAccess,
  11. type SessionHandle,
  12. type SessionHandleReadOptions,
  13. type SessionPersistenceListOptions,
  14. type SessionPersistenceOpenOptions,
  15. type SessionPersistenceSnapshot,
  16. type SessionPersistenceStatOptions,
  17. } from '@deepseek-ai/dsh-session-persistence'
  18. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  19. import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
  20. import { vi } from 'vitest'
  21. import {
  22. RemoteError,
  23. remoteErrorOf,
  24. type RemoteResult,
  25. } from '@deepseek-ai/dsh-typert-protocol'
  26. import SessionController from '../src/index.ts'
  27. import type {
  28. ModelCatalog,
  29. SessionAttachmentRequest,
  30. SessionAttachmentValue,
  31. SessionCancelRequest,
  32. SessionCancelValue,
  33. SessionControlFrame,
  34. SessionCreateRequest,
  35. SessionCreateValue,
  36. SessionForkRequest,
  37. SessionForkValue,
  38. SessionFollowFrame,
  39. SessionFollowRequest,
  40. SessionListRequest,
  41. SessionListValue,
  42. SessionOpenWorkspacePathRequest,
  43. SessionOpenWorkspacePathValue,
  44. SessionPage,
  45. SessionPageRequest,
  46. SessionPromptRequest,
  47. SessionPromptValue,
  48. SessionRenameRequest,
  49. SessionRenameValue,
  50. SessionSearchRequest,
  51. SessionSearchValue,
  52. SessionSelectModelRequest,
  53. SessionSelectModelValue,
  54. SessionUpdateQueueRequest,
  55. SessionUpdateQueueValue,
  56. } from '../src/types.ts'
  57. /** Direct test face matching the generated `ctx.remote.session` unary methods. */
  58. export interface TestSessionRemote {
  59. canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
  60. list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
  61. search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
  62. create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
  63. selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
  64. modelCatalog(): Promise<RemoteResult<ModelCatalog>>
  65. rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
  66. fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
  67. prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPromptValue>>
  68. attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
  69. updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
  70. cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
  71. openWorkspacePath(
  72. request: SessionOpenWorkspacePathRequest,
  73. signal?: AbortSignal,
  74. ): Promise<RemoteResult<SessionOpenWorkspacePathValue>>
  75. page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
  76. follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
  77. control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
  78. }
  79. /** Dependencies and policy supplied by a Session Controller unit harness. */
  80. export interface TestSessionRemoteDefaults {
  81. readonly defaultModelSelection: () => AgentModelSelection
  82. readonly cwd: string
  83. readonly coldBlankProbeMaxEvents?: number
  84. readonly coldBlankProbeMaxBytes?: number
  85. readonly nativeOpen?: boolean
  86. readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
  87. readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
  88. readonly canOpenPath?: () => boolean
  89. }
  90. const installed = new WeakMap<Context, SessionController>()
  91. /** Compact header-and-events point read a persistence double declares per session. */
  92. interface TestSessionInspection {
  93. readonly meta: SessionHeader
  94. readonly events: readonly SessionEvent[]
  95. }
  96. type LegacyTestPersistence = Record<string, unknown> & {
  97. readonly list?: (signal?: AbortSignal) => Promise<readonly SessionHeader[]>
  98. readonly inspect?: (
  99. sessionId: SessionId,
  100. signal?: AbortSignal,
  101. ) => Promise<TestSessionInspection | undefined>
  102. readonly stat?: (
  103. sessionId: SessionId,
  104. options?: SessionPersistenceStatOptions,
  105. ) => Promise<SessionPersistenceSnapshot | undefined>
  106. readonly open?: (
  107. sessionId: SessionId,
  108. access: SessionAccess,
  109. options?: SessionPersistenceOpenOptions,
  110. ) => Promise<SessionHandle>
  111. }
  112. /** One immutable read handle over a double's inspected header and events. */
  113. function testReadHandle(
  114. sessionId: SessionId,
  115. inspection: TestSessionInspection,
  116. ): SessionHandle {
  117. const events = Object.freeze([...inspection.events])
  118. return {
  119. id: sessionId,
  120. header: inspection.meta,
  121. inheritedEventCount: SessionLogOffset(0),
  122. access: 'read',
  123. read: (offset = 0, length?: number, options?: SessionHandleReadOptions) => {
  124. options?.signal?.throwIfAborted()
  125. return Promise.resolve(events.slice(offset, length === undefined ? undefined : offset + length))
  126. },
  127. append: () => Promise.reject(new SessionReadOnlyError(sessionId, 'append')),
  128. flush: () => Promise.reject(new SessionReadOnlyError(sessionId, 'flush')),
  129. close: () => Promise.resolve(),
  130. [Symbol.asyncDispose]: () => Promise.resolve(),
  131. }
  132. }
  133. /**
  134. * Adapt a compact header/inspect persistence double onto the handle-based
  135. * abstract the production readers consume: `list` snapshots wrap the double's
  136. * headers, `stat` derives a metadata-less snapshot from the listing (so the
  137. * cold-blank probe skips unless the double declares its own `stat`), and
  138. * `open` serves immutable read handles over the double's `inspect` result.
  139. */
  140. export function testSessionPersistence(
  141. _ctx: Context,
  142. persistence: LegacyTestPersistence,
  143. ): Record<string, unknown> {
  144. const listHeaders = async (signal?: AbortSignal): Promise<readonly SessionHeader[]> =>
  145. await persistence.list?.(signal) ?? []
  146. const adapted: Record<string, unknown> = {
  147. ...persistence,
  148. list: async (options?: SessionPersistenceListOptions) =>
  149. (await listHeaders(options?.signal)).map(header => ({
  150. header,
  151. revision: SessionPersistenceRevision(`test:${header.id}:list`),
  152. })),
  153. }
  154. if (persistence.stat === undefined) {
  155. adapted.stat = async (
  156. sessionId: SessionId,
  157. options?: SessionPersistenceStatOptions,
  158. ): Promise<SessionPersistenceSnapshot | undefined> => {
  159. options?.signal?.throwIfAborted()
  160. const header = (await listHeaders(options?.signal)).find(listed => listed.id === sessionId)
  161. return header === undefined
  162. ? undefined
  163. : { header, revision: SessionPersistenceRevision(`test:${sessionId}:stat`) }
  164. }
  165. }
  166. if (persistence.open === undefined) {
  167. adapted.open = async (
  168. sessionId: SessionId,
  169. access: SessionAccess,
  170. options?: SessionPersistenceOpenOptions,
  171. ): Promise<SessionHandle> => {
  172. options?.signal?.throwIfAborted()
  173. if (access !== 'read') {
  174. throw new Error(`test persistence double only serves read handles (requested "${access}")`)
  175. }
  176. const inspection = await persistence.inspect?.(sessionId, options?.signal)
  177. if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId)
  178. return testReadHandle(sessionId, inspection)
  179. }
  180. }
  181. return adapted
  182. }
  183. /** Concrete point-read query used by Session Controller tests that do not exercise search. */
  184. class TestSessionQuery extends SessionQueryEngine {
  185. override searchSessions(): Promise<never> {
  186. return Promise.reject(new Error('session search is not configured in this test'))
  187. }
  188. override searchEvents(): Promise<never> {
  189. return Promise.reject(new Error('event search is not configured in this test'))
  190. }
  191. }
  192. /** Install the required projection and point-query services for direct controller tests. */
  193. export function installSessionReadTestServices(ctx: Context): void {
  194. if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx)
  195. if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx)
  196. }
  197. function installControllers(
  198. ctx: Context,
  199. defaults: TestSessionRemoteDefaults,
  200. ): SessionController {
  201. const found = installed.get(ctx)
  202. if (found !== undefined) return found
  203. if (ctx.get('typert') === undefined) {
  204. const dispose = (): void => {}
  205. ctx.provide('typert', {
  206. lookups: { configure: () => dispose },
  207. contexts: { configureHost: () => dispose },
  208. } as never)
  209. }
  210. if (ctx.get('agentDefaultModel') === undefined) {
  211. ctx.provide('agentDefaultModel', {
  212. currentSelection: defaults.defaultModelSelection,
  213. saveSelection: async (selection: AgentModelSelection) => {
  214. await defaults.saveDefaultModelSelection?.(selection)
  215. },
  216. } as never)
  217. }
  218. if (ctx.get('llm') === undefined) {
  219. ctx.provide('llm', {
  220. listProviders: () => {
  221. const selection = defaults.defaultModelSelection()
  222. return [{ id: selection.provider, name: selection.provider }]
  223. },
  224. } as never)
  225. }
  226. if (ctx.get('fileUploads') === undefined) {
  227. ctx.provide('fileUploads', {
  228. registerAgentResolver: () => () => {},
  229. resolve: () => undefined,
  230. bindPrompt: () => () => {},
  231. retirePrompt: () => {},
  232. } as never)
  233. }
  234. installSessionReadTestServices(ctx)
  235. const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
  236. let controller: SessionController
  237. try {
  238. controller = new SessionController(
  239. ctx,
  240. {
  241. ...defaults.coldBlankProbeMaxEvents === undefined
  242. ? {}
  243. : { coldBlankProbeMaxEvents: defaults.coldBlankProbeMaxEvents },
  244. ...defaults.coldBlankProbeMaxBytes === undefined
  245. ? {}
  246. : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes },
  247. ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen },
  248. },
  249. {
  250. ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
  251. ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
  252. },
  253. )
  254. } finally {
  255. cwd.mockRestore()
  256. }
  257. installed.set(ctx, controller)
  258. return controller
  259. }
  260. /** Build or return the production Session Controller for a direct unit harness. */
  261. export function createSessionTestController(
  262. ctx: Context,
  263. defaults: TestSessionRemoteDefaults,
  264. ): SessionController {
  265. return installControllers(ctx, defaults)
  266. }
  267. function remoteResult<T>(
  268. operation: () => T | Promise<T>,
  269. signal?: AbortSignal,
  270. ): Promise<RemoteResult<T>> {
  271. return Promise.resolve()
  272. .then(operation)
  273. .then(value => ({ ok: true as const, value }))
  274. .catch((error: unknown) => ({
  275. ok: false as const,
  276. error: signal?.aborted === true
  277. ? new RemoteError('gateway/cancelled', 'request was aborted', {})
  278. : remoteErrorOf(error)
  279. ?? new RemoteError(
  280. 'gateway/internal',
  281. error instanceof Error ? error.message : String(error),
  282. {},
  283. ),
  284. }))
  285. }
  286. /** Build the generated Session Remote's unary result semantics without a carrier. */
  287. export function createSessionTestRemote(
  288. ctx: Context,
  289. defaults: TestSessionRemoteDefaults,
  290. ): TestSessionRemote {
  291. const direct = createSessionTestController(ctx, defaults)
  292. return {
  293. canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()),
  294. list: (request, signal = new AbortController().signal) => remoteResult(
  295. () => direct.list(request, signal),
  296. signal,
  297. ),
  298. search: (request, signal = new AbortController().signal) => remoteResult(
  299. () => direct.search(request, signal),
  300. signal,
  301. ),
  302. create: request => remoteResult(() => direct.create(request)),
  303. selectModel: request => remoteResult(() => direct.selectModel(request)),
  304. modelCatalog: () => remoteResult(() => direct.modelCatalog()),
  305. rename: request => remoteResult(() => direct.rename(request)),
  306. fork: request => remoteResult(() => direct.fork(request)),
  307. prompt: (request, signal = new AbortController().signal) => remoteResult(
  308. () => direct.prompt(request, signal),
  309. signal,
  310. ),
  311. attachment: request => remoteResult(() => direct.attachment(request)),
  312. updateQueue: request => remoteResult(() => direct.updateQueue(request)),
  313. cancel: request => remoteResult(() => direct.cancel(request)),
  314. openWorkspacePath: (request, signal = new AbortController().signal) => remoteResult(
  315. () => direct.openWorkspacePath(request, signal),
  316. signal,
  317. ),
  318. page: (request, signal = new AbortController().signal) => remoteResult(
  319. () => direct.page(request, signal),
  320. signal,
  321. ),
  322. follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
  323. control: (signal = new AbortController().signal) => direct.control(signal),
  324. }
  325. }