assembled-remote.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. /**
  2. * RemoteMock scenario for built-client tests that do not own a Host.
  3. * The adjacent JSON is maintained with this module when Remote responses or
  4. * the current Session header version change.
  5. * Fixture Sessions have no retained Host terminals to restore.
  6. */
  7. import { readFileSync } from 'node:fs'
  8. import { randomUUID } from 'node:crypto'
  9. import { join } from 'node:path'
  10. import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock'
  11. import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts'
  12. interface SessionSummary {
  13. readonly sessionId: string
  14. updatedAt: number
  15. running: boolean
  16. blank: boolean
  17. readonly parentSessionId?: string
  18. readonly origin?: 'subagent'
  19. readonly cwd?: string
  20. readonly projections?: {
  21. readonly asOfSeq: number
  22. readonly values: Readonly<Record<string, unknown>>
  23. }
  24. }
  25. interface WorkspaceView {
  26. readonly workspaceId: string
  27. readonly path: string
  28. readonly title: string
  29. sessionIds: string[]
  30. readonly createdAt: string
  31. updatedAt: string
  32. }
  33. interface EventRecord {
  34. readonly type: 'event'
  35. readonly event: {
  36. readonly seq: number
  37. readonly time: number
  38. readonly type: string
  39. readonly data: unknown
  40. readonly surfaceOp?: string
  41. }
  42. }
  43. interface FollowSnapshot {
  44. readonly type: 'snapshot'
  45. readonly header: Readonly<Record<string, unknown>>
  46. readonly cursor: number
  47. readonly records: readonly EventRecord[]
  48. readonly hasMore: boolean
  49. readonly projections: Readonly<Record<string, unknown>>
  50. readonly assistantStream?: Readonly<Record<string, unknown>>
  51. }
  52. interface ControlBaseline {
  53. readonly type: 'baseline'
  54. readonly value: {
  55. readonly queues: Readonly<Record<string, readonly unknown[]>>
  56. readonly jobs: Readonly<Record<string, readonly unknown[]>>
  57. readonly approvals: readonly unknown[]
  58. readonly questions: readonly unknown[]
  59. readonly projections: Readonly<Record<string, {
  60. readonly asOfSeq: number
  61. readonly values: Readonly<Record<string, unknown>>
  62. }>>
  63. }
  64. }
  65. interface CapturedFixture {
  66. readonly sessionList: { readonly ok: true; readonly value: { readonly items: readonly SessionSummary[] } }
  67. readonly settingsDescribe: unknown
  68. readonly credentialsDescribe: unknown
  69. readonly modelCatalog: unknown
  70. readonly agentPresets: unknown
  71. readonly commands: unknown
  72. readonly workspace: {
  73. readonly type: 'baseline'
  74. readonly value: {
  75. readonly items: readonly WorkspaceView[]
  76. readonly archivedSessionIds: readonly string[]
  77. }
  78. }
  79. readonly control: ControlBaseline
  80. readonly remoteEvents: readonly unknown[]
  81. readonly follow: FollowSnapshot
  82. readonly attachment: unknown
  83. }
  84. export interface AssembledRemoteOptions {
  85. /** Return the fixture's image-dimension admission error from Session prompt. */
  86. readonly rejectPrompt?: boolean
  87. }
  88. export interface AssembledRemote {
  89. readonly mock: RemoteMock
  90. }
  91. const fixtureSource = readFileSync(
  92. join(process.cwd(), 'apps/web/tests/fixtures/assembled-remote.fixture.json'),
  93. 'utf8',
  94. )
  95. /** Create one isolated RemoteMock world for an assembled built-client case. */
  96. export function createAssembledRemote(options: AssembledRemoteOptions = {}): AssembledRemote {
  97. const fixture = JSON.parse(fixtureSource) as CapturedFixture
  98. const sessions = [...structuredClone(fixture.sessionList.value.items)]
  99. const workspaces = [...structuredClone(fixture.workspace.value.items)]
  100. const records = new Map<string, EventRecord[]>([[
  101. 'fx-alpha',
  102. structuredClone(fixture.follow.records) as EventRecord[],
  103. ]])
  104. const nextTurns = new Map([...records].map(([sessionId, sessionRecords]) => {
  105. let next = 0
  106. for (const { event } of sessionRecords) {
  107. if (event.type !== 'turn/start' || !isRecord(event.data)) continue
  108. const turn = event.data['turn']
  109. if (typeof turn === 'number') next = Math.max(next, turn + 1)
  110. }
  111. return [sessionId, next] as const
  112. }))
  113. const attachments = new Map<string, unknown>([['fixture:image', structuredClone(fixture.attachment)]])
  114. const blankSessionProjections = fixture.control.value.projections['fx-gamma']
  115. if (blankSessionProjections === undefined) {
  116. throw new Error('assembled fixture: blank Session projections missing')
  117. }
  118. let nextSession = 1
  119. const mock = RemoteMock.create().load(remoteDefaultResponses)
  120. mock.load({
  121. unary: {
  122. 'settings/describe': structuredClone(fixture.settingsDescribe),
  123. 'credentials/describe': structuredClone(fixture.credentialsDescribe),
  124. 'session/modelCatalog': structuredClone(fixture.modelCatalog),
  125. 'agentPresets/list': structuredClone(fixture.agentPresets),
  126. 'commands/list': structuredClone(fixture.commands),
  127. 'settings/canOpenAgentPresetDirectory': ok(true),
  128. 'settings/openSettingsDocument': ok({ opened: true }),
  129. 'settings/openAgentPresetDirectory': ok({ opened: true }),
  130. 'subagents/list': ok({ entries: [], parentAvailable: true }),
  131. 'terminal/list': ok([]),
  132. 'skills/list': ok({ skills: [] }),
  133. 'session/canOpenWorkspacePath': ok(true),
  134. 'session/openWorkspacePath': ok({ opened: true }),
  135. 'session/updateQueue': {
  136. ok: false,
  137. error: {
  138. code: 'session/queue-item-not-found',
  139. message: 'assembled fixture has no pending queue item',
  140. details: {},
  141. },
  142. },
  143. 'session/cancel': ok({ accepted: true }),
  144. },
  145. })
  146. mock.stream('$events', (_args, stream) => {
  147. for (const frame of fixture.remoteEvents) stream.push(structuredClone(frame))
  148. })
  149. mock.stream('session/control', (_args, stream) => {
  150. stream.push(structuredClone(fixture.control))
  151. })
  152. mock.stream('workspace/follow', (_args, stream) => {
  153. stream.push({
  154. type: 'baseline',
  155. value: {
  156. items: structuredClone(workspaces),
  157. archivedSessionIds: structuredClone(fixture.workspace.value.archivedSessionIds),
  158. },
  159. })
  160. })
  161. mock.stream('session/follow', ([args], stream) => {
  162. const request = recordValue(args, 'request')
  163. const sessionId = followedSessionId(request)
  164. if (sessionId === 'fx-alpha') {
  165. stream.push(structuredClone(fixture.follow))
  166. return
  167. }
  168. const summary = sessions.find(candidate => candidate.sessionId === sessionId)
  169. if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`)
  170. const sessionRecords = records.get(sessionId) ?? []
  171. stream.push({
  172. type: 'snapshot',
  173. header: {
  174. version: 3,
  175. id: sessionId,
  176. createdAt: summary.updatedAt,
  177. cwd: summary.cwd,
  178. isSeeded: false,
  179. },
  180. cursor: sessionRecords.at(-1)?.event.seq ?? -1,
  181. records: structuredClone(sessionRecords),
  182. hasMore: false,
  183. projections: structuredClone(blankSessionProjections),
  184. ...isAssistantStreamRequested(request) ? { assistantStream: { revision: 0 } } : {},
  185. })
  186. })
  187. mock.unary('$events/result', (result: unknown) => {
  188. const eventId = recordString(result, 'eventId')
  189. mock.streams.push('$events', { type: 'cancel', eventId })
  190. return ok(undefined)
  191. })
  192. mock.unary('session/list', () => ok({ items: structuredClone(sessions) }))
  193. mock.unary('workspace/create', (request: unknown) => {
  194. const path = recordString(recordValue(request, 'request'), 'path')
  195. const existing = workspaces.find(workspace => workspace.path === path)
  196. if (existing !== undefined) return ok({ workspace: structuredClone(existing), created: false })
  197. const now = new Date().toISOString()
  198. const workspace = {
  199. workspaceId: `fx-ws-${String(workspaces.length + 1)}`,
  200. path,
  201. title: path.split('/').filter(Boolean).at(-1) ?? path,
  202. sessionIds: [],
  203. createdAt: now,
  204. updatedAt: now,
  205. }
  206. workspaces.unshift(workspace)
  207. mock.streams.push('workspace/follow', { type: 'upsert', workspace: structuredClone(workspace) })
  208. return ok({ workspace: structuredClone(workspace), created: true })
  209. })
  210. mock.unary('session/create', (request: unknown) => {
  211. request = recordValue(request, 'request')
  212. const requestedId = optionalRecordString(request, 'sessionId')
  213. const sessionId = requestedId ?? `fx-${String(nextSession++)}`
  214. const existing = sessions.find(candidate => candidate.sessionId === sessionId)
  215. if (existing !== undefined) return ok({ sessionId })
  216. const workspaceId = optionalRecordString(request, 'workspaceId')
  217. const workspace = workspaces.find(candidate => candidate.workspaceId === workspaceId)
  218. const cwd = workspace?.path ?? optionalRecordString(request, 'cwd') ?? '/tmp/fixture'
  219. const summary: SessionSummary = {
  220. sessionId,
  221. updatedAt: Date.now(),
  222. running: false,
  223. blank: true,
  224. cwd,
  225. projections: structuredClone(blankSessionProjections),
  226. }
  227. sessions.push(summary)
  228. records.set(sessionId, [])
  229. nextTurns.set(sessionId, 0)
  230. if (workspace !== undefined && !workspace.sessionIds.includes(sessionId)) {
  231. workspace.sessionIds = [sessionId, ...workspace.sessionIds]
  232. workspace.updatedAt = new Date().toISOString()
  233. mock.streams.push('workspace/follow', {
  234. type: 'upsert',
  235. workspace: structuredClone(workspace),
  236. })
  237. }
  238. mock.streams.push('$events', { type: 'emit', event: 'api-session/added', args: [structuredClone(summary)] })
  239. return ok({ sessionId })
  240. })
  241. mock.unary('session/attachment', (request: unknown) => {
  242. request = recordValue(request, 'request')
  243. const attachmentId = recordString(request, 'attachmentId')
  244. return attachments.get(attachmentId) ?? {
  245. ok: false,
  246. error: {
  247. code: 'session/attachment-invalid',
  248. message: `assembled fixture attachment ${attachmentId} is missing`,
  249. details: { reason: 'ATTACHMENT_NOT_FOUND' },
  250. },
  251. }
  252. })
  253. mock.unary('session/prompt', (request: unknown) => {
  254. request = recordValue(request, 'request')
  255. if (options.rejectPrompt === true) {
  256. return {
  257. ok: false,
  258. error: {
  259. code: 'session/attachment-invalid',
  260. message: 'assembled fixture: image side exceeds the deployment limit',
  261. details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' },
  262. },
  263. }
  264. }
  265. const sessionId = recordString(request, 'sessionId')
  266. const requestId = recordString(request, 'requestId')
  267. const sessionRecords = records.get(sessionId) ?? []
  268. const summary = sessions.find(candidate => candidate.sessionId === sessionId)
  269. if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`)
  270. const content = recordArray(request, 'content').map((part) => {
  271. if (!isRecord(part) || part.type !== 'image') return part
  272. const attachmentId = `assembled:${randomUUID()}`
  273. const data = recordString(part, 'data')
  274. const attachment = {
  275. attachmentId,
  276. mediaType: recordString(part, 'mediaType'),
  277. bytes: Math.max(1, Math.floor(data.length * 3 / 4)),
  278. width: 160,
  279. height: 90,
  280. ...optionalRecordString(part, 'name') === undefined
  281. ? {}
  282. : { name: optionalRecordString(part, 'name') },
  283. }
  284. attachments.set(attachmentId, ok({ attachment, data }))
  285. return { type: 'image', attachment }
  286. })
  287. const turn = nextTurns.get(sessionId) ?? 0
  288. nextTurns.set(sessionId, turn + 1)
  289. summary.updatedAt = Date.now()
  290. summary.blank = false
  291. if (!summary.running) {
  292. summary.running = true
  293. mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [sessionId, true] })
  294. }
  295. const turnEvent = eventOf(sessionRecords.length, 'turn/start', { turn })
  296. const userEvent = eventOf(sessionRecords.length + 1, 'user/message', {
  297. content,
  298. source: { kind: 'user', rpcId: requestId },
  299. role: 'user',
  300. id: randomUUID(),
  301. }, 'append')
  302. sessionRecords.push(turnEvent, userEvent)
  303. records.set(sessionId, sessionRecords)
  304. mock.streams.push('session/follow', turnEvent, follows(sessionId))
  305. mock.streams.push('session/follow', userEvent, follows(sessionId))
  306. return ok({ accepted: true })
  307. })
  308. mock.unary('commands/execute', (args: unknown) => {
  309. const line = optionalRecordString(args, 'line') ?? ''
  310. const name = /^\/(\S+)/u.exec(line.trim())?.[1]
  311. if (name === undefined || !['compact', 'echo', 'goal', 'permission', 'plan'].includes(name)) {
  312. return ok(undefined)
  313. }
  314. return ok({
  315. commandId: `assembled-command-${randomUUID()}`,
  316. result: {
  317. kind: 'success',
  318. ...name === 'echo' ? { text: line.replace(/^\/echo\s*/u, '') } : {},
  319. },
  320. })
  321. })
  322. return { mock }
  323. }
  324. function eventOf(
  325. seq: number,
  326. type: string,
  327. data: unknown,
  328. surfaceOp?: string,
  329. ): EventRecord {
  330. return {
  331. type: 'event',
  332. event: {
  333. seq,
  334. time: Date.now(),
  335. type,
  336. data,
  337. ...(surfaceOp === undefined ? {} : { surfaceOp }),
  338. },
  339. }
  340. }
  341. function follows(sessionId: string): (args: readonly unknown[]) => boolean {
  342. return ([args]) => followedSessionId(recordValue(args, 'request')) === sessionId
  343. }
  344. function followedSessionId(value: unknown): string {
  345. if (!isRecord(value) || !isRecord(value.address)) throw new TypeError('assembled fixture follow request is invalid')
  346. return value.address.kind === 'session'
  347. ? recordString(value.address, 'sessionId')
  348. : recordString(value.address, 'childSessionId')
  349. }
  350. function isAssistantStreamRequested(value: unknown): boolean {
  351. return isRecord(value) && value.assistantStream === true
  352. }
  353. function recordString(value: unknown, key: string): string {
  354. const selected = isRecord(value) ? value[key] : undefined
  355. if (typeof selected !== 'string') throw new TypeError(`assembled fixture ${key} must be a string`)
  356. return selected
  357. }
  358. function optionalRecordString(value: unknown, key: string): string | undefined {
  359. const selected = isRecord(value) ? value[key] : undefined
  360. if (selected === undefined) return undefined
  361. if (typeof selected !== 'string') throw new TypeError(`assembled fixture ${key} must be a string`)
  362. return selected
  363. }
  364. function recordArray(value: unknown, key: string): readonly unknown[] {
  365. const selected = isRecord(value) ? value[key] : undefined
  366. if (!Array.isArray(selected)) throw new TypeError(`assembled fixture ${key} must be an array`)
  367. return selected
  368. }
  369. function recordValue(value: unknown, key: string): unknown {
  370. if (!isRecord(value) || !(key in value)) throw new TypeError(`assembled fixture ${key} is missing`)
  371. return value[key]
  372. }
  373. function isRecord(value: unknown): value is Record<string, unknown> {
  374. return typeof value === 'object' && value !== null
  375. }