test-remote.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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 { vi } from 'vitest'
  5. import {
  6. TypertRemoteFailure,
  7. type RemoteResult,
  8. } from '@deepseek-ai/dsh-typert-protocol'
  9. import SessionController from '../src/index.ts'
  10. import type {
  11. SessionAttachmentRequest,
  12. SessionAttachmentValue,
  13. SessionCancelRequest,
  14. SessionCancelValue,
  15. SessionControlFrame,
  16. SessionCreateRequest,
  17. SessionCreateValue,
  18. SessionForkRequest,
  19. SessionForkValue,
  20. SessionListRequest,
  21. SessionListValue,
  22. SessionModels,
  23. SessionModelsRequest,
  24. SessionPage,
  25. SessionPageRequest,
  26. SessionPromptRequest,
  27. SessionPromptValue,
  28. SessionRenameRequest,
  29. SessionRenameValue,
  30. SessionSearchRequest,
  31. SessionSearchValue,
  32. SessionSelectModelRequest,
  33. SessionSelectModelValue,
  34. SessionUpdateQueueRequest,
  35. SessionUpdateQueueValue,
  36. } from '../src/types.ts'
  37. /** Direct test face matching the generated `ctx.remote.session` unary methods. */
  38. export interface TestSessionRemote {
  39. list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
  40. search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
  41. create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
  42. models(request: SessionModelsRequest): Promise<RemoteResult<SessionModels>>
  43. selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
  44. rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
  45. fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
  46. prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPromptValue>>
  47. attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
  48. updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
  49. cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
  50. page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
  51. control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
  52. }
  53. /** Dependencies and policy supplied by a Session Controller unit harness. */
  54. export interface TestSessionRemoteDefaults {
  55. readonly defaultModelSelection: () => AgentModelSelection
  56. readonly cwd: string
  57. readonly coldBlankProbeMaxBytes?: number
  58. readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
  59. }
  60. const installed = new WeakMap<Context, SessionController>()
  61. function installControllers(
  62. ctx: Context,
  63. defaults: TestSessionRemoteDefaults,
  64. ): SessionController {
  65. const found = installed.get(ctx)
  66. if (found !== undefined) return found
  67. if (ctx.get('typert') === undefined) {
  68. const dispose = (): void => {}
  69. ctx.provide('typert', {
  70. lookups: { configure: () => dispose },
  71. contexts: { configureHost: () => dispose },
  72. } as never)
  73. }
  74. if (ctx.get('agentDefaultModel') === undefined) {
  75. ctx.provide('agentDefaultModel', {
  76. currentSelection: defaults.defaultModelSelection,
  77. saveSelection: async (selection: AgentModelSelection) => {
  78. await defaults.saveDefaultModelSelection?.(selection)
  79. },
  80. } as never)
  81. }
  82. if (ctx.get('llm') === undefined) {
  83. ctx.provide('llm', {
  84. listProviders: () => {
  85. const selection = defaults.defaultModelSelection()
  86. return [{ id: selection.provider, name: selection.provider }]
  87. },
  88. } as never)
  89. }
  90. const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
  91. let controller: SessionController
  92. try {
  93. controller = new SessionController(ctx, defaults.coldBlankProbeMaxBytes === undefined
  94. ? {}
  95. : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes })
  96. } finally {
  97. cwd.mockRestore()
  98. }
  99. installed.set(ctx, controller)
  100. return controller
  101. }
  102. /** Build or return the production Session Controller for a direct unit harness. */
  103. export function createSessionTestController(
  104. ctx: Context,
  105. defaults: TestSessionRemoteDefaults,
  106. ): SessionController {
  107. return installControllers(ctx, defaults)
  108. }
  109. function remoteResult<T>(
  110. operation: () => T | Promise<T>,
  111. signal?: AbortSignal,
  112. ): Promise<RemoteResult<T>> {
  113. return Promise.resolve()
  114. .then(operation)
  115. .then(value => ({ ok: true as const, value }))
  116. .catch((error: unknown) => ({
  117. ok: false as const,
  118. error: signal?.aborted === true
  119. ? { code: 'cancelled', message: 'request was aborted', details: {} }
  120. : error instanceof TypertRemoteFailure
  121. ? error.failure
  122. : {
  123. code: 'internal',
  124. message: error instanceof Error ? error.message : String(error),
  125. details: {},
  126. },
  127. }))
  128. }
  129. /** Build the generated Session Remote's unary result semantics without a carrier. */
  130. export function createSessionTestRemote(
  131. ctx: Context,
  132. defaults: TestSessionRemoteDefaults,
  133. ): TestSessionRemote {
  134. const direct = createSessionTestController(ctx, defaults)
  135. return {
  136. list: (request, signal = new AbortController().signal) => remoteResult(
  137. () => direct.list(request, signal),
  138. signal,
  139. ),
  140. search: (request, signal = new AbortController().signal) => remoteResult(
  141. () => direct.search(request, signal),
  142. signal,
  143. ),
  144. create: request => remoteResult(() => direct.create(request)),
  145. models: request => remoteResult(() => direct.models(request)),
  146. selectModel: request => remoteResult(() => direct.selectModel(request)),
  147. rename: request => remoteResult(() => direct.rename(request)),
  148. fork: request => remoteResult(() => direct.fork(request)),
  149. prompt: (request, signal = new AbortController().signal) => remoteResult(
  150. () => direct.prompt(request, signal),
  151. signal,
  152. ),
  153. attachment: request => remoteResult(() => direct.attachment(request)),
  154. updateQueue: request => remoteResult(() => direct.updateQueue(request)),
  155. cancel: request => remoteResult(() => direct.cancel(request)),
  156. page: (request, signal = new AbortController().signal) => remoteResult(
  157. () => direct.page(request, signal),
  158. signal,
  159. ),
  160. control: (signal = new AbortController().signal) => direct.control(signal),
  161. }
  162. }