archive.host.spec.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. /**
  2. * session.export host path: the GET download endpoint streams a ZIP whose
  3. * files are the sessions' logical logs serialized as canonical JSONL (root +
  4. * optional descendants) read through persistence read handles, and the
  5. * degenerate compositions fail loudly (missing services → 500, missing root →
  6. * 404, missing descendant → errored stream).
  7. */
  8. import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session'
  9. import { randomBytes } from 'node:crypto'
  10. import { describe, expect, it, vi } from 'vitest'
  11. import { Context } from '@deepseek-ai/cordis'
  12. import { unzipSync, strFromU8 } from 'fflate'
  13. import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  14. import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  15. import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
  16. import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence'
  17. import type { SessionAccess, SessionHandle } from '@deepseek-ai/dsh-session-persistence'
  18. import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
  19. import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
  20. import * as SessionLogExport from '../src/index.ts'
  21. const sid = (id: string): SessionId => id as SessionId
  22. const exportLogName = SessionLogExport.SESSION_LOG_FILENAME
  23. const subagentLogName = (id: string): string => `subagents/${id}/${exportLogName}`
  24. const generationLogName = (version: number): string => version === 0
  25. ? 'session.jsonl'
  26. : `session.v${version}.jsonl`
  27. function header(id: string, parentSession?: SessionId): SessionHeader {
  28. return {
  29. version: SESSION_FORMAT_VERSION,
  30. id: sid(id),
  31. createdAt: 1000,
  32. isSeeded: false,
  33. cwd: '/proj',
  34. ...parentSession === undefined ? {} : { parentSession },
  35. delegationDepth: parentSession === undefined ? 0 : 1,
  36. }
  37. }
  38. /** One stored logical session log served by the fake persistence backend. */
  39. interface StoredLog {
  40. readonly header: SessionHeader
  41. readonly events: readonly SessionEvent[]
  42. }
  43. const turnStart: SessionEvent = { type: 'turn/start', seq: SessionSeq(0), time: 2000, data: { turn: 1 } }
  44. function log(id: string, parentSession?: SessionId, events: readonly SessionEvent[] = [turnStart]): StoredLog {
  45. return { header: header(id, parentSession), events }
  46. }
  47. /** The expected zip text for one stored log: the canonical JSONL serialization. */
  48. function logText(stored: StoredLog): string {
  49. return SessionLogExport.serializeSessionLog(stored.header, stored.events)
  50. }
  51. function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
  52. return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
  53. }
  54. /** One durable image object served by the fake attachment store. */
  55. function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
  56. return {
  57. ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
  58. data: new Uint8Array([1, 2, 3, 4]),
  59. }
  60. }
  61. /** A user/message event carrying one image reference. */
  62. function imageEvent(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): SessionEvent {
  63. return {
  64. type: 'user/message', seq: SessionSeq(1), time: 1000,
  65. data: { content: [{ type: 'image', attachment: { attachmentId: id, mediaType, bytes: 4, width: 2, height: 2 } }] },
  66. } as unknown as SessionEvent
  67. }
  68. /** A read handle over one stored log; only what readSessionLogText touches. */
  69. function readHandle(stored: StoredLog): SessionHandle {
  70. return {
  71. id: stored.header.id,
  72. header: stored.header,
  73. access: 'read',
  74. inheritedEventCount: 0,
  75. read: async () => ({ eventState: 'detached', events: structuredClone(stored.events) }),
  76. close: async () => {},
  77. } as unknown as SessionHandle
  78. }
  79. /** A user/message event carrying one generic-file reference. */
  80. function fileEvent(id: string, name = 'notes.txt', bytes = 5, seq = SessionSeq(1)): SessionEvent {
  81. return {
  82. type: 'user/message', seq, time: 1000,
  83. data: { content: [{ type: 'file', attachment: { attachmentId: id, name, bytes } }] },
  84. } as unknown as SessionEvent
  85. }
  86. async function buildApi(
  87. logs: Record<string, StoredLog>,
  88. descendants: SessionLineageNode[] = [],
  89. services: {
  90. query?: boolean
  91. persistence?: boolean | 'throw'
  92. attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
  93. readFileStream?: (ref: FileAttachmentRef, signal?: AbortSignal) => AsyncIterable<Uint8Array>
  94. sessions?: {
  95. get(id: SessionId): { readonly id: SessionId } | undefined
  96. flush(session: { readonly id: SessionId }): Promise<boolean>
  97. }
  98. open?: (id: SessionId, access: SessionAccess, options?: { signal?: AbortSignal }) => Promise<SessionHandle>
  99. traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
  100. target: { header: SessionHeader; live: boolean; persisted: boolean }
  101. ancestors: readonly SessionLineageNode[]
  102. complete: boolean
  103. root: { header: SessionHeader; live: boolean; persisted: boolean }
  104. descendants: readonly SessionLineageNode[]
  105. }>
  106. compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
  107. } = {},
  108. ) {
  109. const ctx = new Context()
  110. ctx.provide('commands', { register: () => () => {} } as never)
  111. const query = services.query ?? true
  112. const persistence = services.persistence ?? true
  113. if (query) {
  114. ctx.provide('sessionQuery', {
  115. traceSession: services.traceSession ?? (async () => ({
  116. target: { header: header('session-root'), live: false, persisted: true },
  117. ancestors: [],
  118. complete: true,
  119. root: { header: header('session-root'), live: false, persisted: true },
  120. descendants,
  121. })),
  122. } as never)
  123. }
  124. if (persistence) {
  125. ctx.provide('sessionPersistence', {
  126. stat: async (id: SessionId) => {
  127. // A custom `open` owns the scenario: absence must reach it, not stop here.
  128. if (services.open !== undefined || persistence === 'throw') return { header: header(String(id)) }
  129. const stored = logs[id]
  130. return stored === undefined ? undefined : { header: stored.header }
  131. },
  132. open: services.open ?? (async (id: SessionId) => {
  133. if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
  134. const stored = logs[id]
  135. if (stored === undefined) throw new SessionPersistenceNotFoundError(id)
  136. return readHandle(stored)
  137. }),
  138. } as never)
  139. }
  140. if (services.attachments !== false) {
  141. const readImage = typeof services.attachments === 'function'
  142. ? services.attachments
  143. : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
  144. ctx.provide('attachments', {
  145. imageLimits: {} as never,
  146. validateImage: async () => {},
  147. saveImage: async () => { throw new Error('export never saves images') },
  148. readImage,
  149. readFileStream: services.readFileStream ?? (async function* () {
  150. throw new Error('fixture has no files')
  151. }),
  152. } as never)
  153. }
  154. if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
  155. const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
  156. const fiber = ctx.plugin(SessionLogExport, {
  157. ...services.compressionLevel === undefined
  158. ? {}
  159. : { compressionLevel: services.compressionLevel },
  160. })
  161. await fiber.await()
  162. const handler = connection.createSharedFetchHandler('/api')
  163. return {
  164. fetch: handler,
  165. downloads: {
  166. sessionLog: (
  167. request: { sessionId: SessionId; includeDescendants: boolean },
  168. signal: AbortSignal,
  169. ): Promise<Response> => {
  170. const url = new URL(`http://host${SessionLogExport.SESSION_LOG_EXPORT_PATH}`)
  171. url.searchParams.set('sessionId', request.sessionId)
  172. url.searchParams.set('includeDescendants', String(request.includeDescendants))
  173. return handler.fetch(new Request(url, { signal }))
  174. },
  175. },
  176. }
  177. }
  178. function toFetchHandler(api: Awaited<ReturnType<typeof buildApi>>): { fetch(request: Request): Promise<Response> } {
  179. return api.fetch
  180. }
  181. async function responseBytes(response: Response): Promise<Uint8Array> {
  182. return new Uint8Array(await response.arrayBuffer())
  183. }
  184. /** Minimal ready services for the direct streamSessionLogZip chunking tests. */
  185. function directReady(): SessionLogExport.SessionLogExportReady {
  186. return {
  187. sessionQuery: { traceSession: async () => { throw new Error('unused') } } as never,
  188. sessionPersistence: { open: async () => { throw new Error('unused') } } as never,
  189. attachments: { readImage: async () => { throw new Error('no media') } } as never,
  190. sessions: undefined,
  191. }
  192. }
  193. /** Consume one directly built zip stream into its unpacked files. */
  194. async function directZipFiles(rootContent: string): Promise<Record<string, Uint8Array>> {
  195. const stream = SessionLogExport.streamSessionLogZip(
  196. directReady(), rootContent, sid('session-root'), false, 6, new AbortController().signal,
  197. )
  198. return unzipSync(new Uint8Array(await new Response(stream).arrayBuffer()))
  199. }
  200. describe('session export compression config', () => {
  201. it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
  202. expect(SessionLogExport.Config({})).toEqual({
  203. compressionLevel: 6,
  204. })
  205. expect(SessionLogExport.Config({ compressionLevel: 0 }))
  206. .toEqual({ compressionLevel: 0 })
  207. expect(SessionLogExport.Config({ compressionLevel: 9 }))
  208. .toEqual({ compressionLevel: 9 })
  209. for (const value of [-1, 10, 1.5]) {
  210. expect(() => SessionLogExport.Config({ compressionLevel: value } as never)).toThrow()
  211. }
  212. })
  213. })
  214. describe('serializeSessionLog', () => {
  215. it('uses the canonical current-generation export filename', () => {
  216. expect(SessionLogExport.SESSION_LOG_FILENAME).toBe(generationLogName(SESSION_FORMAT_VERSION))
  217. })
  218. it('writes the physical header line, one line per event, and a trailing newline', () => {
  219. const stored = log('session-root')
  220. expect(logText(stored)).toBe(
  221. `${JSON.stringify({
  222. type: 'session', version: SESSION_FORMAT_VERSION, id: sid('session-root'), createdAt: 1000,
  223. cwd: '/proj', isSeeded: false, delegationDepth: 0,
  224. })}\n${JSON.stringify(turnStart)}\n`,
  225. )
  226. })
  227. it('serializes v2 lineage through the tagged marker and defaults delegationDepth to 0', () => {
  228. const seeded: SessionHeader = {
  229. version: SESSION_FORMAT_VERSION,
  230. id: sid('seeded'),
  231. createdAt: 1000,
  232. isSeeded: true,
  233. parentSession: sid('parent'),
  234. origin: 'subagent',
  235. agentPreset: 'minimal',
  236. }
  237. const events: SessionEvent[] = [
  238. { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
  239. { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  240. { type: 'session/end-seed', seq: SessionSeq(2), time: 2, data: { inherited: true } },
  241. ]
  242. expect(SessionLogExport.serializeSessionLog(seeded, events)).toBe(`${JSON.stringify({
  243. type: 'session',
  244. version: SESSION_FORMAT_VERSION,
  245. id: sid('seeded'),
  246. createdAt: 1000,
  247. parentSession: sid('parent'),
  248. isSeeded: true,
  249. origin: 'subagent',
  250. delegationDepth: 0,
  251. agentPreset: 'minimal',
  252. })}\n${events.map(event => JSON.stringify(event)).join('\n')}\n`)
  253. })
  254. })
  255. describe('readSessionLogText', () => {
  256. it('reads without a signal and maps only open not-found to undefined', async () => {
  257. const stored = log('session-root')
  258. const persistence = {
  259. open: async (id: SessionId) => {
  260. if (id !== stored.header.id) throw new SessionPersistenceNotFoundError(id)
  261. return readHandle(stored)
  262. },
  263. } as never
  264. await expect(SessionLogExport.readSessionLogText(persistence, sid('session-root')))
  265. .resolves.toBe(logText(stored))
  266. await expect(SessionLogExport.readSessionLogText(persistence, sid('absent')))
  267. .resolves.toBeUndefined()
  268. })
  269. it('propagates a non-not-found open failure', async () => {
  270. const persistence = {
  271. open: async () => { throw new Error('EACCES: permission denied') },
  272. } as never
  273. await expect(SessionLogExport.readSessionLogText(persistence, sid('session-root')))
  274. .rejects.toThrow('EACCES: permission denied')
  275. })
  276. })
  277. describe('session.export download endpoint', () => {
  278. it('streams a ZIP with the root log serialized as canonical JSONL', async () => {
  279. const stored = log('session-root')
  280. const api = await buildApi({ 'session-root': stored })
  281. const response = await toFetchHandler(api).fetch(
  282. new Request('http://host/api/session.export?sessionId=session-root'),
  283. )
  284. expect(response.status).toBe(200)
  285. expect(response.headers.get('content-type')).toBe('application/zip')
  286. expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
  287. const files = unzipSync(await responseBytes(response))
  288. expect(Object.keys(files)).toEqual([exportLogName])
  289. expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(stored))
  290. })
  291. it('preflights root preparation through HEAD without streaming a body', async () => {
  292. const open = vi.fn(async () => readHandle(log('session-root')))
  293. const api = await buildApi({}, [], { open })
  294. const response = await toFetchHandler(api).fetch(
  295. new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
  296. )
  297. expect(response.status).toBe(200)
  298. expect(response.headers.get('content-type')).toBe('application/zip')
  299. expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
  300. expect(response.body).toBeNull()
  301. expect(open).toHaveBeenCalledOnce()
  302. })
  303. it('returns a bodyless preparation error from HEAD', async () => {
  304. const api = await buildApi({})
  305. const response = await toFetchHandler(api).fetch(
  306. new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
  307. )
  308. expect(response.status).toBe(404)
  309. expect(response.body).toBeNull()
  310. })
  311. it('uses the resolved compression level for ZIP entries', async () => {
  312. const filler = {
  313. type: 'user/message', seq: SessionSeq(1), time: 1000,
  314. data: { content: [{ type: 'text', text: 'compressible '.repeat(32 * 1024) }] },
  315. } as unknown as SessionEvent
  316. const stored = log('session-root', undefined, [turnStart, filler])
  317. const storedApi = await buildApi({ 'session-root': stored }, [], { compressionLevel: 0 })
  318. const compressedApi = await buildApi({ 'session-root': stored }, [], { compressionLevel: 9 })
  319. const uncompressed = await storedApi.downloads.sessionLog(
  320. { sessionId: sid('session-root'), includeDescendants: false },
  321. new AbortController().signal,
  322. )
  323. const compressed = await compressedApi.downloads.sessionLog(
  324. { sessionId: sid('session-root'), includeDescendants: false },
  325. new AbortController().signal,
  326. )
  327. const storedBytes = await responseBytes(uncompressed)
  328. const compressedBytes = await responseBytes(compressed)
  329. expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
  330. expect(strFromU8(unzipSync(compressedBytes)[exportLogName] as Uint8Array)).toBe(logText(stored))
  331. })
  332. it('includes descendant logs under subagents/<id>/ when requested', async () => {
  333. const child = log('child-a', sid('session-root'))
  334. const api = await buildApi({
  335. 'session-root': log('session-root'),
  336. 'child-a': child,
  337. 'grandchild-a': log('grandchild-a', sid('child-a')),
  338. }, [
  339. node('child-a', node('grandchild-a')),
  340. ])
  341. const response = await toFetchHandler(api).fetch(
  342. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  343. )
  344. expect(response.status).toBe(200)
  345. const files = unzipSync(await responseBytes(response))
  346. expect(Object.keys(files).sort()).toEqual([
  347. exportLogName,
  348. subagentLogName('child-a'),
  349. subagentLogName('grandchild-a'),
  350. ])
  351. expect(strFromU8(files[subagentLogName('child-a')] as Uint8Array))
  352. .toBe(logText(child))
  353. })
  354. it('flushes each live root and descendant immediately before reading its log', async () => {
  355. const staleMarker = { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } } as SessionEvent
  356. const durableMarker = { type: 'turn/start', seq: SessionSeq(0), time: 2, data: { turn: 1 } } as SessionEvent
  357. const stored: Record<string, StoredLog> = {
  358. 'session-root': log('session-root', undefined, [staleMarker]),
  359. 'child-a': log('child-a', sid('session-root'), [staleMarker]),
  360. }
  361. const durable: Record<string, StoredLog> = {
  362. 'session-root': log('session-root', undefined, [durableMarker]),
  363. 'child-a': log('child-a', sid('session-root'), [durableMarker]),
  364. }
  365. const flushed: SessionId[] = []
  366. const api = await buildApi(stored, [node('child-a')], {
  367. sessions: {
  368. get: id => durable[id] === undefined ? undefined : { id },
  369. flush: async (session) => {
  370. const logAfterFlush = durable[session.id]
  371. if (logAfterFlush === undefined) throw new Error('unexpected session')
  372. flushed.push(session.id)
  373. stored[session.id] = logAfterFlush
  374. return true
  375. },
  376. },
  377. })
  378. const response = await toFetchHandler(api).fetch(
  379. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  380. )
  381. const files = unzipSync(await responseBytes(response))
  382. expect(flushed).toEqual([sid('session-root'), sid('child-a')])
  383. expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(durable['session-root'] as StoredLog))
  384. expect(strFromU8(files[subagentLogName('child-a')] as Uint8Array)).toBe(logText(durable['child-a'] as StoredLog))
  385. })
  386. it('reads a cold log without asking the live-session store to flush', async () => {
  387. const flush = vi.fn(async () => true)
  388. const stored = log('session-root')
  389. const api = await buildApi({ 'session-root': stored }, [], {
  390. sessions: {
  391. get: () => undefined,
  392. flush,
  393. },
  394. })
  395. const response = await api.downloads.sessionLog(
  396. { sessionId: sid('session-root'), includeDescendants: false },
  397. new AbortController().signal,
  398. )
  399. const files = unzipSync(await responseBytes(response))
  400. expect(flush).not.toHaveBeenCalled()
  401. expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(logText(stored))
  402. })
  403. it('answers 404 for a session the backend does not store', async () => {
  404. const api = await buildApi({})
  405. const response = await toFetchHandler(api).fetch(
  406. new Request('http://host/api/session.export?sessionId=session-root'),
  407. )
  408. expect(response.status).toBe(404)
  409. expect(await response.text()).toBe('session not found')
  410. })
  411. it('answers 400 when the sessionId query parameter is absent', async () => {
  412. const api = await buildApi({ 'session-root': log('session-root') })
  413. const response = await toFetchHandler(api).fetch(
  414. new Request('http://host/api/session.export?includeDescendants=true'),
  415. )
  416. expect(response.status).toBe(400)
  417. })
  418. it('answers 400 for an includeDescendants value other than true or false', async () => {
  419. const api = await buildApi({ 'session-root': log('session-root') })
  420. const response = await toFetchHandler(api).fetch(
  421. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
  422. )
  423. expect(response.status).toBe(400)
  424. })
  425. it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
  426. const api = await buildApi({}, [], { query: false, persistence: false })
  427. const response = await toFetchHandler(api).fetch(
  428. new Request('http://host/api/session.export?sessionId=session-root'),
  429. )
  430. expect(response.status).toBe(500)
  431. expect(await response.text()).toContain('session-query')
  432. })
  433. it('fails the whole export when a descendant has no stored log', async () => {
  434. const api = await buildApi({
  435. 'session-root': log('session-root'),
  436. }, [node('child-missing')])
  437. const response = await toFetchHandler(api).fetch(
  438. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  439. )
  440. expect(response.status).toBe(200)
  441. // The stream errors before completing, so the body read rejects rather
  442. // than returning a truncated-but-valid archive.
  443. await expect(response.arrayBuffer()).rejects.toThrow()
  444. })
  445. it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
  446. // The push loop slices by 2^16 code units and must back off one unit when
  447. // the boundary lands inside a surrogate pair; otherwise the pair re-encodes
  448. // as U+FFFD and the exported log is silently corrupted.
  449. const content = `${'a'.repeat((1 << 16) - 1)}😀tail`
  450. const files = await directZipFiles(content)
  451. expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(content)
  452. })
  453. it('splits a long log on a plain code-unit boundary without backoff', async () => {
  454. // A boundary that lands on a BMP character needs no surrogate backoff; the
  455. // round trip must still be byte-identical across the multi-chunk push.
  456. const content = 'z'.repeat((1 << 16) + 4096)
  457. const files = await directZipFiles(content)
  458. expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(content)
  459. })
  460. it('streams an empty root text as an empty zip entry', async () => {
  461. const files = await directZipFiles('')
  462. expect(Object.keys(files)).toEqual([exportLogName])
  463. expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('')
  464. })
  465. it('waits for response pull capacity before reading the next archive entry', async () => {
  466. const filler = {
  467. type: 'user/message', seq: SessionSeq(2), time: 1000,
  468. data: { content: [{ type: 'text', text: randomBytes(512 * 1024).toString('base64') }] },
  469. } as unknown as SessionEvent
  470. const stored = log('session-root', undefined, [imageEvent('after-root'), filler])
  471. let imageReads = 0
  472. const api = await buildApi({ 'session-root': stored }, [], {
  473. attachments: async (ref) => {
  474. imageReads += 1
  475. return storedImage(String(ref.attachmentId), ref.mediaType)
  476. },
  477. })
  478. vi.useFakeTimers()
  479. let response: Response | undefined
  480. try {
  481. response = await toFetchHandler(api).fetch(
  482. new Request('http://host/api/session.export?sessionId=session-root'),
  483. )
  484. // Exhausting timer turns must not advance a producer whose byte queue is
  485. // full; only a consumer pull can release it.
  486. await vi.runAllTimersAsync()
  487. expect(imageReads).toBe(0)
  488. } finally {
  489. vi.useRealTimers()
  490. }
  491. if (response === undefined) throw new Error('missing export response')
  492. const files = unzipSync(await responseBytes(response))
  493. expect(imageReads).toBe(1)
  494. expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
  495. })
  496. it('exports a shared lineage node once (seen-set dedup)', async () => {
  497. const api = await buildApi({
  498. 'session-root': log('session-root'),
  499. 'child-a': log('child-a', sid('session-root')),
  500. 'child-b': log('child-b', sid('session-root')),
  501. shared: log('shared', sid('child-a')),
  502. }, [
  503. node('child-a', node('shared')),
  504. node('child-b', node('shared')),
  505. ])
  506. const response = await toFetchHandler(api).fetch(
  507. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  508. )
  509. const files = unzipSync(await responseBytes(response))
  510. expect(Object.keys(files).sort()).toEqual([
  511. exportLogName,
  512. subagentLogName('child-a'),
  513. subagentLogName('child-b'),
  514. subagentLogName('shared'),
  515. ])
  516. })
  517. it('answers 500 without leaking the backend error when the root read fails', async () => {
  518. const api = await buildApi({}, [], { query: true, persistence: 'throw' })
  519. const response = await toFetchHandler(api).fetch(
  520. new Request('http://host/api/session.export?sessionId=session-root'),
  521. )
  522. expect(response.status).toBe(500)
  523. const body = await response.text()
  524. expect(body).toBe('session log export failed to read the stored log')
  525. expect(body).not.toContain('/host/private/')
  526. })
  527. it('answers the private-error-safe 500 when the live root flush fails', async () => {
  528. const api = await buildApi({ 'session-root': log('session-root') }, [], {
  529. sessions: {
  530. get: id => ({ id }),
  531. flush: async () => { throw new Error('/host/private/flush-state') },
  532. },
  533. })
  534. const response = await toFetchHandler(api).fetch(
  535. new Request('http://host/api/session.export?sessionId=session-root'),
  536. )
  537. expect(response.status).toBe(500)
  538. const body = await response.text()
  539. expect(body).toBe('session log export failed to read the stored log')
  540. expect(body).not.toContain('/host/private/')
  541. })
  542. it('forwards one request signal through root, lineage, and descendant reads', async () => {
  543. const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
  544. const traces: AbortSignal[] = []
  545. const api = await buildApi({}, [node('child-a')], {
  546. open: async (id, _access, options) => {
  547. reads.push({ id, signal: options?.signal })
  548. return readHandle(id === sid('session-root')
  549. ? log('session-root')
  550. : log('child-a', sid('session-root')))
  551. },
  552. traceSession: async (_id, signal) => {
  553. if (signal !== undefined) traces.push(signal)
  554. return {
  555. target: { header: header('session-root'), live: false, persisted: true },
  556. ancestors: [],
  557. complete: true,
  558. root: { header: header('session-root'), live: false, persisted: true },
  559. descendants: [node('child-a')],
  560. }
  561. },
  562. })
  563. const controller = new AbortController()
  564. const response = await api.downloads.sessionLog(
  565. { sessionId: sid('session-root'), includeDescendants: true },
  566. controller.signal,
  567. )
  568. await response.arrayBuffer()
  569. const rootSignal = reads[0]?.signal
  570. if (rootSignal === undefined) throw new Error('missing root signal')
  571. const producerSignal = traces[0]
  572. if (producerSignal === undefined) throw new Error('missing lineage signal')
  573. expect(reads[0]?.id).toBe(sid('session-root'))
  574. expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
  575. const cancellation = new Error('request cancelled after response')
  576. controller.abort(cancellation)
  577. expect(rootSignal.aborted).toBe(true)
  578. expect(rootSignal.reason).toBe(cancellation)
  579. expect(producerSignal.aborted).toBe(true)
  580. expect(producerSignal.reason).toBe(cancellation)
  581. })
  582. it('preserves request cancellation instead of translating it to HTTP 500', async () => {
  583. const api = await buildApi({ 'session-root': log('session-root') })
  584. const controller = new AbortController()
  585. const cancellation = new Error('request cancelled')
  586. controller.abort(cancellation)
  587. await expect(api.downloads.sessionLog(
  588. { sessionId: sid('session-root'), includeDescendants: false },
  589. controller.signal,
  590. )).rejects.toBe(cancellation)
  591. })
  592. it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
  593. let reportDescendantStarted!: (signal: AbortSignal) => void
  594. const descendantStarted = new Promise<AbortSignal>((resolve) => {
  595. reportDescendantStarted = resolve
  596. })
  597. const api = await buildApi({}, [node('child-a')], {
  598. open: async (id, _access, options) => {
  599. if (id === sid('session-root')) return readHandle(log('session-root'))
  600. const signal = options?.signal
  601. if (signal === undefined) throw new Error('missing descendant signal')
  602. reportDescendantStarted(signal)
  603. return new Promise((_, reject) => {
  604. signal.addEventListener('abort', () => {
  605. reject(signal.reason as Error)
  606. }, { once: true })
  607. })
  608. },
  609. })
  610. const response = await api.downloads.sessionLog(
  611. { sessionId: sid('session-root'), includeDescendants: true },
  612. new AbortController().signal,
  613. )
  614. const reader = response.body?.getReader()
  615. if (reader === undefined) throw new Error('missing response body')
  616. const descendantSignal = await descendantStarted
  617. const cancellation = new Error('download consumer left')
  618. await reader.cancel(cancellation)
  619. expect(descendantSignal.aborted).toBe(true)
  620. expect(descendantSignal.reason).toBe(cancellation)
  621. })
  622. it('aborts attachment reads when its reader cancels', async () => {
  623. let reportAttachmentStarted!: (signal: AbortSignal) => void
  624. const attachmentStarted = new Promise<AbortSignal>((resolve) => {
  625. reportAttachmentStarted = resolve
  626. })
  627. const stored = log('session-root', undefined, [imageEvent('slow-img')])
  628. const api = await buildApi({ 'session-root': stored }, [], {
  629. attachments: async (_ref, signal) => {
  630. if (signal === undefined) throw new Error('missing attachment signal')
  631. reportAttachmentStarted(signal)
  632. return new Promise((_, reject) => {
  633. signal.addEventListener('abort', () => {
  634. reject(signal.reason as Error)
  635. }, { once: true })
  636. })
  637. },
  638. })
  639. const response = await api.downloads.sessionLog(
  640. { sessionId: sid('session-root'), includeDescendants: false },
  641. new AbortController().signal,
  642. )
  643. const reader = response.body?.getReader()
  644. if (reader === undefined) throw new Error('missing response body')
  645. const attachmentSignal = await attachmentStarted
  646. const cancellation = new Error('download consumer left during attachment read')
  647. await reader.cancel(cancellation)
  648. expect(attachmentSignal.aborted).toBe(true)
  649. expect(attachmentSignal.reason).toBe(cancellation)
  650. })
  651. it('uses a stable Error reason when its reader cancels without one', async () => {
  652. let reportDescendantStarted!: (signal: AbortSignal) => void
  653. const descendantStarted = new Promise<AbortSignal>((resolve) => {
  654. reportDescendantStarted = resolve
  655. })
  656. const api = await buildApi({}, [node('child-a')], {
  657. open: async (id, _access, options) => {
  658. if (id === sid('session-root')) return readHandle(log('session-root'))
  659. const signal = options?.signal
  660. if (signal === undefined) throw new Error('missing descendant signal')
  661. reportDescendantStarted(signal)
  662. return new Promise((_, reject) => {
  663. signal.addEventListener('abort', () => {
  664. reject(signal.reason as Error)
  665. }, { once: true })
  666. })
  667. },
  668. })
  669. const response = await api.downloads.sessionLog(
  670. { sessionId: sid('session-root'), includeDescendants: true },
  671. new AbortController().signal,
  672. )
  673. const reader = response.body?.getReader()
  674. if (reader === undefined) throw new Error('missing response body')
  675. const descendantSignal = await descendantStarted
  676. await reader.cancel()
  677. expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
  678. })
  679. it('normalizes a non-Error descendant failure before erroring the stream', async () => {
  680. const api = await buildApi({}, [node('child-a')], {
  681. open: async (id) => {
  682. if (id === sid('session-root')) return readHandle(log('session-root'))
  683. throw 'descendant read failed'
  684. },
  685. })
  686. const response = await api.downloads.sessionLog(
  687. { sessionId: sid('session-root'), includeDescendants: true },
  688. new AbortController().signal,
  689. )
  690. await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
  691. })
  692. it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
  693. const stored = log('session-root', undefined, [imageEvent('img-1')])
  694. const api = await buildApi({ 'session-root': stored })
  695. const response = await toFetchHandler(api).fetch(
  696. new Request('http://host/api/session.export?sessionId=session-root'),
  697. )
  698. expect(response.status).toBe(200)
  699. const files = unzipSync(await responseBytes(response))
  700. expect(Object.keys(files).sort()).toEqual(['media/img-1.png', exportLogName].sort())
  701. expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
  702. })
  703. it('streams generic files under their content-addressed archive paths', async () => {
  704. const digest = 'a'.repeat(64)
  705. const id = `sha256:${digest}`
  706. const fallbackDigest = 'c'.repeat(64)
  707. const fallbackId = `sha256:${fallbackDigest}`
  708. const root = log('session-root', undefined, [
  709. fileEvent(id, 'notes.txt', 5),
  710. fileEvent(fallbackId, '.', 5, SessionSeq(2)),
  711. ])
  712. const reads: Array<{ ref: FileAttachmentRef; signal: AbortSignal | undefined }> = []
  713. const api = await buildApi({ 'session-root': root }, [], {
  714. readFileStream: (ref, signal) => (async function* (): AsyncIterable<Uint8Array> {
  715. reads.push({ ref, signal })
  716. yield new Uint8Array()
  717. yield Uint8Array.of(1, 2)
  718. yield Uint8Array.of(3, 4, 5)
  719. })(),
  720. })
  721. const response = await toFetchHandler(api).fetch(
  722. new Request('http://host/api/session.export?sessionId=session-root'),
  723. )
  724. const files = unzipSync(await responseBytes(response))
  725. expect(files[`files/aa/${digest}/notes.txt`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5))
  726. expect(files[`files/cc/${fallbackDigest}/file`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5))
  727. expect(reads).toHaveLength(2)
  728. expect(reads[0]?.ref).toMatchObject({ attachmentId: id, name: 'notes.txt', bytes: 5 })
  729. expect(reads[0]?.signal).toBeInstanceOf(AbortSignal)
  730. })
  731. it('fails the whole export when a referenced file stream fails', async () => {
  732. const digest = 'b'.repeat(64)
  733. const id = `sha256:${digest}`
  734. const root = log('session-root', undefined, [fileEvent(id)])
  735. const api = await buildApi({ 'session-root': root }, [], {
  736. readFileStream: () => (async function* (): AsyncIterable<Uint8Array> {
  737. yield Uint8Array.of(1)
  738. throw new Error('file bytes missing')
  739. })(),
  740. })
  741. const response = await toFetchHandler(api).fetch(
  742. new Request('http://host/api/session.export?sessionId=session-root'),
  743. )
  744. await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing')
  745. })
  746. it('collects media referenced from nested tool results', async () => {
  747. const nested = {
  748. type: 'assistant/message', seq: SessionSeq(2), time: 2000,
  749. data: { content: [{ type: 'tool-result', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] }] },
  750. } as unknown as SessionEvent
  751. const api = await buildApi({ 'session-root': log('session-root', undefined, [nested]) })
  752. const response = await toFetchHandler(api).fetch(
  753. new Request('http://host/api/session.export?sessionId=session-root'),
  754. )
  755. const files = unzipSync(await responseBytes(response))
  756. expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', exportLogName].sort())
  757. })
  758. it('scans wrapped, inserted, and embedded-stream carriers plus non-object content items', async () => {
  759. const block = (id: string, mediaType: string): unknown =>
  760. ({ type: 'image', attachment: { attachmentId: id, mediaType, bytes: 4, width: 2, height: 2 } })
  761. const wrapped = {
  762. type: 'assistant/message', seq: SessionSeq(2), time: 2000,
  763. data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } },
  764. } as unknown as SessionEvent
  765. const inserted = {
  766. type: 'context/inserted', seq: SessionSeq(3), time: 3000,
  767. data: { inserted: [{ content: [block('inserted-1', 'image/gif')] }] },
  768. } as unknown as SessionEvent
  769. const attempt = {
  770. type: 'assistant/attempt', seq: SessionSeq(4), time: 4000,
  771. data: {
  772. stream: [
  773. { type: 'text-chunks', time0: 3999, index: 0, dt: [], texts: ['ignored'] },
  774. { type: 'chunk', time: 4000, chunk: { type: 'finish', reason: { kind: 'stop' } } },
  775. {
  776. type: 'chunk', time: 4000,
  777. chunk: { type: 'block-end', block: block('stream-1', 'image/png') },
  778. },
  779. ],
  780. },
  781. } as unknown as SessionEvent
  782. const api = await buildApi({ 'session-root': log('session-root', undefined, [wrapped, inserted, attempt]) })
  783. const response = await toFetchHandler(api).fetch(
  784. new Request('http://host/api/session.export?sessionId=session-root'),
  785. )
  786. const files = unzipSync(await responseBytes(response))
  787. expect(Object.keys(files).sort()).toEqual([
  788. 'media/inserted-1.gif',
  789. 'media/stream-1.png',
  790. 'media/wrapped-1.jpg',
  791. exportLogName,
  792. ])
  793. })
  794. it('deduplicates one media object referenced by several included logs', async () => {
  795. const root = log('session-root', undefined, [imageEvent('shared-img')])
  796. const child = log('child-a', sid('session-root'), [imageEvent('shared-img')])
  797. const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
  798. const response = await toFetchHandler(api).fetch(
  799. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  800. )
  801. const files = unzipSync(await responseBytes(response))
  802. expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
  803. expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
  804. })
  805. it('includes descendant media only when descendants are requested', async () => {
  806. const child = log('child-a', sid('session-root'), [imageEvent('child-img')])
  807. const api = await buildApi({ 'session-root': log('session-root'), 'child-a': child }, [node('child-a')])
  808. const without = await toFetchHandler(api).fetch(
  809. new Request('http://host/api/session.export?sessionId=session-root'),
  810. )
  811. expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual([exportLogName])
  812. const withDescendants = await toFetchHandler(api).fetch(
  813. new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
  814. )
  815. expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
  816. 'media/child-img.png',
  817. exportLogName,
  818. subagentLogName('child-a'),
  819. ])
  820. })
  821. it('fails the whole export when a referenced image cannot be read', async () => {
  822. const stored = log('session-root', undefined, [imageEvent('gone-img')])
  823. const api = await buildApi({ 'session-root': stored }, [], {
  824. attachments: async () => { throw new Error('attachment bytes missing') },
  825. })
  826. const response = await toFetchHandler(api).fetch(
  827. new Request('http://host/api/session.export?sessionId=session-root'),
  828. )
  829. expect(response.status).toBe(200)
  830. await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
  831. })
  832. it('answers 500 when the deployment mounts no attachments service', async () => {
  833. const api = await buildApi({ 'session-root': log('session-root') }, [], { attachments: false })
  834. const response = await toFetchHandler(api).fetch(
  835. new Request('http://host/api/session.export?sessionId=session-root'),
  836. )
  837. expect(response.status).toBe(500)
  838. expect(await response.text()).toContain('attachments')
  839. })
  840. })