session-export.spec.ts 32 KB

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