Merge origin/master into worktree/web-plugin-config
Three seams: the tsconfig path map gained a mapping on each side and keeps both; the event-producer matrix is generated, so it was regenerated rather than hand-merged row by row.
This commit is contained in:
263
packages/host/apiproxy/tests/api-proxy-tasks.spec.ts
Normal file
263
packages/host/apiproxy/tests/api-proxy-tasks.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Background-task carrier paths of the host ApiProxy: the subscription
|
||||
* baseline is sent only for a session that has tasks, every registry change
|
||||
* pushes that owner's whole set, an unowned change fans out to every
|
||||
* subscribed session, the projection drops the three internal snapshot
|
||||
* fields, a composition without `ctx.tasks` emits nothing, and listing never
|
||||
* resumes a cold session.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
|
||||
|
||||
/**
|
||||
* A producer whose settlement the test drives. `cancel` deliberately does not
|
||||
* settle, so a kill is observable as the distinct `stopping` step before the
|
||||
* test supplies the terminal outcome and its detail.
|
||||
*/
|
||||
function producer(label = 'sleep 60') {
|
||||
let settle!: (outcome: TaskOutcome) => void
|
||||
// A stream producer, so the carrier CAN consume the cursor if it ever calls
|
||||
// `read()`; `reads` is what proves it never does.
|
||||
const reads = { count: 0 }
|
||||
const spec = {
|
||||
kind: 'bash' as const,
|
||||
label,
|
||||
run: () => ({
|
||||
cancel: () => {},
|
||||
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
|
||||
readOutput: () => { reads.count += 1; return 'stolen output' },
|
||||
}),
|
||||
}
|
||||
return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } }
|
||||
}
|
||||
|
||||
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
if (withRegistry) {
|
||||
await ctx.plugin(LocalTaskService)
|
||||
ctx.tasks.attachController('api-proxy-test')
|
||||
}
|
||||
const session = ctx.sessions.create()
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, session, agent }
|
||||
}
|
||||
|
||||
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
|
||||
async function collect(
|
||||
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
|
||||
count: number,
|
||||
abort: AbortController,
|
||||
): Promise<TaskFrame[]> {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const envelope of iterable) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort()
|
||||
}
|
||||
return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks')
|
||||
}
|
||||
|
||||
describe('session/tasks subscription baseline', () => {
|
||||
it('is omitted for a session with no tasks — absence is the empty set', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort()
|
||||
}
|
||||
})()
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true)
|
||||
void session
|
||||
})
|
||||
|
||||
it('carries the live set for a session that already has tasks when the stream opens', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent })
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal)
|
||||
const [baseline] = await collect(stream, 1, abort)
|
||||
expect(baseline?.sessionId).toBe(session.id)
|
||||
expect(baseline?.tasks).toHaveLength(1)
|
||||
const [task] = baseline?.tasks ?? []
|
||||
expect(task?.startedAt).toBeTypeOf('number')
|
||||
expect({ ...task, startedAt: 0 }).toEqual({
|
||||
id: 'bash-1',
|
||||
kind: 'bash',
|
||||
label: 'pnpm run build',
|
||||
status: 'running',
|
||||
startedAt: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks change pushes', () => {
|
||||
it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => {
|
||||
const { ctx, session, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 3, abort)
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.tasks.kill(id, agent, 'test')
|
||||
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
|
||||
const frames = await collected
|
||||
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
|
||||
expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed'])
|
||||
// Terminal detail rides the same whole-set push; no separate signal.
|
||||
expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM')
|
||||
expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number')
|
||||
})
|
||||
|
||||
it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => {
|
||||
const { ctx, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
|
||||
|
||||
const [frame] = await collected
|
||||
const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {})
|
||||
expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status'])
|
||||
})
|
||||
|
||||
it('fans an unowned change out to every subscribed session', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const second = ctx.sessions.create()
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 2, abort)
|
||||
|
||||
ctx.tasks.start(producer('open to every caller').spec)
|
||||
|
||||
const frames = await collected
|
||||
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
|
||||
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
|
||||
for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller')
|
||||
})
|
||||
|
||||
it('serves a cold session the unowned set without resuming it', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const coldId = SessionId('session-cold-tasks')
|
||||
let loaded = false
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
|
||||
locate: () => undefined,
|
||||
load: () => { loaded = true; throw new Error('task listing must not load a cold log') },
|
||||
} as never)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 1, abort)
|
||||
|
||||
ctx.tasks.start(producer().spec)
|
||||
await collected
|
||||
expect(loaded).toBe(false)
|
||||
expect(ctx.agents.get(coldId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks without the registry', () => {
|
||||
it('emits no frames at all, so the client renders no entry point', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort()
|
||||
}
|
||||
})()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await drained
|
||||
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks never consumes model output', () => {
|
||||
it('drives the whole lifecycle without calling the single consuming cursor', async () => {
|
||||
// `ctx.tasks.read()` consumes the one output cursor, so a carrier read
|
||||
// silently takes bytes the model's `task_output` will never see. The
|
||||
// failure is invisible at the call site, which is why this asserts the
|
||||
// count rather than trusting review.
|
||||
const { ctx, agent } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 3, abort)
|
||||
|
||||
const p = producer()
|
||||
const id = ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
ctx.tasks.kill(id, agent, 'test')
|
||||
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
|
||||
await collected
|
||||
|
||||
expect(p.reads.count).toBe(0)
|
||||
})
|
||||
|
||||
it('reads nothing while minting the subscription baseline either', async () => {
|
||||
const { ctx, agent } = await harness(true)
|
||||
const p = producer()
|
||||
ctx.tasks.start({ ...p.spec, owner: agent })
|
||||
|
||||
const abort = new AbortController()
|
||||
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal)
|
||||
const [baseline] = await collect(stream, 1, abort)
|
||||
|
||||
expect(baseline?.tasks).toHaveLength(1)
|
||||
expect(p.reads.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session/tasks baseline for a session born after the stream opened', () => {
|
||||
it('carries the already-visible unowned set to the new session', async () => {
|
||||
const { ctx } = await harness(true)
|
||||
const proxy = api(ctx)
|
||||
const abort = new AbortController()
|
||||
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal)
|
||||
|
||||
// One unowned task exists before the new session is created; the subscribe
|
||||
// frame clears the client mirror, so the baseline has to follow it.
|
||||
ctx.tasks.start(producer('visible to every caller').spec)
|
||||
const created = ctx.sessions.create()
|
||||
|
||||
const frames = await collect(stream, 2, abort)
|
||||
const forNew = frames.filter(frame => frame.sessionId === created.id)
|
||||
expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller')
|
||||
})
|
||||
})
|
||||
@@ -133,6 +133,7 @@ function scriptedApi(overrides: {
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
|
||||
},
|
||||
downloads: {
|
||||
async sessionLog() {
|
||||
return new Response('stub', { status: 404 })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -436,6 +436,11 @@ describe('events frame schemas', () => {
|
||||
},
|
||||
] },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [
|
||||
{ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 },
|
||||
{ id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 },
|
||||
] },
|
||||
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
|
||||
]
|
||||
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
|
||||
@@ -444,6 +449,14 @@ describe('events frame schemas', () => {
|
||||
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
|
||||
// A producer kind stays an open string, but the closed status set and
|
||||
// the identity/label bounds are the carrier's own wire contract.
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
|
||||
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
|
||||
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
|
||||
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
|
||||
})
|
||||
|
||||
377
packages/host/apiproxy/tests/session-export.spec.ts
Normal file
377
packages/host/apiproxy/tests/session-export.spec.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* session.export host path: the GET download endpoint streams a ZIP whose
|
||||
* files are the stored artifacts verbatim (root + optional descendants), and
|
||||
* the degenerate compositions fail loudly (missing services → 500, missing
|
||||
* root → 404, missing descendant → errored stream).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
function header(id: string, parentSession?: SessionId): SessionHeader {
|
||||
return {
|
||||
version: 0,
|
||||
id: sid(id),
|
||||
createdAt: 1000,
|
||||
cwd: '/proj',
|
||||
...parentSession === undefined ? {} : { parentSession },
|
||||
delegationDepth: parentSession === undefined ? 0 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
|
||||
return {
|
||||
meta: header(id, parentSession),
|
||||
filename: 'session.jsonl',
|
||||
content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
|
||||
return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
|
||||
}
|
||||
|
||||
/** One durable image object served by the fake attachment store. */
|
||||
function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
|
||||
return {
|
||||
ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
|
||||
data: new Uint8Array([1, 2, 3, 4]),
|
||||
}
|
||||
}
|
||||
|
||||
/** A user/message event line carrying one image reference. */
|
||||
function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
|
||||
return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
|
||||
}
|
||||
|
||||
async function buildApi(
|
||||
artifacts: Record<string, SessionRawArtifact>,
|
||||
descendants: SessionLineageNode[] = [],
|
||||
services: {
|
||||
query?: boolean
|
||||
persistence?: boolean | 'throw'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef) => Promise<ReturnType<typeof storedImage>>)
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const query = services.query ?? true
|
||||
const persistence = services.persistence ?? true
|
||||
if (query) {
|
||||
ctx.provide('sessionQuery', {
|
||||
traceSession: async () => ({
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
ancestors: [],
|
||||
complete: true,
|
||||
root: { header: header('session-root'), live: false, persisted: true },
|
||||
descendants,
|
||||
}),
|
||||
} as never)
|
||||
}
|
||||
if (persistence) {
|
||||
ctx.provide('sessionPersistence', {
|
||||
readRaw: async (id: SessionId) => {
|
||||
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
|
||||
return artifacts[id]
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
if (services.attachments !== false) {
|
||||
const readImage = typeof services.attachments === 'function'
|
||||
? services.attachments
|
||||
: async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: {} as never,
|
||||
validateImage: async () => {},
|
||||
saveImage: async () => { throw new Error('export never saves images') },
|
||||
readImage,
|
||||
} as never)
|
||||
}
|
||||
return createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
}
|
||||
|
||||
async function responseBytes(response: Response): Promise<Uint8Array> {
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
describe('session.export download endpoint', () => {
|
||||
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('application/zip')
|
||||
expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files)).toEqual(['session.jsonl'])
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
|
||||
})
|
||||
|
||||
it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
'child-a': artifact('child-a', sid('session-root')),
|
||||
'grandchild-a': artifact('grandchild-a', sid('child-a')),
|
||||
}, [
|
||||
node('child-a', node('grandchild-a')),
|
||||
])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'session.jsonl',
|
||||
'subagents/child-a/session.jsonl',
|
||||
'subagents/grandchild-a/session.jsonl',
|
||||
])
|
||||
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array))
|
||||
.toBe(artifact('child-a').content)
|
||||
})
|
||||
|
||||
it('answers 404 for a missing root session', async () => {
|
||||
const api = await buildApi({})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('answers 400 when the sessionId query parameter is absent', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?includeDescendants=true'),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers 400 for an includeDescendants value other than true or false', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
|
||||
const api = await buildApi({}, [], { query: false, persistence: false })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.text()).toContain('session-query')
|
||||
})
|
||||
|
||||
it('fails the whole export when a descendant has no stored artifact', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
}, [node('child-missing')])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
// The stream errors before completing, so the body read rejects rather
|
||||
// than returning a truncated-but-valid archive.
|
||||
await expect(response.arrayBuffer()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
|
||||
// The push loop slices by 2^16 code units and must back off one unit when
|
||||
// the boundary lands inside a surrogate pair; otherwise the pair re-encodes
|
||||
// as U+FFFD and the exported artifact is silently corrupted.
|
||||
const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
|
||||
// A boundary that lands on a BMP character needs no surrogate backoff; the
|
||||
// round trip must still be byte-identical across the multi-chunk push.
|
||||
const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('exports an empty artifact as an empty zip entry', async () => {
|
||||
const root = { ...artifact('session-root'), content: '' }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files)).toEqual(['session.jsonl'])
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
|
||||
})
|
||||
|
||||
it('exports a shared lineage node once (seen-set dedup)', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
'child-a': artifact('child-a', sid('session-root')),
|
||||
'child-b': artifact('child-b', sid('session-root')),
|
||||
shared: artifact('shared', sid('child-a')),
|
||||
}, [
|
||||
node('child-a', node('shared')),
|
||||
node('child-b', node('shared')),
|
||||
])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'session.jsonl',
|
||||
'subagents/child-a/session.jsonl',
|
||||
'subagents/child-b/session.jsonl',
|
||||
'subagents/shared/session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
|
||||
const api = await buildApi({}, [], { query: true, persistence: 'throw' })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
const body = await response.text()
|
||||
expect(body).toBe('session log export failed to read the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('img-1'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
|
||||
expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
|
||||
})
|
||||
|
||||
it('collects media referenced from nested tool results', async () => {
|
||||
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}}]}]}}'
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
nested,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
|
||||
})
|
||||
|
||||
it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
|
||||
const block = (id: string, mediaType: string) =>
|
||||
`{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
|
||||
const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
|
||||
const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
|
||||
const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
wrapped,
|
||||
inserted,
|
||||
chunk,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'media/chunk-1.png',
|
||||
'media/inserted-1.gif',
|
||||
'media/wrapped-1.jpg',
|
||||
'session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates one media object referenced by several included logs', async () => {
|
||||
const line = imageEventLine('shared-img')
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
line,
|
||||
].join('\n') + '\n')
|
||||
const child = artifact('child-a', sid('session-root'), [
|
||||
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
|
||||
line,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
|
||||
expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
|
||||
})
|
||||
|
||||
it('includes descendant media only when descendants are requested', async () => {
|
||||
const child = artifact('child-a', sid('session-root'), [
|
||||
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
|
||||
imageEventLine('child-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
|
||||
const without = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
|
||||
const withDescendants = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
|
||||
'media/child-img.png',
|
||||
'session.jsonl',
|
||||
'subagents/child-a/session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('fails the whole export when a referenced image cannot be read', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('gone-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async () => { throw new Error('attachment bytes missing') },
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
|
||||
})
|
||||
|
||||
it('answers 500 when the deployment mounts no attachments service', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.text()).toContain('attachments')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user