Преглед на файлове

refactor(attachment): unify prompt content admission

creatixchu преди 1 седмица
родител
ревизия
ea669428be

+ 2 - 2
.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

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md


+ 2 - 2
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

+ 14 - 6
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.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -182,13 +190,13 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
 async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
 
 /**
- * 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<AdmittedPromptContentPart[]>
+async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
 
 /**
  * Validate and durably commit one image before its owning session event is appended.

+ 14 - 6
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 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -182,13 +190,13 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
 async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
 
 /**
- * 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<AdmittedPromptContentPart[]>
+async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
 
 /**
  * Validate and durably commit one image before its owning session event is appended.

+ 2 - 2
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

+ 1 - 1
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.
 

+ 1 - 1
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 与历史操作,不提供文件传输。
 

+ 19 - 28
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<FileUploadReceiptId, FileAttachmentRef>()
-  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<FileUploadReceiptId>()
+  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<SessionPromptRequest['content'][number], { readonly type: 'file' }>
-  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 {

+ 12 - 2
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' },
     ])
   })

+ 2 - 2
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

+ 1 - 1
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
 

+ 1 - 1
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)。
 
 ### 源码地图
 

+ 15 - 10
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<AdmittedPromptContentPart[]> {
-    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 }
+    })
   }
 
   /**

+ 7 - 1
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 {

+ 18 - 5
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 } },
     ])

+ 8 - 4
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<AdmittedPromptContentPart[]>',
-        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<AdmittedPromptContentPart[]>',
+        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\'>;',

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -359,6 +359,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   ApprovalService: 'approval.md',
   AskUserQuestionRequestEvent: 'user-questions.md',
   AdmittedPromptContentPart: 'attachment.md',
+  AttachmentAdmissionPart: 'attachment.md',
   EncodedFileAttachment: 'attachment.md',
   EncodedImageAttachment: 'attachment.md',
   FileAttachmentRef: 'attachment.md',

+ 5 - 0
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",

Някои файлове не бяха показани, защото твърде много файлове са промени