refactor(session): separate attachment carrier policy from V4 migration
The event-type allowlist and opaque-payload attachment policy change live Session authorization/export behavior independently of a format upgrade. They belong in a focused PR against master, where supported native V3 nested tool-result content must also be handled deliberately. Restore the session-controller and archive implementation, tests and README pairs to this layer's parent. Remove the test-only session-format-catalog dependency and its lock entry with the migration-specific attachment tests. The original changes are captured from immutable commit 85737364be9 for the standalone work; migration identifier conversion and reader checks are intact. This extraction keeps the integration stack's existing attachment behavior until the separate policy change is reviewed. It makes no Session-format, schema or version change.
This commit is contained in:
1 parent
11ef33b336
commit
d5302e33f8
12 files changed
+48
-236
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client references
|
||||
|
||||
|
||||
@@ -54,8 +54,6 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
|
||||
|
||||
队列编辑仅允许用非空文本替换待处理内容。
|
||||
|
||||
附件授权只检查已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据不能授权读取附件。
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client 引用
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
@@ -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/<attachmentId>.<ext>`, and generic files use `files/<digest-prefix>/<digest>/<name>`. 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.
|
||||
|
||||
@@ -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/<attachmentId>.<ext>`,通用文件使用 `files/<digest-prefix>/<digest>/<name>`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。
|
||||
|
||||
附件收集只读取已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据保持不透明,导出的日志原样保留它们。
|
||||
|
||||
### 失败
|
||||
|
||||
当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
||||
|
||||
@@ -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<string, ImageAttachmentRef>,
|
||||
files: Map<string, FileAttachmentRef>,
|
||||
): 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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Generated
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user