From ea669428bed0bcdac437bfc317674b9ff9ffa4c6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 3 Sep 2026 20:53:29 +0800 Subject: [PATCH] refactor(attachment): unify prompt content admission --- .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 2 +- .../2026-08-26-generic-file-upload.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 20 +++++--- docs/subsystems/attachment.zh.md | 20 +++++--- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/commands.ts | 47 ++++++++----------- .../tests/commands-upload-file.host.spec.ts | 14 +++++- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 25 ++++++---- packages/attachment/attachment/src/types.ts | 8 +++- .../attachment/tests/admission.spec.ts | 23 +++++++-- .../extensions/tool-cordis/src/api-catalog.ts | 12 +++-- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 20 files changed, 127 insertions(+), 76 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 7511572021..5ba21dcd1a 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.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 .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: b1beeb5afd50a1ff03ba583567202608260e8ebe -2026-08-26-generic-file-upload.zh.md: 0407cb76967c27831dfd8401da422d448290637c +2026-08-26-generic-file-upload.md: fcb60c2d02ec1aac5d9d40f05dad0eaa5b823cd9 +2026-08-26-generic-file-upload.zh.md: e696a8173c3f673d0bd574474816550fb8160158 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index b1beeb5afd..fcb60c2d02 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -14,7 +14,7 @@ The command plane had already established a whole-envelope rule for images: a co Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts an Agent Context together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it obtains the wire identity from Typert's registered `agent` Context adapter and assembles the raw route or scoped Remote request. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, byte storage, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. -Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. The Host validates every file receipt before persisting any image, rejects upload completion after the original Agent is disposed, and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. +Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 0407cb7696..e696a8173c 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -14,7 +14,7 @@ Status: implemented 文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收 Agent Context 与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它通过 Typert 已注册的 `agent` Context adapter 取得 wire identity,并组装原始路由或按 scope 寻址的 Remote 请求。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、字节存储、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 -默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Host 会在持久化任何图片前验证所有文件凭证,原 Agent 已销毁时拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 +默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 ## 考虑过的替代方案 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index f6bcc65c23..485ec384c8 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: 6d9d4c86aa9435450a766c6aa74f4062824ba96b -attachment.zh.md: 4a8a7c3acf4203ed4d3cff2b92098086a5aa39a9 +attachment.md: 1ad7e27c4474d5a7544690a8a9e07e77c21597ac +attachment.zh.md: bd58de40b9a8bd675e5a555c5a16ff0b5d81d4bd diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 6d9d4c86aa..1ad7e27c44 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -80,10 +80,18 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } ``` ```ts type-equiv @@ -149,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` is the Host prompt entry and replaces base64 image uploads with durable references in part order. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -182,13 +190,13 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access attachment storage. - * @param content - browser prompt parts in message order. + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ -async admitPromptContent( content: readonly PromptContentPart[], ): Promise +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 4a8a7c3acf..bd58de40b9 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -80,10 +80,18 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } ``` ```ts type-equiv @@ -149,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 是 Host prompt 入口,按 part 顺序把 base64 图片上传替换为持久引用。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -182,13 +190,13 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access attachment storage. - * @param content - browser prompt parts in message order. + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ -async admitPromptContent( content: readonly PromptContentPart[], ): Promise +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 555f274fbc..db720e76c7 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: effcf3fe21b8091ee0eb012dd80dfe2f4e75d394 -README.zh.md: 6f1a260f60377c6a8079ffbde757fe4dbada9337 +README.md: a75dbdbaae526348e40da90ae7a57b702cb8b264 +README.zh.md: 5411878a5a87dd2b02cd46fc37f5aa6ddea291ac diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index effcf3fe21..a75dbdbaae 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and validates every same-Agent receipt before persisting images. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 6f1a260f60..5411878a5a 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,并在持久化图片前验证每个凭证属于同一个准确 Agent。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 8b2a4ad658..3671c4e31d 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -5,13 +5,15 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' -import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef, +} from '@deepseek-ai/dsh-attachment' import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import type { MessageSource } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -328,12 +330,12 @@ export class SessionCommandController { ) } } - const durable = await durablePromptContent( - this.ctx, + const admission = resolvePromptFileReceipts( request.content, receiptId => this.ctx.fileUploads.resolve(agent, receiptId), ) - const message: UserMessage = createUserMessage({ content: durable.content, source }) + const content = await this.ctx.attachments.admitPromptContent(admission.content) + const message: UserMessage = createUserMessage({ content, source }) if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', @@ -341,7 +343,7 @@ export class SessionCommandController { { sessionId: agent.id }, ) } - using binding = this.ctx.fileUploads.bindPrompt(agent, durable.receiptIds, request.requestId) + using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId) if (request.mode === 'steer') agent.steer(message) else agent.followup(message) binding.commit() @@ -520,36 +522,25 @@ export class SessionCommandController { } } -async function durablePromptContent( - ctx: Context, - content: readonly SessionPromptRequest['content'][number][], +function resolvePromptFileReceipts( + content: SessionPromptRequest['content'], stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, -): Promise<{ readonly content: ContentBlock[]; readonly receiptIds: readonly FileUploadReceiptId[] }> { - const files = new Map() - for (const part of content) { - if (part.type !== 'file' || files.has(part.receiptId)) continue - const file = stagedFile(part.receiptId) - if (file === undefined) { +): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } { + const receiptIds = new Set() + const resolved = content.map((part): AttachmentAdmissionPart => { + if (part.type !== 'file') return part + const attachment = stagedFile(part.receiptId) + if (attachment === undefined) { throw new RemoteError( 'session/attachment-invalid', 'File was not uploaded for this session.', { reason: 'FILE_NOT_STAGED' }, ) } - files.set(part.receiptId, file) - } - type NonFilePart = Exclude - const admitted = await ctx.attachments.admitPromptContent( - content.filter((part): part is NonFilePart => part.type !== 'file'), - ) - let next = 0 - const durable = content.map((part) => { - if (part.type === 'file') { - return { type: 'file' as const, attachment: files.get(part.receiptId) as FileAttachmentRef } - } - return admitted[next++] as ContentBlock + receiptIds.add(part.receiptId) + return { type: 'file', attachment } }) - return { content: durable, receiptIds: [...files.keys()] } + return { content: resolved, receiptIds: [...receiptIds] } } function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index acff794b8a..4e7565c7ff 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -126,8 +126,8 @@ describe('Session file uploads', () => { .resolves.toMatchObject({ status: 405 }) }) - it('stages one verbatim upload and cites it from a later prompt as a file block', async () => { - const { ctx, controller, uploads, agent, followup, saveFile } = await uploadHarness() + it('stages one verbatim upload and preserves its order with an admitted image', async () => { + const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal) expect(saveFile).toHaveBeenCalledTimes(1) expect(receipt.file.name).toBe('notes.pdf') @@ -148,14 +148,24 @@ describe('Session file uploads', () => { expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({ attachments: [{ type: 'file', attachment: receipt.file }], }) + const image: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + } + saveImages.mockResolvedValueOnce([image]) await controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'text', text: 'read it' }, ])) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ { type: 'file', attachment: receipt.file }, + { type: 'image', attachment: image }, { type: 'text', text: 'read it' }, ]) }) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 2aceef5333..6064cc78ca 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 1ab340bbd96881aceaa9ba20ace02fecb2f85052 -README.zh.md: 1fbc9163f202dac32f48d0ca39aa4ebe9559766f +README.md: 73c10f0a7c857291f7fbfe42042b9c9f4ce13da0 +README.zh.md: 43c95a8b072c6d9680af4a7f83c0e5c962aed48b diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 1ab340bbd9..73c10f0a7c 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -72,7 +72,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers call `ctx.attachments.admitPromptContent()` to replace browser image uploads with durable references while retaining part order. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 1fbc9163f2..43c95a8b07 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方调用 `ctx.attachments.admitPromptContent()`,按原顺序把浏览器图片上传替换为持久引用。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 1def0c6102..4fb08a85fc 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -5,11 +5,11 @@ import { admitEncodedImages } from './admission.ts' import { AttachmentError } from './error.ts' import type { AdmittedPromptContentPart, + AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, - PromptContentPart, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -25,6 +25,7 @@ export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, AdmittedPromptContentPart, + AttachmentAdmissionPart, EncodedFileAttachment, EncodedImageAttachment, FileAttachmentRef, @@ -102,23 +103,27 @@ export abstract class AttachmentStore extends Service { } /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access attachment storage. - * @param content - browser prompt parts in message order. + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ async admitPromptContent( - content: readonly PromptContentPart[], + content: readonly AttachmentAdmissionPart[], ): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) + if (content.every(part => part.type !== 'image')) { + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'file', attachment: part.attachment }) } const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) + return content.map((part) => { + if (part.type === 'text') return { type: 'text', text: part.text } + if (part.type === 'file') return { type: 'file', attachment: part.attachment } + return { type: 'image', attachment: refs[next++] as ImageAttachmentRef } + }) } /** diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 032c7e07c8..a69b2363dc 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -106,10 +106,16 @@ export type PromptContentPart = readonly name?: string } -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +export type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + +/** Host-admitted prompt content with every attachment represented by its durable reference. */ export type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index 7951164cd7..71c56c0fed 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,8 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import AttachmentStore, { admitEncodedFile, admitEncodedImages } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import type { + FileAttachmentRef, ImageAttachmentRef, SaveImageAttachment, +} from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes +const FILE_REF: FileAttachmentRef = { + attachmentId: 'file-1' as FileAttachmentRef['attachmentId'], + name: 'notes.md', + bytes: 12, +} /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { @@ -104,23 +111,29 @@ describe('admitEncodedFile', () => { }) describe('AttachmentStore.admitPromptContent', () => { - it('converts text-only prompts without touching the attachment store', async () => { + it('passes through text and durable files without touching image storage', async () => { const store = Object.setPrototypeOf({ - saveImages: () => { throw new Error('text-only prompts must not reach the store') }, + saveImages: () => { throw new Error('prompts without image uploads must not reach the store') }, }, AttachmentStore.prototype) as AttachmentStore await expect(store.admitPromptContent([ { type: 'text', text: 'hello' }, - ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) + { type: 'file', attachment: FILE_REF }, + ])).resolves.toEqual([ + { type: 'text', text: 'hello' }, + { type: 'file', attachment: FILE_REF }, + ]) }) - it('replaces image parts with admitted references in part order', async () => { + it('replaces images and passes through files in part order', async () => { const { store } = storeOf() await expect(store.admitPromptContent([ { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', mediaType: 'image/png', data: 'Ag==' }, ])).resolves.toEqual([ { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, ]) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index d2f808d89d..ba4b530452 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -483,9 +483,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'durable normalized attachment references in the same order after every member succeeds.', }, { - signature: 'async admitPromptContent( content: readonly PromptContentPart[], ): Promise', - description: 'Admit one browser prompt and replace each uploaded image with its durable reference. Text-only prompts do not access attachment storage.', - parameters: [{ name: 'content', description: 'browser prompt parts in message order.' }], + signature: 'async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise', + description: 'Admit one Host prompt and replace each uploaded image with its durable reference. Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation.', + parameters: [{ name: 'content', description: 'prompt parts in message order after file receipt resolution.' }], returns: 'admitted prompt parts in the same order as `content`.', throws: ['AttachmentError when the image batch is refused.'], }, @@ -3448,7 +3448,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AdmittedPromptContentPart', - declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n};', + declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n} | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', }, { name: 'Agent', @@ -3590,6 +3590,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssistantProvenance', declaration: 'export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n}', }, + { + name: 'AttachmentAdmissionPart', + declaration: 'export type AttachmentAdmissionPart = PromptContentPart | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', + }, { name: 'AttachmentId', declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ca4cc4aafa..6073ca5f4f 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -359,6 +359,7 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', AdmittedPromptContentPart: 'attachment.md', + AttachmentAdmissionPart: 'attachment.md', EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', FileAttachmentRef: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c0109a078e..4cbd595653 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -951,6 +951,11 @@ "symbol": "PromptContentPart", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AttachmentAdmissionPart", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "AdmittedPromptContentPart",