session.client.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /**
  2. * The Remote side of one Session under test: default answers for every
  3. * `session/*` and `subagents/*` endpoint a `Session` or its manager calls, builders for
  4. * the two history-shaped answers, the `session/follow` opening snapshot and
  5. * the `session/page` page, both derived from event lists the way the Host
  6. * derives them from its log, and builders for the `session/control` queue
  7. * frame and the attachment references the Host's log carries.
  8. */
  9. import { AttachmentId, type FileAttachmentRef, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  10. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
  11. import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
  12. import { ok, type RemoteMock, type RemoteTable, type StreamScript, type UnaryRuleFn } from '@deepseek-ai/dsh-remote-mock'
  13. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
  14. import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
  15. import type {
  16. SessionAssistantStreamBaseline, SessionControlFrame, SessionFollowFrame, SessionFollowRequest,
  17. SessionPage, SessionPageRequest, SessionQueuedItem, SessionRequestId,
  18. } from '../../src/types.ts'
  19. import { entries, historyValue } from '../event-script.client.ts'
  20. import { followSnapshot, pageThrough } from './history.client.ts'
  21. export { followSnapshot } from './history.client.ts'
  22. /** Endpoints a Session opens and calls for its history. */
  23. export const FOLLOW = 'session/follow'
  24. export const PAGE = 'session/page'
  25. /** A history answer as the Host returns it, possibly still pending. */
  26. export type HistoryAnswer = RemoteResult<SessionPage> | Promise<RemoteResult<SessionPage>>
  27. /** A history answer, or a function of the request that produces one. */
  28. export type HistorySource<Request> = HistoryAnswer | ((request: Request) => HistoryAnswer)
  29. /**
  30. * The failure branch of a Remote result.
  31. * @param error - the owner-declared failure.
  32. * @returns the result.
  33. */
  34. export function err<T>(error: RemoteFailure): RemoteResult<T> {
  35. return { ok: false, error }
  36. }
  37. /**
  38. * The Host's history answer for `events`.
  39. * @param events - events in seq order.
  40. * @param hasMore - whether older history exists.
  41. * @returns the success result.
  42. */
  43. export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult<SessionPage> {
  44. return ok(historyValue(events, hasMore))
  45. }
  46. /**
  47. * One live event frame of a follow stream.
  48. * @param event - the event.
  49. * @returns the frame.
  50. */
  51. export function frame(event: SessionEvent): SessionFollowFrame {
  52. return entries([event])[0]!
  53. }
  54. /**
  55. * Deliver one live event to every open follow stream and wait until the client has consumed it.
  56. * @param mock - the mock holding the streams.
  57. * @param event - the event.
  58. */
  59. export async function pushEvent(mock: RemoteMock, event: SessionEvent): Promise<void> {
  60. mock.streams.push(FOLLOW, frame(event))
  61. await mock.streams.drained(FOLLOW)
  62. }
  63. /**
  64. * `session/follow` script: the opening snapshot built from the history answer,
  65. * then open for pushes. A failed answer fails the stream with its error; a
  66. * rejected one fails it with the rejection.
  67. * @param history - history answer or a function of the follow request.
  68. * @param options - snapshot cursor and initial Assistant stream state; the latter may be read per opening.
  69. * @returns the script.
  70. */
  71. export function followScript(
  72. history: HistorySource<SessionFollowRequest>,
  73. options: {
  74. cursor?: number
  75. assistantStream?: SessionAssistantStreamBaseline | (() => SessionAssistantStreamBaseline)
  76. } = {},
  77. ): StreamScript {
  78. return async ([request], stream) => {
  79. const follow = request as SessionFollowRequest
  80. const result = await answer(history, follow)
  81. if (!result.ok) {
  82. stream.fail(result.error)
  83. return
  84. }
  85. const assistantStream = typeof options.assistantStream === 'function'
  86. ? options.assistantStream()
  87. : options.assistantStream
  88. stream.push(followSnapshot(result.value, follow, options.cursor, assistantStream))
  89. }
  90. }
  91. /**
  92. * `session/page` rule: the history answer cut at the request's `throughSeq`.
  93. * @param history - history answer or a function of the page request.
  94. * @returns the rule.
  95. */
  96. export function pageRule(
  97. history: HistorySource<SessionPageRequest>,
  98. ): UnaryRuleFn<readonly [SessionPageRequest], Promise<RemoteResult<SessionPage>>> {
  99. return async (page) => {
  100. const result = await answer(history, page)
  101. if (!result.ok) return result
  102. return ok(pageThrough(result.value, page.throughSeq))
  103. }
  104. }
  105. function answer<Request>(history: HistorySource<Request>, request: Request): HistoryAnswer {
  106. return typeof history === 'function' ? history(request) : history
  107. }
  108. /**
  109. * An image attachment reference as the Host's durable log carries it.
  110. * @param id - opaque attachment id.
  111. * @returns the reference.
  112. */
  113. export function imageRef(id: string): ImageAttachmentRef {
  114. return { attachmentId: AttachmentId(id), mediaType: 'image/png', bytes: 1, width: 2, height: 2 }
  115. }
  116. /**
  117. * A file attachment reference as the Host's durable log carries it.
  118. * @param id - opaque attachment id.
  119. * @param name - display filename.
  120. * @returns the reference.
  121. */
  122. export function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
  123. return { attachmentId: AttachmentId(id), name, bytes: 3 }
  124. }
  125. /** One pending inbox occurrence of a queue frame; the message id defaults to the item id. */
  126. export interface QueueItemFixture {
  127. readonly id: string
  128. readonly placement?: SessionQueuedItem['placement']
  129. /** Prompt identity the Host copied from the queued message's user source. */
  130. readonly rpcId?: SessionRequestId
  131. /** Queued message content; ignored when `message` is given. */
  132. readonly content?: readonly ContentBlock[]
  133. /** The queued message itself, for a test that also lands it durably (the queue mirror correlates by `id`). */
  134. readonly message?: { readonly id: MessageId; readonly content: readonly ContentBlock[] }
  135. }
  136. /**
  137. * One pending inbox occurrence as the Host lists it.
  138. * @param fixture - item fields.
  139. * @returns the item.
  140. */
  141. export function queueItem(fixture: QueueItemFixture): SessionQueuedItem {
  142. const message = fixture.message ?? { id: fixture.id as MessageId, content: fixture.content ?? [] }
  143. return {
  144. id: fixture.id as MessageId,
  145. placement: fixture.placement ?? 'queued',
  146. ...(fixture.rpcId === undefined ? {} : { rpcId: fixture.rpcId }),
  147. // The Host serializes queued content to JSON; the blocks cross unchanged.
  148. message: { id: message.id, content: message.content as unknown as SessionQueuedItem['message']['content'] },
  149. }
  150. }
  151. /**
  152. * One authoritative queue frame of the `session/control` stream.
  153. * @param sessionId - the addressed Session.
  154. * @param items - the complete pending queue.
  155. * @returns the frame.
  156. */
  157. export function queueFrame(
  158. sessionId: SessionId,
  159. items: readonly QueueItemFixture[],
  160. ): Extract<SessionControlFrame, { type: 'queue' }> {
  161. return { type: 'queue', sessionId, items: items.map(queueItem) }
  162. }
  163. /** Default answers: every command accepted, empty history and subagent catalog, one attachment of one zero byte. */
  164. export const sessionWorld: RemoteTable = {
  165. unary: {
  166. 'session/prompt': ok({ accepted: true }),
  167. 'session/cancel': ok({ accepted: true }),
  168. 'session/updateQueue': ok({ accepted: true }),
  169. 'session/rename': ok({ title: 'fk-renamed', seq: 0 }),
  170. 'session/attachment': ok({
  171. attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
  172. data: 'AA==',
  173. }),
  174. 'session/page': pageRule(ok({ records: [], hasMore: false })),
  175. 'subagents/list': ok({ entries: [], parentAvailable: true }),
  176. 'subagents/prompt': ok({ messageId: 'fake-message' }),
  177. 'subagents/interruptByParent': ok({ accepted: true }),
  178. },
  179. stream: {
  180. 'session/follow': followScript(ok({ records: [], hasMore: false })),
  181. },
  182. }