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