session-export.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. /**
  2. * session.export host path: the GET download endpoint streams a ZIP whose
  3. * files are the stored artifacts verbatim (root + optional descendants), and
  4. * the degenerate compositions fail loudly (missing services → 500, missing
  5. * root → 404, missing descendant → errored stream).
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { Context } from '@deepseek-ai/cordis'
  9. import { unzipSync, strFromU8 } from 'fflate'
  10. import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  11. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  12. import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  13. import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
  14. import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
  15. import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  16. import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
  17. const sid = (id: string): SessionId => id as SessionId
  18. function header(id: string, parentSession?: SessionId): SessionHeader {
  19. return {
  20. version: 0,
  21. id: sid(id),
  22. createdAt: 1000,
  23. cwd: '/proj',
  24. ...parentSession === undefined ? {} : { parentSession },
  25. delegationDepth: parentSession === undefined ? 0 : 1,
  26. }
  27. }
  28. function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
  29. return {
  30. meta: header(id, parentSession),
  31. filename: 'session.jsonl',
  32. content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
  33. }
  34. }
  35. function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
  36. return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
  37. }
  38. /** One durable image object served by the fake attachment store. */
  39. function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
  40. return {
  41. ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
  42. data: new Uint8Array([1, 2, 3, 4]),
  43. }
  44. }
  45. /** A user/message event line carrying one image reference. */
  46. function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
  47. return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
  48. }
  49. async function buildApi(
  50. artifacts: Record<string, SessionRawArtifact>,
  51. descendants: SessionLineageNode[] = [],
  52. services: {
  53. query?: boolean
  54. persistence?: boolean | 'throw'
  55. attachments?: boolean | ((ref: ImageAttachmentRef) => Promise<ReturnType<typeof storedImage>>)
  56. } = {},
  57. ) {
  58. const ctx = new Context()
  59. await ctx.plugin(UserInteractionService)
  60. const query = services.query ?? true
  61. const persistence = services.persistence ?? true
  62. if (query) {
  63. ctx.provide('sessionQuery', {
  64. traceSession: async () => ({
  65. target: { header: header('session-root'), live: false, persisted: true },
  66. ancestors: [],
  67. complete: true,
  68. root: { header: header('session-root'), live: false, persisted: true },
  69. descendants,
  70. }),
  71. } as never)
  72. }
  73. if (persistence) {
  74. ctx.provide('sessionPersistence', {
  75. readRaw: async (id: SessionId) => {
  76. if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
  77. return artifacts[id]
  78. },
  79. } as never)
  80. }
  81. if (services.attachments !== false) {
  82. const readImage = typeof services.attachments === 'function'
  83. ? services.attachments
  84. : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
  85. ctx.provide('attachments', {
  86. imageLimits: {} as never,
  87. validateImage: async () => {},
  88. saveImage: async () => { throw new Error('export never saves images') },
  89. readImage,
  90. } as never)
  91. }
  92. return createApiProxy(ctx, {
  93. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  94. cwd: '/tmp',
  95. })
  96. }
  97. async function responseBytes(response: Response): Promise<Uint8Array> {
  98. return new Uint8Array(await response.arrayBuffer())
  99. }
  100. describe('session.export download endpoint', () => {
  101. it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
  102. const api = await buildApi({ 'session-root': artifact('session-root') })
  103. const response = await toFetchHandler(api).fetch(
  104. new Request('http://host/api/session.export?sessionId=session-root'),
  105. )
  106. expect(response.status).toBe(200)
  107. expect(response.headers.get('content-type')).toBe('application/zip')
  108. expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
  109. const files = unzipSync(await responseBytes(response))
  110. expect(Object.keys(files)).toEqual(['session.jsonl'])
  111. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
  112. })
  113. it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
  114. const api = await buildApi({
  115. 'session-root': artifact('session-root'),
  116. 'child-a': artifact('child-a', sid('session-root')),
  117. 'grandchild-a': artifact('grandchild-a', sid('child-a')),
  118. }, [
  119. node('child-a', node('grandchild-a')),
  120. ])
  121. const response = await toFetchHandler(api).fetch(
  122. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  123. )
  124. expect(response.status).toBe(200)
  125. const files = unzipSync(await responseBytes(response))
  126. expect(Object.keys(files).sort()).toEqual([
  127. 'session.jsonl',
  128. 'subagents/child-a/session.jsonl',
  129. 'subagents/grandchild-a/session.jsonl',
  130. ])
  131. expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array))
  132. .toBe(artifact('child-a').content)
  133. })
  134. it('answers 404 for a missing root session', async () => {
  135. const api = await buildApi({})
  136. const response = await toFetchHandler(api).fetch(
  137. new Request('http://host/api/session.export?sessionId=session-root'),
  138. )
  139. expect(response.status).toBe(404)
  140. })
  141. it('answers 400 when the sessionId query parameter is absent', async () => {
  142. const api = await buildApi({ 'session-root': artifact('session-root') })
  143. const response = await toFetchHandler(api).fetch(
  144. new Request('http://host/api/session.export?includeDescendants=true'),
  145. )
  146. expect(response.status).toBe(400)
  147. })
  148. it('answers 400 for an includeDescendants value other than true or false', async () => {
  149. const api = await buildApi({ 'session-root': artifact('session-root') })
  150. const response = await toFetchHandler(api).fetch(
  151. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
  152. )
  153. expect(response.status).toBe(400)
  154. })
  155. it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
  156. const api = await buildApi({}, [], { query: false, persistence: false })
  157. const response = await toFetchHandler(api).fetch(
  158. new Request('http://host/api/session.export?sessionId=session-root'),
  159. )
  160. expect(response.status).toBe(500)
  161. expect(await response.text()).toContain('session-query')
  162. })
  163. it('fails the whole export when a descendant has no stored artifact', async () => {
  164. const api = await buildApi({
  165. 'session-root': artifact('session-root'),
  166. }, [node('child-missing')])
  167. const response = await toFetchHandler(api).fetch(
  168. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  169. )
  170. expect(response.status).toBe(200)
  171. // The stream errors before completing, so the body read rejects rather
  172. // than returning a truncated-but-valid archive.
  173. await expect(response.arrayBuffer()).rejects.toThrow()
  174. })
  175. it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
  176. // The push loop slices by 2^16 code units and must back off one unit when
  177. // the boundary lands inside a surrogate pair; otherwise the pair re-encodes
  178. // as U+FFFD and the exported artifact is silently corrupted.
  179. const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
  180. const api = await buildApi({ 'session-root': root })
  181. const response = await toFetchHandler(api).fetch(
  182. new Request('http://host/api/session.export?sessionId=session-root'),
  183. )
  184. const files = unzipSync(await responseBytes(response))
  185. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
  186. })
  187. it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
  188. // A boundary that lands on a BMP character needs no surrogate backoff; the
  189. // round trip must still be byte-identical across the multi-chunk push.
  190. const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
  191. const api = await buildApi({ 'session-root': root })
  192. const response = await toFetchHandler(api).fetch(
  193. new Request('http://host/api/session.export?sessionId=session-root'),
  194. )
  195. const files = unzipSync(await responseBytes(response))
  196. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
  197. })
  198. it('exports an empty artifact as an empty zip entry', async () => {
  199. const root = { ...artifact('session-root'), content: '' }
  200. const api = await buildApi({ 'session-root': root })
  201. const response = await toFetchHandler(api).fetch(
  202. new Request('http://host/api/session.export?sessionId=session-root'),
  203. )
  204. const files = unzipSync(await responseBytes(response))
  205. expect(Object.keys(files)).toEqual(['session.jsonl'])
  206. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
  207. })
  208. it('exports a shared lineage node once (seen-set dedup)', async () => {
  209. const api = await buildApi({
  210. 'session-root': artifact('session-root'),
  211. 'child-a': artifact('child-a', sid('session-root')),
  212. 'child-b': artifact('child-b', sid('session-root')),
  213. shared: artifact('shared', sid('child-a')),
  214. }, [
  215. node('child-a', node('shared')),
  216. node('child-b', node('shared')),
  217. ])
  218. const response = await toFetchHandler(api).fetch(
  219. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  220. )
  221. const files = unzipSync(await responseBytes(response))
  222. expect(Object.keys(files).sort()).toEqual([
  223. 'session.jsonl',
  224. 'subagents/child-a/session.jsonl',
  225. 'subagents/child-b/session.jsonl',
  226. 'subagents/shared/session.jsonl',
  227. ])
  228. })
  229. it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
  230. const api = await buildApi({}, [], { query: true, persistence: 'throw' })
  231. const response = await toFetchHandler(api).fetch(
  232. new Request('http://host/api/session.export?sessionId=session-root'),
  233. )
  234. expect(response.status).toBe(500)
  235. const body = await response.text()
  236. expect(body).toBe('session log export failed to read the stored artifact')
  237. expect(body).not.toContain('/host/private/')
  238. })
  239. it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
  240. const root = artifact('session-root', undefined, [
  241. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  242. imageEventLine('img-1'),
  243. ].join('\n') + '\n')
  244. const api = await buildApi({ 'session-root': root })
  245. const response = await toFetchHandler(api).fetch(
  246. new Request('http://host/api/session.export?sessionId=session-root'),
  247. )
  248. expect(response.status).toBe(200)
  249. const files = unzipSync(await responseBytes(response))
  250. expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
  251. expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
  252. })
  253. it('collects media referenced from nested tool results', async () => {
  254. const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}'
  255. const root = artifact('session-root', undefined, [
  256. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  257. nested,
  258. ].join('\n') + '\n')
  259. const api = await buildApi({ 'session-root': root })
  260. const response = await toFetchHandler(api).fetch(
  261. new Request('http://host/api/session.export?sessionId=session-root'),
  262. )
  263. const files = unzipSync(await responseBytes(response))
  264. expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
  265. })
  266. it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
  267. const block = (id: string, mediaType: string) =>
  268. `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
  269. const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
  270. const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
  271. const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
  272. const root = artifact('session-root', undefined, [
  273. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  274. wrapped,
  275. inserted,
  276. chunk,
  277. ].join('\n') + '\n')
  278. const api = await buildApi({ 'session-root': root })
  279. const response = await toFetchHandler(api).fetch(
  280. new Request('http://host/api/session.export?sessionId=session-root'),
  281. )
  282. const files = unzipSync(await responseBytes(response))
  283. expect(Object.keys(files).sort()).toEqual([
  284. 'media/chunk-1.png',
  285. 'media/inserted-1.gif',
  286. 'media/wrapped-1.jpg',
  287. 'session.jsonl',
  288. ])
  289. })
  290. it('deduplicates one media object referenced by several included logs', async () => {
  291. const line = imageEventLine('shared-img')
  292. const root = artifact('session-root', undefined, [
  293. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  294. line,
  295. ].join('\n') + '\n')
  296. const child = artifact('child-a', sid('session-root'), [
  297. '{"type":"session","version":0,"id":"child-a","createdAt":1000}',
  298. line,
  299. ].join('\n') + '\n')
  300. const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
  301. const response = await toFetchHandler(api).fetch(
  302. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  303. )
  304. const files = unzipSync(await responseBytes(response))
  305. expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
  306. expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
  307. })
  308. it('includes descendant media only when descendants are requested', async () => {
  309. const child = artifact('child-a', sid('session-root'), [
  310. '{"type":"session","version":0,"id":"child-a","createdAt":1000}',
  311. imageEventLine('child-img'),
  312. ].join('\n') + '\n')
  313. const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
  314. const without = await toFetchHandler(api).fetch(
  315. new Request('http://host/api/session.export?sessionId=session-root'),
  316. )
  317. expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
  318. const withDescendants = await toFetchHandler(api).fetch(
  319. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  320. )
  321. expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
  322. 'media/child-img.png',
  323. 'session.jsonl',
  324. 'subagents/child-a/session.jsonl',
  325. ])
  326. })
  327. it('fails the whole export when a referenced image cannot be read', async () => {
  328. const root = artifact('session-root', undefined, [
  329. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  330. imageEventLine('gone-img'),
  331. ].join('\n') + '\n')
  332. const api = await buildApi({ 'session-root': root }, [], {
  333. attachments: async () => { throw new Error('attachment bytes missing') },
  334. })
  335. const response = await toFetchHandler(api).fetch(
  336. new Request('http://host/api/session.export?sessionId=session-root'),
  337. )
  338. expect(response.status).toBe(200)
  339. await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
  340. })
  341. it('answers 500 when the deployment mounts no attachments service', async () => {
  342. const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
  343. const response = await toFetchHandler(api).fetch(
  344. new Request('http://host/api/session.export?sessionId=session-root'),
  345. )
  346. expect(response.status).toBe(500)
  347. expect(await response.text()).toContain('attachments')
  348. })
  349. })