session-export.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  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 { randomBytes } from 'node:crypto'
  8. import { describe, expect, it, vi } from 'vitest'
  9. import { Context } from '@deepseek-ai/cordis'
  10. import { unzipSync, strFromU8 } from 'fflate'
  11. import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  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 ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  16. const sid = (id: string): SessionId => id as SessionId
  17. function header(id: string, parentSession?: SessionId): SessionHeader {
  18. return {
  19. version: 0,
  20. id: sid(id),
  21. createdAt: 1000,
  22. cwd: '/proj',
  23. ...parentSession === undefined ? {} : { parentSession },
  24. delegationDepth: parentSession === undefined ? 0 : 1,
  25. }
  26. }
  27. function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
  28. return {
  29. meta: header(id, parentSession),
  30. filename: 'session.jsonl',
  31. content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
  32. }
  33. }
  34. function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
  35. return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
  36. }
  37. /** One durable image object served by the fake attachment store. */
  38. function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
  39. return {
  40. ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
  41. data: new Uint8Array([1, 2, 3, 4]),
  42. }
  43. }
  44. /** A user/message event line carrying one image reference. */
  45. function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
  46. return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
  47. }
  48. async function buildApi(
  49. artifacts: Record<string, SessionRawArtifact>,
  50. descendants: SessionLineageNode[] = [],
  51. services: {
  52. query?: boolean
  53. persistence?: boolean | 'throw' | 'unsupported'
  54. attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
  55. sessions?: {
  56. get(id: SessionId): { readonly id: SessionId } | undefined
  57. flush(session: { readonly id: SessionId }): Promise<boolean>
  58. }
  59. readRaw?: (id: SessionId, signal?: AbortSignal) => Promise<SessionRawArtifact | undefined>
  60. traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
  61. target: { header: SessionHeader; live: boolean; persisted: boolean }
  62. ancestors: readonly SessionLineageNode[]
  63. complete: boolean
  64. root: { header: SessionHeader; live: boolean; persisted: boolean }
  65. descendants: readonly SessionLineageNode[]
  66. }>
  67. compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
  68. } = {},
  69. ) {
  70. const ctx = new Context()
  71. const query = services.query ?? true
  72. const persistence = services.persistence ?? true
  73. if (query) {
  74. ctx.provide('sessionQuery', {
  75. traceSession: services.traceSession ?? (async () => ({
  76. target: { header: header('session-root'), live: false, persisted: true },
  77. ancestors: [],
  78. complete: true,
  79. root: { header: header('session-root'), live: false, persisted: true },
  80. descendants,
  81. })),
  82. } as never)
  83. }
  84. if (persistence) {
  85. ctx.provide('sessionPersistence', {
  86. supportsRawArtifacts: persistence !== 'unsupported',
  87. readRaw: services.readRaw ?? (async (id: SessionId) => {
  88. if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
  89. return artifacts[id]
  90. }),
  91. } as never)
  92. }
  93. if (services.attachments !== false) {
  94. const readImage = typeof services.attachments === 'function'
  95. ? services.attachments
  96. : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
  97. ctx.provide('attachments', {
  98. imageLimits: {} as never,
  99. validateImage: async () => {},
  100. saveImage: async () => { throw new Error('export never saves images') },
  101. readImage,
  102. } as never)
  103. }
  104. if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
  105. return createApiProxy(ctx, {
  106. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  107. cwd: '/tmp',
  108. ...services.compressionLevel === undefined
  109. ? {}
  110. : { sessionExportCompressionLevel: services.compressionLevel },
  111. })
  112. }
  113. async function responseBytes(response: Response): Promise<Uint8Array> {
  114. return new Uint8Array(await response.arrayBuffer())
  115. }
  116. describe('session export compression config', () => {
  117. it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
  118. expect(ApiProxyService.Config({})).toEqual({
  119. sessionExportCompressionLevel: 6,
  120. })
  121. expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
  122. .toEqual({ sessionExportCompressionLevel: 0 })
  123. expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
  124. .toEqual({ sessionExportCompressionLevel: 9 })
  125. for (const value of [-1, 10, 1.5]) {
  126. expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
  127. }
  128. })
  129. })
  130. describe('session.export download endpoint', () => {
  131. it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
  132. const api = await buildApi({ 'session-root': artifact('session-root') })
  133. const response = await toFetchHandler(api).fetch(
  134. new Request('http://host/api/session.export?sessionId=session-root'),
  135. )
  136. expect(response.status).toBe(200)
  137. expect(response.headers.get('content-type')).toBe('application/zip')
  138. expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
  139. const files = unzipSync(await responseBytes(response))
  140. expect(Object.keys(files)).toEqual(['session.jsonl'])
  141. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
  142. })
  143. it('preflights root preparation through HEAD without streaming a body', async () => {
  144. const readRaw = vi.fn(async () => artifact('session-root'))
  145. const api = await buildApi({}, [], { readRaw })
  146. const response = await toFetchHandler(api).fetch(
  147. new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
  148. )
  149. expect(response.status).toBe(200)
  150. expect(response.headers.get('content-type')).toBe('application/zip')
  151. expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
  152. expect(response.body).toBeNull()
  153. expect(readRaw).toHaveBeenCalledOnce()
  154. })
  155. it('returns a bodyless preparation error from HEAD', async () => {
  156. const api = await buildApi({})
  157. const response = await toFetchHandler(api).fetch(
  158. new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
  159. )
  160. expect(response.status).toBe(404)
  161. expect(response.body).toBeNull()
  162. })
  163. it('uses the resolved compression level for ZIP entries', async () => {
  164. const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024))
  165. const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 })
  166. const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 })
  167. const stored = await storedApi.downloads.sessionLog(
  168. { sessionId: sid('session-root'), includeDescendants: false },
  169. new AbortController().signal,
  170. )
  171. const compressed = await compressedApi.downloads.sessionLog(
  172. { sessionId: sid('session-root'), includeDescendants: false },
  173. new AbortController().signal,
  174. )
  175. const storedBytes = await responseBytes(stored)
  176. const compressedBytes = await responseBytes(compressed)
  177. expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
  178. expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content)
  179. })
  180. it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
  181. const api = await buildApi({
  182. 'session-root': artifact('session-root'),
  183. 'child-a': artifact('child-a', sid('session-root')),
  184. 'grandchild-a': artifact('grandchild-a', sid('child-a')),
  185. }, [
  186. node('child-a', node('grandchild-a')),
  187. ])
  188. const response = await toFetchHandler(api).fetch(
  189. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  190. )
  191. expect(response.status).toBe(200)
  192. const files = unzipSync(await responseBytes(response))
  193. expect(Object.keys(files).sort()).toEqual([
  194. 'session.jsonl',
  195. 'subagents/child-a/session.jsonl',
  196. 'subagents/grandchild-a/session.jsonl',
  197. ])
  198. expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array))
  199. .toBe(artifact('child-a').content)
  200. })
  201. it('flushes each live root and descendant immediately before reading its artifact', async () => {
  202. const stored: Record<string, SessionRawArtifact> = {
  203. 'session-root': artifact('session-root', undefined, 'stale root'),
  204. 'child-a': artifact('child-a', sid('session-root'), 'stale child'),
  205. }
  206. const durable: Record<string, SessionRawArtifact> = {
  207. 'session-root': artifact('session-root', undefined, 'durable root'),
  208. 'child-a': artifact('child-a', sid('session-root'), 'durable child'),
  209. }
  210. const flushed: SessionId[] = []
  211. const api = await buildApi(stored, [node('child-a')], {
  212. sessions: {
  213. get: id => durable[id] === undefined ? undefined : { id },
  214. flush: async (session) => {
  215. const artifactAfterFlush = durable[session.id]
  216. if (artifactAfterFlush === undefined) throw new Error('unexpected session')
  217. flushed.push(session.id)
  218. stored[session.id] = artifactAfterFlush
  219. return true
  220. },
  221. },
  222. })
  223. const response = await toFetchHandler(api).fetch(
  224. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  225. )
  226. const files = unzipSync(await responseBytes(response))
  227. expect(flushed).toEqual([sid('session-root'), sid('child-a')])
  228. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root')
  229. expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child')
  230. })
  231. it('reads a cold artifact without asking the live-session store to flush', async () => {
  232. const flush = vi.fn(async () => true)
  233. const root = artifact('session-root')
  234. const api = await buildApi({ 'session-root': root }, [], {
  235. sessions: {
  236. get: () => undefined,
  237. flush,
  238. },
  239. })
  240. const response = await api.downloads.sessionLog(
  241. { sessionId: sid('session-root'), includeDescendants: false },
  242. new AbortController().signal,
  243. )
  244. const files = unzipSync(await responseBytes(response))
  245. expect(flush).not.toHaveBeenCalled()
  246. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
  247. })
  248. it('answers 404 for a missing root session', async () => {
  249. const api = await buildApi({})
  250. const response = await toFetchHandler(api).fetch(
  251. new Request('http://host/api/session.export?sessionId=session-root'),
  252. )
  253. expect(response.status).toBe(404)
  254. })
  255. it('answers 501 when the persistence backend has no per-session raw artifacts', async () => {
  256. const api = await buildApi({}, [], { persistence: 'unsupported' })
  257. const response = await toFetchHandler(api).fetch(
  258. new Request('http://host/api/session.export?sessionId=session-root'),
  259. )
  260. expect(response.status).toBe(501)
  261. expect(await response.text()).toContain('does not expose per-session raw artifacts')
  262. })
  263. it('answers 400 when the sessionId query parameter is absent', async () => {
  264. const api = await buildApi({ 'session-root': artifact('session-root') })
  265. const response = await toFetchHandler(api).fetch(
  266. new Request('http://host/api/session.export?includeDescendants=true'),
  267. )
  268. expect(response.status).toBe(400)
  269. })
  270. it('answers 400 for an includeDescendants value other than true or false', async () => {
  271. const api = await buildApi({ 'session-root': artifact('session-root') })
  272. const response = await toFetchHandler(api).fetch(
  273. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
  274. )
  275. expect(response.status).toBe(400)
  276. })
  277. it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
  278. const api = await buildApi({}, [], { query: false, persistence: false })
  279. const response = await toFetchHandler(api).fetch(
  280. new Request('http://host/api/session.export?sessionId=session-root'),
  281. )
  282. expect(response.status).toBe(500)
  283. expect(await response.text()).toContain('session-query')
  284. })
  285. it('fails the whole export when a descendant has no stored artifact', async () => {
  286. const api = await buildApi({
  287. 'session-root': artifact('session-root'),
  288. }, [node('child-missing')])
  289. const response = await toFetchHandler(api).fetch(
  290. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  291. )
  292. expect(response.status).toBe(200)
  293. // The stream errors before completing, so the body read rejects rather
  294. // than returning a truncated-but-valid archive.
  295. await expect(response.arrayBuffer()).rejects.toThrow()
  296. })
  297. it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
  298. // The push loop slices by 2^16 code units and must back off one unit when
  299. // the boundary lands inside a surrogate pair; otherwise the pair re-encodes
  300. // as U+FFFD and the exported artifact is silently corrupted.
  301. const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
  302. const api = await buildApi({ 'session-root': root })
  303. const response = await toFetchHandler(api).fetch(
  304. new Request('http://host/api/session.export?sessionId=session-root'),
  305. )
  306. const files = unzipSync(await responseBytes(response))
  307. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
  308. })
  309. it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
  310. // A boundary that lands on a BMP character needs no surrogate backoff; the
  311. // round trip must still be byte-identical across the multi-chunk push.
  312. const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
  313. const api = await buildApi({ 'session-root': root })
  314. const response = await toFetchHandler(api).fetch(
  315. new Request('http://host/api/session.export?sessionId=session-root'),
  316. )
  317. const files = unzipSync(await responseBytes(response))
  318. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
  319. })
  320. it('waits for response pull capacity before reading the next archive entry', async () => {
  321. const root = artifact('session-root', undefined, [
  322. imageEventLine('after-root'),
  323. randomBytes(512 * 1024).toString('base64'),
  324. ].join('\n'))
  325. let imageReads = 0
  326. const api = await buildApi({ 'session-root': root }, [], {
  327. attachments: async (ref) => {
  328. imageReads += 1
  329. return storedImage(String(ref.attachmentId), ref.mediaType)
  330. },
  331. })
  332. vi.useFakeTimers()
  333. let response: Response | undefined
  334. try {
  335. response = await toFetchHandler(api).fetch(
  336. new Request('http://host/api/session.export?sessionId=session-root'),
  337. )
  338. // Exhausting timer turns must not advance a producer whose byte queue is
  339. // full; only a consumer pull can release it.
  340. await vi.runAllTimersAsync()
  341. expect(imageReads).toBe(0)
  342. } finally {
  343. vi.useRealTimers()
  344. }
  345. if (response === undefined) throw new Error('missing export response')
  346. const files = unzipSync(await responseBytes(response))
  347. expect(imageReads).toBe(1)
  348. expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
  349. })
  350. it('exports an empty artifact as an empty zip entry', async () => {
  351. const root = { ...artifact('session-root'), content: '' }
  352. const api = await buildApi({ 'session-root': root })
  353. const response = await toFetchHandler(api).fetch(
  354. new Request('http://host/api/session.export?sessionId=session-root'),
  355. )
  356. const files = unzipSync(await responseBytes(response))
  357. expect(Object.keys(files)).toEqual(['session.jsonl'])
  358. expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
  359. })
  360. it('exports a shared lineage node once (seen-set dedup)', async () => {
  361. const api = await buildApi({
  362. 'session-root': artifact('session-root'),
  363. 'child-a': artifact('child-a', sid('session-root')),
  364. 'child-b': artifact('child-b', sid('session-root')),
  365. shared: artifact('shared', sid('child-a')),
  366. }, [
  367. node('child-a', node('shared')),
  368. node('child-b', node('shared')),
  369. ])
  370. const response = await toFetchHandler(api).fetch(
  371. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  372. )
  373. const files = unzipSync(await responseBytes(response))
  374. expect(Object.keys(files).sort()).toEqual([
  375. 'session.jsonl',
  376. 'subagents/child-a/session.jsonl',
  377. 'subagents/child-b/session.jsonl',
  378. 'subagents/shared/session.jsonl',
  379. ])
  380. })
  381. it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
  382. const api = await buildApi({}, [], { query: true, persistence: 'throw' })
  383. const response = await toFetchHandler(api).fetch(
  384. new Request('http://host/api/session.export?sessionId=session-root'),
  385. )
  386. expect(response.status).toBe(500)
  387. const body = await response.text()
  388. expect(body).toBe('session log export failed to prepare the stored artifact')
  389. expect(body).not.toContain('/host/private/')
  390. })
  391. it('answers the private-error-safe 500 when the live root flush fails', async () => {
  392. const api = await buildApi({ 'session-root': artifact('session-root') }, [], {
  393. sessions: {
  394. get: id => ({ id }),
  395. flush: async () => { throw new Error('/host/private/flush-state') },
  396. },
  397. })
  398. const response = await toFetchHandler(api).fetch(
  399. new Request('http://host/api/session.export?sessionId=session-root'),
  400. )
  401. expect(response.status).toBe(500)
  402. const body = await response.text()
  403. expect(body).toBe('session log export failed to prepare the stored artifact')
  404. expect(body).not.toContain('/host/private/')
  405. })
  406. it('forwards one request signal through root, lineage, and descendant reads', async () => {
  407. const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
  408. const traces: AbortSignal[] = []
  409. const api = await buildApi({}, [node('child-a')], {
  410. readRaw: async (id, signal) => {
  411. reads.push({ id, signal })
  412. return id === sid('session-root')
  413. ? artifact('session-root')
  414. : artifact('child-a', sid('session-root'))
  415. },
  416. traceSession: async (_id, signal) => {
  417. if (signal !== undefined) traces.push(signal)
  418. return {
  419. target: { header: header('session-root'), live: false, persisted: true },
  420. ancestors: [],
  421. complete: true,
  422. root: { header: header('session-root'), live: false, persisted: true },
  423. descendants: [node('child-a')],
  424. }
  425. },
  426. })
  427. const controller = new AbortController()
  428. const response = await api.downloads.sessionLog(
  429. { sessionId: sid('session-root'), includeDescendants: true },
  430. controller.signal,
  431. )
  432. await response.arrayBuffer()
  433. const producerSignal = traces[0]
  434. if (producerSignal === undefined) throw new Error('missing lineage signal')
  435. expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal })
  436. expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
  437. const cancellation = new Error('request cancelled after response')
  438. controller.abort(cancellation)
  439. expect(producerSignal.aborted).toBe(true)
  440. expect(producerSignal.reason).toBe(cancellation)
  441. })
  442. it('preserves request cancellation instead of translating it to HTTP 500', async () => {
  443. const api = await buildApi({ 'session-root': artifact('session-root') })
  444. const controller = new AbortController()
  445. const cancellation = new Error('request cancelled')
  446. controller.abort(cancellation)
  447. await expect(api.downloads.sessionLog(
  448. { sessionId: sid('session-root'), includeDescendants: false },
  449. controller.signal,
  450. )).rejects.toBe(cancellation)
  451. })
  452. it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
  453. let reportDescendantStarted!: (signal: AbortSignal) => void
  454. const descendantStarted = new Promise<AbortSignal>((resolve) => {
  455. reportDescendantStarted = resolve
  456. })
  457. const api = await buildApi({}, [node('child-a')], {
  458. readRaw: async (id, signal) => {
  459. if (id === sid('session-root')) return artifact('session-root')
  460. if (signal === undefined) throw new Error('missing descendant signal')
  461. reportDescendantStarted(signal)
  462. return new Promise((_, reject) => {
  463. signal.addEventListener('abort', () => {
  464. reject(signal.reason as Error)
  465. }, { once: true })
  466. })
  467. },
  468. })
  469. const response = await api.downloads.sessionLog(
  470. { sessionId: sid('session-root'), includeDescendants: true },
  471. new AbortController().signal,
  472. )
  473. const reader = response.body?.getReader()
  474. if (reader === undefined) throw new Error('missing response body')
  475. const descendantSignal = await descendantStarted
  476. const cancellation = new Error('download consumer left')
  477. await reader.cancel(cancellation)
  478. expect(descendantSignal.aborted).toBe(true)
  479. expect(descendantSignal.reason).toBe(cancellation)
  480. })
  481. it('aborts attachment reads when its reader cancels', async () => {
  482. let reportAttachmentStarted!: (signal: AbortSignal) => void
  483. const attachmentStarted = new Promise<AbortSignal>((resolve) => {
  484. reportAttachmentStarted = resolve
  485. })
  486. const root = artifact('session-root', undefined, [
  487. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  488. imageEventLine('slow-img'),
  489. ].join('\n') + '\n')
  490. const api = await buildApi({ 'session-root': root }, [], {
  491. attachments: async (_ref, signal) => {
  492. if (signal === undefined) throw new Error('missing attachment signal')
  493. reportAttachmentStarted(signal)
  494. return new Promise((_, reject) => {
  495. signal.addEventListener('abort', () => {
  496. reject(signal.reason as Error)
  497. }, { once: true })
  498. })
  499. },
  500. })
  501. const response = await api.downloads.sessionLog(
  502. { sessionId: sid('session-root'), includeDescendants: false },
  503. new AbortController().signal,
  504. )
  505. const reader = response.body?.getReader()
  506. if (reader === undefined) throw new Error('missing response body')
  507. const attachmentSignal = await attachmentStarted
  508. const cancellation = new Error('download consumer left during attachment read')
  509. await reader.cancel(cancellation)
  510. expect(attachmentSignal.aborted).toBe(true)
  511. expect(attachmentSignal.reason).toBe(cancellation)
  512. })
  513. it('uses a stable Error reason when its reader cancels without one', async () => {
  514. let reportDescendantStarted!: (signal: AbortSignal) => void
  515. const descendantStarted = new Promise<AbortSignal>((resolve) => {
  516. reportDescendantStarted = resolve
  517. })
  518. const api = await buildApi({}, [node('child-a')], {
  519. readRaw: async (id, signal) => {
  520. if (id === sid('session-root')) return artifact('session-root')
  521. if (signal === undefined) throw new Error('missing descendant signal')
  522. reportDescendantStarted(signal)
  523. return new Promise((_, reject) => {
  524. signal.addEventListener('abort', () => {
  525. reject(signal.reason as Error)
  526. }, { once: true })
  527. })
  528. },
  529. })
  530. const response = await api.downloads.sessionLog(
  531. { sessionId: sid('session-root'), includeDescendants: true },
  532. new AbortController().signal,
  533. )
  534. const reader = response.body?.getReader()
  535. if (reader === undefined) throw new Error('missing response body')
  536. const descendantSignal = await descendantStarted
  537. await reader.cancel()
  538. expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
  539. })
  540. it('normalizes a non-Error descendant failure before erroring the stream', async () => {
  541. const api = await buildApi({}, [node('child-a')], {
  542. readRaw: async (id) => {
  543. if (id === sid('session-root')) return artifact('session-root')
  544. throw 'descendant read failed'
  545. },
  546. })
  547. const response = await api.downloads.sessionLog(
  548. { sessionId: sid('session-root'), includeDescendants: true },
  549. new AbortController().signal,
  550. )
  551. await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
  552. })
  553. it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
  554. const root = artifact('session-root', undefined, [
  555. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  556. imageEventLine('img-1'),
  557. ].join('\n') + '\n')
  558. const api = await buildApi({ 'session-root': root })
  559. const response = await toFetchHandler(api).fetch(
  560. new Request('http://host/api/session.export?sessionId=session-root'),
  561. )
  562. expect(response.status).toBe(200)
  563. const files = unzipSync(await responseBytes(response))
  564. expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
  565. expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
  566. })
  567. it('collects media referenced from nested tool results', async () => {
  568. 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}}]}]}}'
  569. const root = artifact('session-root', undefined, [
  570. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  571. nested,
  572. ].join('\n') + '\n')
  573. const api = await buildApi({ 'session-root': root })
  574. const response = await toFetchHandler(api).fetch(
  575. new Request('http://host/api/session.export?sessionId=session-root'),
  576. )
  577. const files = unzipSync(await responseBytes(response))
  578. expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
  579. })
  580. it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
  581. const block = (id: string, mediaType: string) =>
  582. `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
  583. const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
  584. const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
  585. const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
  586. const root = artifact('session-root', undefined, [
  587. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  588. wrapped,
  589. inserted,
  590. chunk,
  591. ].join('\n') + '\n')
  592. const api = await buildApi({ 'session-root': root })
  593. const response = await toFetchHandler(api).fetch(
  594. new Request('http://host/api/session.export?sessionId=session-root'),
  595. )
  596. const files = unzipSync(await responseBytes(response))
  597. expect(Object.keys(files).sort()).toEqual([
  598. 'media/chunk-1.png',
  599. 'media/inserted-1.gif',
  600. 'media/wrapped-1.jpg',
  601. 'session.jsonl',
  602. ])
  603. })
  604. it('deduplicates one media object referenced by several included logs', async () => {
  605. const line = imageEventLine('shared-img')
  606. const root = artifact('session-root', undefined, [
  607. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  608. line,
  609. ].join('\n') + '\n')
  610. const child = artifact('child-a', sid('session-root'), [
  611. '{"type":"session","version":0,"id":"child-a","createdAt":1000}',
  612. line,
  613. ].join('\n') + '\n')
  614. const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
  615. const response = await toFetchHandler(api).fetch(
  616. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  617. )
  618. const files = unzipSync(await responseBytes(response))
  619. expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
  620. expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
  621. })
  622. it('includes descendant media only when descendants are requested', async () => {
  623. const child = artifact('child-a', sid('session-root'), [
  624. '{"type":"session","version":0,"id":"child-a","createdAt":1000}',
  625. imageEventLine('child-img'),
  626. ].join('\n') + '\n')
  627. const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
  628. const without = await toFetchHandler(api).fetch(
  629. new Request('http://host/api/session.export?sessionId=session-root'),
  630. )
  631. expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
  632. const withDescendants = await toFetchHandler(api).fetch(
  633. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  634. )
  635. expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
  636. 'media/child-img.png',
  637. 'session.jsonl',
  638. 'subagents/child-a/session.jsonl',
  639. ])
  640. })
  641. it('fails the whole export when a referenced image cannot be read', async () => {
  642. const root = artifact('session-root', undefined, [
  643. '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
  644. imageEventLine('gone-img'),
  645. ].join('\n') + '\n')
  646. const api = await buildApi({ 'session-root': root }, [], {
  647. attachments: async () => { throw new Error('attachment bytes missing') },
  648. })
  649. const response = await toFetchHandler(api).fetch(
  650. new Request('http://host/api/session.export?sessionId=session-root'),
  651. )
  652. expect(response.status).toBe(200)
  653. await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
  654. })
  655. it('answers 500 when the deployment mounts no attachments service', async () => {
  656. const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
  657. const response = await toFetchHandler(api).fetch(
  658. new Request('http://host/api/session.export?sessionId=session-root'),
  659. )
  660. expect(response.status).toBe(500)
  661. expect(await response.text()).toContain('attachments')
  662. })
  663. })