diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index ef274f24bf..8ceb4a8da4 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: de20b2bacf0b563404b66f716c3ffdca01cb8058 -README.zh.md: cc94e573b0bc5d52323d06d010c12457749b1914 +README.md: 228ed88ed20b9f9581496468e47079afd1542e4f +README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index de20b2bacf..228ed88ed2 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -54,8 +54,6 @@ A resume blocked by an existing write handle returns `session/writer-held` with Queue edits replace pending content with non-empty text only. -Attachment authorization inspects only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data cannot authorize attachment reads. - ## Client references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index cc94e573b0..edebf8bdda 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -54,8 +54,6 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 队列编辑仅允许用非空文本替换待处理内容。 -附件授权只检查已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据不能授权读取附件。 - ## Client 引用 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index e45f6b120d..643c553e0a 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -141,7 +141,6 @@ "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-format-catalog": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 4323315694..fa456772de 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -620,16 +620,19 @@ function imageBlockIn( if (!Array.isArray(content)) return undefined for (const value of content) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { readonly type?: unknown; readonly attachment?: unknown } + const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef if (match(ref)) return ref } + if (block.type === 'tool-result') { + const nested = imageBlockIn(block.content, match) + if (nested !== undefined) return nested + } } return undefined } -/** Read only first-party declared content fields; unknown event payloads stay opaque. */ function imageInEvent( event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean, @@ -637,46 +640,22 @@ function imageInEvent( const data = event.data as { readonly content?: unknown readonly message?: { readonly content?: unknown } - readonly inserted: readonly { readonly content?: unknown }[] - readonly messages: readonly { readonly content?: unknown }[] - readonly summary?: unknown - readonly rawOutput?: unknown + readonly inserted?: readonly { readonly content?: unknown }[] } - // First-party event payloads can be present without their producer plugin mounted. - const type: string = event.type - switch (type) { - case 'user/message': - case 'tool/ptc-dispatch': - return imageBlockIn(data.content, match) - case 'system/message': - case 'developer/message': - case 'tool/result': - case 'team/message/queued': - return imageBlockIn(data.message?.content, match) - case 'agent/inbox/spliced': - case 'session/title-llm-request': { - const messages = type === 'agent/inbox/spliced' ? data.inserted : data.messages - for (const message of messages) { - const found = imageBlockIn(message.content, match) - if (found !== undefined) return found - } - return undefined - } - case 'compaction/summary': - return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match) - case 'assistant/message': { - const found = imageBlockIn(data.message?.content, match) - if (found !== undefined) return found - break - } - case 'assistant/attempt': break - default: return undefined - } - const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'> - for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) { - const found = imageBlockIn([chunk.block], match) + const direct = imageBlockIn(data.content, match) + if (direct !== undefined) return direct + const message = imageBlockIn(data.message?.content, match) + if (message !== undefined) return message + for (const inserted of data.inserted ?? []) { + const found = imageBlockIn(inserted.content, match) if (found !== undefined) return found } + if (event.type === 'assistant/message' || event.type === 'assistant/attempt') { + for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) { + const found = imageBlockIn([chunk.block], match) + if (found !== undefined) return found + } + } return undefined } diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 2dd7454c37..3f1a45967d 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -10,7 +10,6 @@ import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq, } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' -import { createSessionFormatCatalogWithChildren, historicalSessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog' import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import { describe, expect, it, vi } from 'vitest' @@ -317,8 +316,7 @@ function imageRef(id: string): ImageAttachmentRef { } function event(type: string, seq: SessionSeq, data: unknown): SessionEvent { - const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type) - return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent + return { type, seq, time: seq + 1, data } as SessionEvent } async function persistedController( @@ -350,90 +348,16 @@ async function persistedController( } describe('Session attachment authorization', () => { - it.each([ - ['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'], - ['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'], - ['session/title-llm-request', 'messages'], ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], - ] as const)('reads the declared %s %s content without rewriting it', async (type, field) => { - const ref = imageRef(`${type}-${field}`) - const content = [{ type: 'image', attachment: ref }] - const source = type === 'system/message' ? { kind: 'system-prompt' } - : type === 'tool/result' ? { kind: 'tool', callId: 'declared-call' } : { kind: 'test' } - const data = field === 'message' ? { message: { - id: 'declared-message', role: type.split('/')[0], content, source, - ...type === 'tool/result' ? { toolCallId: 'declared-call' } : {}, - } } - : field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content } - const events = [event(type, SessionSeq(0), data)] - const saved = JSON.stringify(events) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController(events, readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .resolves.toEqual({ attachment: ref, data: 'AQ==' }) - expect(readImage).toHaveBeenCalledExactlyOnceWith(ref) - expect(JSON.stringify(events)).toBe(saved) - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('ignores unrelated fields of a known message event and its content blocks', async () => { - const ref = imageRef('not-a-content-occurrence') - const content = [{ type: 'image', attachment: ref }] - const stored = event('user/message', SessionSeq(0), { - id: 'metadata', role: 'user', source: { kind: 'user' }, - content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], - message: { content }, inserted: [{ content }], - }) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController([stored], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid' }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('denies attachment access from a migrated unknown ignorable event', async () => { - const ref = imageRef('opaque-event-image') - const content = [{ type: 'message', message: { - role: 'tool', toolCallId: 'opaque-call', content: [{ type: 'image', attachment: ref }], - } }] - const header = { type: 'session', version: 3, id: 'cold-attachment', createdAt: 1, isSeeded: false, delegationDepth: 0 } - const original = { type: 'external/image-record', seq: 0, time: 1, ignorable: true, data: { - content, message: { content }, inserted: [{ content }], - stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }], - } } - const historical = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' }) - historical.decodeRow(original) - expect(historical.finish().header.version).toBe(3) - const reader = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' }) - reader.decodeRow(original) - const converted = reader.finish() - expect(converted.events[0]).toMatchObject({ type: 'plugin:external/image-record', data: original.data }) - const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) })) - const fixture = await persistedController(converted.events as SessionEvent[], readImage) - try { - await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId })) - .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) - expect(readImage).not.toHaveBeenCalled() - } finally { - await fixture.ctx.fiber.dispose() - } - }) - - it('finds references in direct, message, inserted, and streamed content', async () => { + it('finds references in direct, message, inserted, nested, and streamed content', async () => { const nested = imageRef('nested') const message = imageRef('message') const inserted = imageRef('inserted') const streamed = imageRef('streamed') const events: SessionEvent[] = [ - { ...event('user/message', SessionSeq(0), { - id: 'direct', role: 'user', source: { kind: 'user' }, - content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: nested }], + { ...event('fixture/direct', SessionSeq(0), { + content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { + type: 'tool-result', content: [{ type: 'image', attachment: nested }], + }], }), ignorable: true as const }, { type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append', @@ -527,7 +451,7 @@ describe('Session attachment authorization', () => { ]) { const ref = imageRef(`failure-${thrown.name}`) const fixture = await persistedController( - [event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })], + [event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })], () => Promise.reject(thrown), ) await expectFailure(fixture.controller.attachment({ diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index f475fe9c87..a2c80bc2d6 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md -README.md: 7fa004df954260de16a6a151e3110e6adfbc0f09 -README.zh.md: 147ee93b3929473d4e78024ff0b4ccbd040a1220 +README.md: 79b8d572a75f57aa13a0dfaa823a04ad1e818f87 +README.zh.md: d6229927130e7200cd21df0da64d0ce9ec80ab2f diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 7fa004df95..79b8d572a7 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -59,8 +59,6 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. -Attachment collection reads only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data remain opaque; the exported log retains them unchanged. - ### Failures The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 147ee93b39..d622992713 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -59,8 +59,6 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后结束时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。 -附件收集只读取已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据保持不透明,导出的日志原样保留它们。 - ### 失败 当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 3dbf5211e1..8728ca9878 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -196,8 +196,9 @@ function fileEntryPath(ref: FileAttachmentRef): string { } /** - * Collect attachment references from one flat content array; all other block fields remain opaque. - * @param content - a declared event content array. + * Collect every attachment reference inside one content array, descending into + * nested tool results the way the live attachment route does. + * @param content - an event content array (or nested tool-result content). * @param images - image dedupe map keyed by attachment id. * @param files - file dedupe map keyed by attachment id and stored name. */ @@ -212,7 +213,7 @@ function collectAttachmentRefs( while (pending.length > 0) { const value = pending.pop() if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; attachment?: unknown } + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef images.set(String(ref.attachmentId), ref) @@ -221,12 +222,16 @@ function collectAttachmentRefs( const ref = block.attachment as FileAttachmentRef files.set(`${String(ref.attachmentId)}\u0000${ref.name}`, ref) } + if (Array.isArray(block.content)) { + for (const item of block.content) pending.push(item) + } } } /** - * Collect references only from declared first-party content fields and completed - * Assistant blocks. Unknown events and unrelated payload fields remain opaque. + * Collect every attachment reference one session event carries, across the same + * carriers the live attachment route scans (direct content, message content, + * inserted messages, and completed blocks in embedded Assistant streams). * @param event - one parsed JSONL event object. * @param images - image dedupe map keyed by attachment id. * @param files - file dedupe map keyed by attachment id and stored name. @@ -236,40 +241,18 @@ function collectEventAttachmentRefs( images: Map, files: Map, ): void { - if (typeof event !== 'object' || event === null || Array.isArray(event)) return - const row = event as { type?: unknown; data?: unknown } - const data = row.data + const data = (event as { data?: unknown }).data if (typeof data !== 'object' || data === null) return const carrier = data as { content?: unknown message?: { content?: unknown } inserted?: Array<{ content?: unknown }> - messages?: Array<{ content?: unknown }> - summary?: unknown - rawOutput?: unknown stream?: Array<{ type?: unknown; chunk?: { type?: unknown; block?: unknown } }> } - switch (row.type) { - case 'user/message': case 'tool/ptc-dispatch': - collectAttachmentRefs(carrier.content, images, files) - return - case 'system/message': case 'developer/message': case 'tool/result': case 'team/message/queued': - collectAttachmentRefs(carrier.message?.content, images, files) - return - case 'agent/inbox/spliced': case 'session/title-llm-request': { - const messages = row.type === 'agent/inbox/spliced' ? carrier.inserted : carrier.messages - for (const message of messages ?? []) collectAttachmentRefs(message.content, images, files) - return - } - case 'compaction/summary': - collectAttachmentRefs(carrier.summary, images, files) - collectAttachmentRefs(carrier.rawOutput, images, files) - return - case 'assistant/message': - collectAttachmentRefs(carrier.message?.content, images, files) - break - case 'assistant/attempt': break - default: return + collectAttachmentRefs(carrier.content, images, files) + if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files) + if (carrier.inserted !== undefined) { + for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files) } if (carrier.stream !== undefined) { for (const record of carrier.stream) { diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 011896e79b..973dbe9d91 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -503,15 +503,6 @@ describe('session.export download endpoint', () => { expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('') }) - it('preserves non-event JSON and absent content carriers without discovering attachment lookalikes', async () => { - const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png' } }] - const text = [null, [], false, { type: 'agent/inbox/spliced', data: { content } }, - { type: 'session/title-llm-request', data: { content } }].map(value => JSON.stringify(value)).join('\n') - const files = await directZipFiles(text) - expect(Object.keys(files)).toEqual([exportLogName]) - expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(text) - }) - it('waits for response pull capacity before reading the next archive entry', async () => { const filler = { type: 'user/message', seq: SessionSeq(2), time: 1000, @@ -806,63 +797,10 @@ describe('session.export download endpoint', () => { await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing') }) - it.each([ - ['system/message', 'message'], ['developer/message', 'message'], ['team/message/queued', 'message'], - ['tool/ptc-dispatch', 'content'], ['session/title-llm-request', 'messages'], - ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'], - ] as const)('exports attachments from the declared %s %s content', async (type, field) => { - const content = [{ type: 'image', attachment: { attachmentId: 'declared-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }] - const data = field === 'message' ? { message: { content } } - : field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content } - const stored = log('session-root', undefined, [{ type, seq: SessionSeq(1), time: 1000, data } as unknown as SessionEvent]) - const api = await buildApi({ 'session-root': stored }) - 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/declared-image.png', exportLogName].sort()) - expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) - }) - - it('keeps unrelated event fields and content-block extension fields out of attachment collection', async () => { - const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }] - const event = { type: 'user/message', seq: SessionSeq(1), time: 1000, data: { - content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }], - message: { content }, inserted: [{ content }], - stream: [{ type: 'chunk', chunk: { type: 'block-end', block: content[0] } }], - } } as unknown as SessionEvent - const stored = log('session-root', undefined, [event]) - const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType))) - const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage }) - 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([exportLogName]) - expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) - expect(readImage).not.toHaveBeenCalled() - }) - - it('exports an unknown ignorable event without reading attachments from its opaque payload', async () => { - const image = { type: 'image', attachment: { attachmentId: 'opaque-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } } - const file = { type: 'file', attachment: { attachmentId: `sha256:${'e'.repeat(64)}`, name: 'opaque.txt', bytes: 5 } } - const content = [{ type: 'message', message: { role: 'tool', toolCallId: 'opaque', content: [image, file] } }] - const opaque = { type: 'plugin:external/image-record', seq: SessionSeq(1), time: 1000, ignorable: true, data: { - content, message: { content }, inserted: [{ content }], - stream: [{ type: 'chunk', time: 1000, chunk: { type: 'block-end', index: 0, block: content[0] } }], - } } as unknown as SessionEvent - const stored = log('session-root', undefined, [opaque]) - const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType))) - const readFileStream = vi.fn(async function* (_ref: FileAttachmentRef) { yield new Uint8Array([1]) }) - const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage, readFileStream }) - 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([exportLogName]) - expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored)) - expect(readImage).not.toHaveBeenCalled() - expect(readFileStream).not.toHaveBeenCalled() - }) - - it('collects media referenced from flat tool results', async () => { + it('collects media referenced from nested tool results', async () => { const nested = { - type: 'tool/result', seq: SessionSeq(2), time: 2000, - data: { message: { role: 'tool', toolCallId: 'outer', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] } }, + type: 'assistant/message', seq: SessionSeq(2), time: 2000, + data: { content: [{ type: 'tool-result', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] }] }, } as unknown as SessionEvent const api = await buildApi({ 'session-root': log('session-root', undefined, [nested]) }) const response = await toFetchHandler(api).fetch( @@ -880,7 +818,7 @@ describe('session.export download endpoint', () => { data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } }, } as unknown as SessionEvent const inserted = { - type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 3000, + type: 'context/inserted', seq: SessionSeq(3), time: 3000, data: { inserted: [{ content: [block('inserted-1', 'image/gif')] }] }, } as unknown as SessionEvent const attempt = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19396a2bbb..fd03c89885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1142,9 +1142,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-format-catalog': - specifier: workspace:^ - version: link:../../session/session-format-catalog '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence