Selaa lähdekoodia

refactor(file-upload): tighten service boundaries

creatixchu 1 viikko sitten
vanhempi
sitoutus
f0e52c2731
30 muutettua tiedostoa jossa 217 lisäystä ja 138 poistoa
  1. 2 2
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml
  2. 0 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
  3. 0 0
      .agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md
  4. 2 2
      docs/subsystems/attachment.i18n.yaml
  5. 16 1
      docs/subsystems/attachment.md
  6. 16 1
      docs/subsystems/attachment.zh.md
  7. 2 2
      packages/attachment/attachment/README.i18n.yaml
  8. 1 1
      packages/attachment/attachment/README.md
  9. 1 1
      packages/attachment/attachment/README.zh.md
  10. 22 2
      packages/attachment/attachment/src/index.ts
  11. 28 0
      packages/attachment/attachment/tests/index.spec.ts
  12. 2 2
      packages/client/file-upload/README.i18n.yaml
  13. 9 8
      packages/client/file-upload/README.md
  14. 9 8
      packages/client/file-upload/README.zh.md
  15. 4 4
      packages/client/file-upload/package.json
  16. 8 8
      packages/client/file-upload/src/client/contract.ts
  17. 5 4
      packages/client/file-upload/src/client/index.ts
  18. 17 25
      packages/client/file-upload/src/client/runtime.ts
  19. 5 6
      packages/client/file-upload/src/index.ts
  20. 22 38
      packages/client/file-upload/tests/file-upload.client.spec.ts
  21. 1 0
      packages/client/file-upload/tsconfig.client.json
  22. 1 3
      packages/client/ui-conversation/src/client/service.ts
  23. 3 5
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  24. 5 4
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  25. 25 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  26. 2 1
      packages/test-support/client-runtime/src/index.ts
  27. 1 1
      packages/test-support/client-runtime/tests/helpers.client.spec.tsx
  28. 7 7
      pnpm-lock.yaml
  29. 1 0
      scripts/gen-cordis-catalog.ts
  30. 0 2
      scripts/package-dependency-policy.ts

+ 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: fcb60c2d02ec1aac5d9d40f05dad0eaa5b823cd9
-2026-08-26-generic-file-upload.zh.md: e696a8173c3f673d0bd574474816550fb8160158
+2026-08-26-generic-file-upload.md: d8643f5f1a3e0dcdfdc7fc8b79cbb156b07f4402
+2026-08-26-generic-file-upload.zh.md: 38de46a424e3f02b7c0293e15835d096b108f071

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 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: 1ad7e27c4474d5a7544690a8a9e07e77c21597ac
-attachment.zh.md: bd58de40b9a8bd675e5a555c5a16ff0b5d81d4bd
+attachment.md: 53b3e51e5b1c87625256178287061386342bc055
+attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1

+ 16 - 1
docs/subsystems/attachment.md

@@ -157,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()` 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.
+`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()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `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 -->
 
@@ -198,6 +198,21 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly Image
  */
 async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
 
+/**
+ * Decode and durably commit one canonical base64 file upload.
+ * @param input - canonical base64 bytes and optional display name.
+ * @returns the durable content-addressed file reference.
+ * @throws AttachmentError when the encoding or storage operation is refused.
+ */
+admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Identify a failure emitted by this attachment capability by its stable code.
+ * @param error - value caught from an attachment operation.
+ * @returns whether the value is an attachment failure.
+ */
+isAttachmentError(error: unknown): error is AttachmentError
+
 /**
  * Validate and durably commit one image before its owning session event is appended.
  * The returned reference describes the persisted normalized image. When

+ 16 - 1
docs/subsystems/attachment.zh.md

@@ -157,7 +157,7 @@ interface RequestImageAttachment {
 }
 ```
 
-`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
+`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -198,6 +198,21 @@ async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly Image
  */
 async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise<AdmittedPromptContentPart[]>
 
+/**
+ * Decode and durably commit one canonical base64 file upload.
+ * @param input - canonical base64 bytes and optional display name.
+ * @returns the durable content-addressed file reference.
+ * @throws AttachmentError when the encoding or storage operation is refused.
+ */
+admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>
+
+/**
+ * Identify a failure emitted by this attachment capability by its stable code.
+ * @param error - value caught from an attachment operation.
+ * @returns whether the value is an attachment failure.
+ */
+isAttachmentError(error: unknown): error is AttachmentError
+
 /**
  * Validate and durably commit one image before its owning session event is appended.
  * The returned reference describes the persisted normalized image. When

+ 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: 73c10f0a7c857291f7fbfe42042b9c9f4ce13da0
-README.zh.md: 43c95a8b072c6d9680af4a7f83c0e5c962aed48b
+README.md: a812182aeff6d09506a1ea2d4fa8d9a44a175936
+README.zh.md: c487f8204c86d8f0bbdfd85280e8fbab6ea14dec

+ 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 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).
+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. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. 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()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。
 
 ### 源码地图
 

+ 22 - 2
packages/attachment/attachment/src/index.ts

@@ -1,11 +1,12 @@
 /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
 
 import { Context, Service } from '@deepseek-ai/cordis'
-import { admitEncodedImages } from './admission.ts'
-import { AttachmentError } from './error.ts'
+import { admitEncodedFile as admitFileInput, admitEncodedImages } from './admission.ts'
+import { AttachmentError, isAttachmentError as matchesAttachmentError } from './error.ts'
 import type {
   AdmittedPromptContentPart,
   AttachmentAdmissionPart,
+  EncodedFileAttachment,
   FileAttachmentRef,
   ImageAttachmentLimits,
   ImageAttachmentRef,
@@ -126,6 +127,25 @@ export abstract class AttachmentStore extends Service {
     })
   }
 
+  /**
+   * Decode and durably commit one canonical base64 file upload.
+   * @param input - canonical base64 bytes and optional display name.
+   * @returns the durable content-addressed file reference.
+   * @throws AttachmentError when the encoding or storage operation is refused.
+   */
+  admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef> {
+    return admitFileInput(this, input)
+  }
+
+  /**
+   * Identify a failure emitted by this attachment capability by its stable code.
+   * @param error - value caught from an attachment operation.
+   * @returns whether the value is an attachment failure.
+   */
+  isAttachmentError(error: unknown): error is AttachmentError {
+    return matchesAttachmentError(error)
+  }
+
   /**
    * Validate and durably commit one image before its owning session event is appended.
    * The returned reference describes the persisted normalized image. When

+ 28 - 0
packages/attachment/attachment/tests/index.spec.ts

@@ -10,6 +10,7 @@ import AttachmentStore, {
   type ImageMediaType,
   type ImageRequestPolicy,
   type RequestImageAttachment,
+  type SaveFileAttachment,
   type SaveImageAttachment,
   type StoredImageAttachment,
 } from '../src/index.ts'
@@ -89,6 +90,19 @@ class UnsupportedProjectionStore extends AttachmentStore {
   }
 }
 
+class RecordingFileStore extends RecordingStore {
+  fileInput: SaveFileAttachment | undefined
+
+  override saveFile(input: SaveFileAttachment) {
+    this.fileInput = input
+    return Promise.resolve({
+      attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
+      name: input.name ?? 'unnamed',
+      bytes: input.data.byteLength,
+    })
+  }
+}
+
 function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment {
   return { data: Uint8Array.of(value), mediaType, name: `${value}.png` }
 }
@@ -178,6 +192,20 @@ describe('AttachmentStore.readImageRequest', () => {
   })
 })
 
+describe('AttachmentStore file admission', () => {
+  it('decodes encoded files through the service and exposes attachment errors', async () => {
+    const store = new RecordingFileStore(new Context())
+
+    await expect(store.admitEncodedFile({ data: 'AQID', name: 'notes.bin' })).resolves.toMatchObject({
+      name: 'notes.bin',
+      bytes: 3,
+    })
+    expect(store.fileInput).toEqual({ data: Uint8Array.of(1, 2, 3), name: 'notes.bin' })
+    expect(store.isAttachmentError(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))).toBe(true)
+    expect(store.isAttachmentError(new Error('unknown failure'))).toBe(false)
+  })
+})
+
 describe('isImageAdmissionError', () => {
   it('separates caller-correctable image admission failures from storage faults', () => {
     expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true)

+ 2 - 2
packages/client/file-upload/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/client/file-upload/README.md
-README.md: 5527bf6f544529088a255934a87af77be4865e12
-README.zh.md: 7825326863511cd7098635d563237bddeb46d035
+README.md: 2c19e1a564d7b58226e6ccd3cb5b7c81e1b1c14e
+README.zh.md: d1553d40d953dd6378a954c43a9b7a36b7334953

+ 9 - 8
packages/client/file-upload/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Agent-scoped browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts."
+description: "Session-addressed browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream<Uint8Array>` under one Agent scope and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs.
+This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream<Uint8Array>` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs.
 
 ## Table of Contents
 
@@ -25,14 +25,14 @@ This package lets browser features store a `Blob`, exact bytes, or a `ReadableSt
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`. The service derives the wire identity from `agentContext`; callers do not assemble an upload URL or Remote request.
+Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request.
 
 ```yaml
 - id: file-upload
   name: '@deepseek-ai/dsh-client-file-upload'
 ```
 
-The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the scoped generated Remote.
+The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the generated Remote.
 
 -----
 
@@ -42,13 +42,13 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The Client plugin provides `ctx.fileUpload`. Its `upload()` method asks Typert's registered `agent` Context adapter for the caller's identity, assembles the raw route request, and invokes the generated scoped Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks.
+The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks.
 
-The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, byte storage, command receipt resolver, and staged-receipt lifecycle. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt.
+The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt.
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Host streaming route, storage, and Agent-scoped receipt lifecycle |
+| [`src/index.ts`](src/index.ts) | Host streaming route, attachment-service admission, and Agent-scoped receipt lifecycle |
 | [`src/types.ts`](src/types.ts) | encoded request, receipt, and durable result types |
 | [`src/client/contract.ts`](src/client/contract.ts) | Client upload, progress, and page-hook types |
 | [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations |
@@ -56,7 +56,7 @@ The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming
 
 </details>
 
-**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Agent scope, and each request uses one selected carrier. Unscoped calls and unsupported stream carriers fail before the body is sent.
+**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Session, and each request uses one selected carrier. Unsupported stream carriers fail before the body is sent.
 
 -----
 
@@ -88,6 +88,7 @@ These limits apply to the transport operation itself.
 - **Uploads are not resumable** — a failed or cancelled retry starts from the first byte.
 - **Stream bodies are one-shot** — transferring a `ReadableStream` locks the caller's object, so retry requires a newly created stream.
 - **Stream progress has no total** — callers receive consumed-byte counts because the stream API carries no byte length.
+- **The browser Worker is self-contained** — its source is emitted from a function string. Adding runtime imports requires moving it to a standalone Worker entry bundled by tsdown.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 9 - 8
packages/client/file-upload/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "按 Agent scope 上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。"
+description: "按 Session 寻址上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包让浏览器功能在一个 Agent scope 下存储 `Blob`、精确字节或 `ReadableStream<Uint8Array>`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。
+本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream<Uint8Array>`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。
 
 ## 目录
 
@@ -25,14 +25,14 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(agentContext, body, name, signal, onProgress)`。服务从 `agentContext` 得到 wire identity;调用方不组装上传 URL 或 Remote 请求。
+在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。
 
 ```yaml
 - id: file-upload
   name: '@deepseek-ai/dsh-client-file-upload'
 ```
 
-本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用按 scope 寻址的生成 Remote。
+本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用生成 Remote。
 
 -----
 
@@ -42,13 +42,13 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法通过 Typert 已注册的 `agent` Context adapter 取得调用方身份,组装原始路由请求,并为可重放输入调用生成的按 scope 寻址 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。
+Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为可重放输入调用生成的 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。
 
-Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、字节存储、命令凭证解析器与暂存凭证生命周期。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。
+Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Host 流式路由、存储与按 Agent scope 管理的凭证生命周期 |
+| [`src/index.ts`](src/index.ts) | Host 流式路由、附件服务准入与按 Agent scope 管理的凭证生命周期 |
 | [`src/types.ts`](src/types.ts) | 编码请求、凭证与持久结果类型 |
 | [`src/client/contract.ts`](src/client/contract.ts) | Client 上传、进度与页面钩子类型 |
 | [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 |
@@ -56,7 +56,7 @@ Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Agent scope,每个请求只使用一个已选定载体。没有 scope 的调用和载体不支持的 stream 会在发送请求体前失败。
+**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Session,每个请求只使用一个已选定载体。载体不支持的 stream 会在发送请求体前失败。
 
 -----
 
@@ -88,6 +88,7 @@ Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编
 - **上传不能断点续传**:失败或取消后的重试会从第一个字节开始。
 - **stream 请求体只能使用一次**:转移 `ReadableStream` 会锁定调用方的对象,因此重试必须重新创建 stream。
 - **stream 进度没有总量**:stream API 不携带字节长度,因此调用方只能收到已消费字节数。
+- **浏览器 Worker 必须自包含**:其源代码由函数字符串生成。如果实现需要运行时 import,就必须迁移为由 tsdown 打包的独立 Worker 入口。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 4 - 4
packages/client/file-upload/package.json

@@ -64,8 +64,7 @@
   "dependencies": {
     "zod": "^4.4.3",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/dsh-attachment": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^"
+    "@deepseek-ai/dsh-brand": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -74,11 +73,12 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",
-    "@deepseek-ai/dsh-api-remotes": "workspace:^"
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-attachment": "workspace:^"
   }
 }

+ 8 - 8
packages/client/file-upload/src/client/contract.ts

@@ -1,4 +1,6 @@
-import type { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type { FileUploadValue } from '../types.ts'
 
 /** Browser request body accepted by the background file-upload service. */
 export type FileUploadBody = Blob | ReadableStream<Uint8Array>
@@ -9,14 +11,14 @@ export interface FileUploadProgress {
   readonly total?: number
 }
 
-/** Browser upload service addressed through one Client Agent scope. */
+/** Browser upload service addressed by one Session identity. */
 export interface FileUploadService {
   /** Whether this page has a Host-backed background upload carrier. */
   readonly available: boolean
   /**
-   * Store one file under an Agent scope. Blob and stream bodies use
+   * Store one file for a Session. Blob and stream bodies use
    * the background carrier; exact bytes and fixture fallbacks use Remote.
-   * @param owner - Agent-scoped Client context that owns the staged receipt.
+   * @param sessionId - Session that owns the staged receipt.
    * @param data - browser Blob, exact bytes, or a one-shot byte stream.
    * @param name - optional display name.
    * @param signal - optional cancellation for the active upload.
@@ -24,14 +26,12 @@ export interface FileUploadService {
    * @returns the staged receipt and durable file reference, or a business error.
    */
   upload(
-    owner: Context,
+    sessionId: SessionId,
     data: Blob | Uint8Array | ReadableStream<Uint8Array>,
     name?: string,
     signal?: AbortSignal,
     onProgress?: (progress: FileUploadProgress) => void,
-  ): Promise<import('@deepseek-ai/dsh-typert-protocol').RemoteResult<
-    import('../types.ts').FileUploadValue
-  >>
+  ): Promise<RemoteResult<FileUploadValue>>
 }
 
 export type { ClientFileUploadHooks, FileUploadFetch } from '../types.ts'

+ 5 - 4
packages/client/file-upload/src/client/index.ts

@@ -2,6 +2,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import { FileUploadRuntime } from './runtime.ts'
+import type { FileUploadService } from './contract.ts'
 
 export type {
   ClientFileUploadHooks,
@@ -14,13 +15,13 @@ export type { FileUploadReceiptId, FileUploadValue } from '../types.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
-    /** Agent-scoped browser service for staged file uploads. */
-    fileUpload: import('./contract.ts').FileUploadService
+    /** Session-addressed browser service for staged file uploads. */
+    fileUpload: FileUploadService
   }
 }
 
-/** The upload service resolves Agent identity and the generated Remote fallback. */
-export const inject = ['typert', 'remote']
+/** The upload service uses the generated Remote fallback. */
+export const inject = ['remote']
 
 /**
  * Provide the browser background-upload service.

+ 17 - 25
packages/client/file-upload/src/client/runtime.ts

@@ -3,9 +3,10 @@
 import { Service, type Context } from '@deepseek-ai/cordis'
 import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import type { RemoteResult, TypertContextRegistry } from '@deepseek-ai/dsh-typert-protocol'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { FILE_UPLOAD_PATH } from '../protocol.ts'
-import type { FileUploadValue } from '../types.ts'
+import type { EncodedFileUploadRequest, FileUploadValue } from '../types.ts'
 import type {
   ClientFileUploadHooks,
   FileUploadBody,
@@ -26,21 +27,18 @@ interface FileUploadResponse {
   readonly body: string
 }
 
-interface AgentFileUploadContext extends Context {
+interface FileUploadRemoteContext extends Context {
   readonly remote: {
     readonly fileUploads: {
       upload(
-        request: import('../types.ts').EncodedFileUploadRequest,
+        sessionId: SessionId,
+        request: EncodedFileUploadRequest,
         signal?: AbortSignal,
       ): Promise<RemoteResult<FileUploadValue>>
     }
   }
 }
 
-interface FileUploadTypert {
-  readonly contexts: Pick<TypertContextRegistry, 'getClient'>
-}
-
 interface UploadWorkerStart {
   readonly url: string
   readonly body: FileUploadBody
@@ -187,8 +185,8 @@ export class FileUploadRuntime extends Service implements FileUploadService {
   }
 
   /**
-   * Store one file under an Agent scope.
-   * @param owner - Agent-scoped Client context that owns the staged receipt.
+   * Store one file for a Session.
+   * @param sessionId - Session that owns the staged receipt.
    * @param data - browser Blob, exact bytes, or a one-shot byte stream.
    * @param name - optional display name.
    * @param signal - optional cancellation for the active upload.
@@ -196,13 +194,12 @@ export class FileUploadRuntime extends Service implements FileUploadService {
    * @returns the staged receipt and durable file reference, or a business error.
    */
   async upload(
-    owner: Context,
+    sessionId: SessionId,
     data: Blob | Uint8Array | ReadableStream<Uint8Array>,
     name?: string,
     signal?: AbortSignal,
     onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void,
   ): Promise<RemoteResult<FileUploadValue>> {
-    const sessionId = this.sessionId(owner)
     if (!(data instanceof Uint8Array) && this.available) {
       const query = new URLSearchParams({ sessionId })
       if (name !== undefined) query.set('name', name)
@@ -222,19 +219,14 @@ export class FileUploadRuntime extends Service implements FileUploadService {
       throw new Error('stream file upload requires a background carrier')
     }
     const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer())
-    return (owner as AgentFileUploadContext).remote.fileUploads.upload({
-      data: bytesToBase64(bytes),
-      ...(name === undefined ? {} : { name }),
-    }, signal)
-  }
-
-  private sessionId(owner: Context): string {
-    const typert = this.ctx.get('typert') as FileUploadTypert | undefined
-    const sessionId = typert?.contexts.getClient('agent')?.identity(owner)
-    if (typeof sessionId !== 'string' || sessionId === '') {
-      throw new Error('fileUpload.upload requires an Agent-scoped context')
-    }
-    return sessionId
+    return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload(
+      sessionId,
+      {
+        data: bytesToBase64(bytes),
+        ...(name === undefined ? {} : { name }),
+      },
+      signal,
+    )
   }
 }
 

+ 5 - 6
packages/client/file-upload/src/index.ts

@@ -3,13 +3,12 @@
 import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import { admitEncodedFile, isAttachmentError } from '@deepseek-ai/dsh-attachment'
 import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import { brandString } from '@deepseek-ai/dsh-brand'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands'
 import { scopeOf } from '@deepseek-ai/dsh-scope'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import { Remote, RemoteError, TypertRemoteService, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import { FILE_UPLOAD_PATH } from './protocol.ts'
 import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts'
@@ -114,7 +113,7 @@ export class FileUploads extends TypertRemoteService {
   @Remote('upload')
   upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise<FileUploadValue> {
     signal.throwIfAborted()
-    return this.commit(agent, async () => admitEncodedFile(this.ctx.attachments, {
+    return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({
       data: request.data,
       ...(request.name === undefined ? {} : { name: request.name }),
     }))
@@ -195,7 +194,7 @@ export class FileUploads extends TypertRemoteService {
     try {
       file = await save()
     } catch (error) {
-      if (isAttachmentError(error)) {
+      if (this.ctx.attachments.isAttachmentError(error)) {
         throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never)
       }
       throw new RemoteError(
@@ -288,7 +287,7 @@ export async function handleFileUploadHttp(service: FileUploads, request: Reques
     result = {
       ok: true,
       value: await service.uploadStream({
-        sessionId: SessionId(sessionId),
+        sessionId: brandString<SessionId>(sessionId),
         data: requestBodyChunks(request.body),
         signal: request.signal,
         ...(name === undefined ? {} : { name }),

+ 22 - 38
packages/client/file-upload/tests/file-upload.client.spec.ts

@@ -1,4 +1,5 @@
 import { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { apply } from '../src/client/index.ts'
 import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts'
@@ -378,22 +379,13 @@ describe('file upload service', () => {
   })
 })
 
-describe('Agent-scoped file upload', () => {
+describe('Session-addressed file upload', () => {
+  const SESSION_ID = 's1' as SessionId
+
   async function scopedService(options: {
-    readonly sessionId?: string
     readonly remote?: ReturnType<typeof vi.fn>
   } = {}) {
     const ctx = new Context()
-    ctx.provide('sessions', {
-      scopeOf: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined,
-    } as never)
-    ctx.provide('typert', {
-      contexts: {
-        getClient: (kind: string) => kind === 'agent'
-          ? { identity: (candidate: Context) => Reflect.get(candidate, 'fixtureSessionId') as string | undefined }
-          : undefined,
-      },
-    } as never)
     const remote = options.remote ?? vi.fn(() => Promise.resolve({
       ok: true,
       value: {
@@ -404,10 +396,7 @@ describe('Agent-scoped file upload', () => {
     ctx.provide('remote', { fileUploads: { upload: remote } } as never)
     const fiber = ctx.plugin(FileUploadRuntime)
     await fiber
-    const owner = options.sessionId === undefined
-      ? ctx
-      : ctx.extend({ fixtureSessionId: options.sessionId })
-    return { ctx, fiber, owner, remote, service: ctx.fileUpload }
+    return { ctx, fiber, remote, service: ctx.fileUpload }
   }
 
   it('assembles the scoped streaming request and parses progress and receipt fields', async () => {
@@ -425,11 +414,11 @@ describe('Agent-scoped file upload', () => {
       }), { status: 200 }))
     })
     ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
-    const { fiber, owner, service } = await scopedService({ sessionId: 's1' })
+    const { fiber, service } = await scopedService()
     const signal = new AbortController().signal
     const file = new Blob(['data'])
 
-    await expect(service.upload(owner, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
+    await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
       ok: true,
       value: {
         receiptId: 'receipt-1',
@@ -448,7 +437,7 @@ describe('Agent-scoped file upload', () => {
     await fiber.dispose()
   })
 
-  it('uses the scoped Remote fallback for exact bytes and fixture Blob bodies', async () => {
+  it('uses the direct Remote fallback for exact bytes and fixture Blob bodies', async () => {
     vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
     const remote = vi.fn(() => Promise.resolve({
       ok: true,
@@ -457,28 +446,23 @@ describe('Agent-scoped file upload', () => {
         file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 },
       },
     }))
-    const { fiber, owner, service } = await scopedService({ sessionId: 's1', remote })
-    await expect(service.upload(owner, Uint8Array.of(0, 0, 0), 'bytes.bin'))
+    const { fiber, service } = await scopedService({ remote })
+    await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin'))
       .resolves.toMatchObject({ ok: true })
-    await expect(service.upload(owner, new Blob([Uint8Array.of(1)])))
+    await expect(service.upload(SESSION_ID, new Blob([Uint8Array.of(1)])))
       .resolves.toMatchObject({ ok: true })
     expect(remote.mock.calls).toEqual([
-      [{ data: 'AAAA', name: 'bytes.bin' }, undefined],
-      [{ data: 'AQ==' }, undefined],
+      [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined],
+      [SESSION_ID, { data: 'AQ==' }, undefined],
     ])
     await fiber.dispose()
   })
 
-  it('rejects an unscoped call, an unavailable stream, and malformed background results', async () => {
+  it('rejects an unavailable stream and malformed background results', async () => {
     vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' })
-    const unscoped = await scopedService()
-    await expect(unscoped.service.upload(unscoped.owner, Uint8Array.of(1)))
-      .rejects.toThrow('fileUpload.upload requires an Agent-scoped context')
-    await unscoped.fiber.dispose()
-
-    const fixture = await scopedService({ sessionId: 's1' })
+    const fixture = await scopedService()
     const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
-    await expect(fixture.service.upload(fixture.owner, stream))
+    await expect(fixture.service.upload(SESSION_ID, stream))
       .rejects.toThrow('stream file upload requires a background carrier')
     await fixture.fiber.dispose()
 
@@ -486,8 +470,8 @@ describe('Agent-scoped file upload', () => {
     ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
       fetch: () => Promise.resolve(new Response(null, { status: 413 })),
     }
-    const rejected = await scopedService({ sessionId: 's1' })
-    await expect(rejected.service.upload(rejected.owner, new Blob()))
+    const rejected = await scopedService()
+    await expect(rejected.service.upload(SESSION_ID, new Blob()))
       .rejects.toThrow('file upload transport failed with HTTP 413')
     await rejected.fiber.dispose()
 
@@ -505,8 +489,8 @@ describe('Agent-scoped file upload', () => {
       ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
         fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
       }
-      const malformed = await scopedService({ sessionId: 's1' })
-      await expect(malformed.service.upload(malformed.owner, new Blob()))
+      const malformed = await scopedService()
+      await expect(malformed.service.upload(SESSION_ID, new Blob()))
         .rejects.toThrow(/file upload transport returned an invalid/)
       await malformed.fiber.dispose()
     }
@@ -517,8 +501,8 @@ describe('Agent-scoped file upload', () => {
         error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
       }), { status: 200 })),
     }
-    const failed = await scopedService({ sessionId: 's1' })
-    await expect(failed.service.upload(failed.owner, new Blob())).resolves.toMatchObject({
+    const failed = await scopedService()
+    await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({
       ok: false,
       error: {
         code: 'session/attachment-invalid',

+ 1 - 0
packages/client/file-upload/tsconfig.client.json

@@ -17,6 +17,7 @@
     { "path": "../../util/brand" },
     { "path": "../../util/crypto" },
     { "path": "../../attachment/attachment" },
+    { "path": "../../core/session" },
     { "path": "../../typert/protocol" }
   ]
 }

+ 1 - 3
packages/client/ui-conversation/src/client/service.ts

@@ -363,10 +363,8 @@ export class ConversationController extends Service implements IConversation {
       try {
         if (controller.signal.aborted
           || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return
-        const owner = this.requireSessions().scope(sessionId)
-        if (owner === undefined) throw new Error(`conversation: session "${sessionId}" resolved no Agent scope`)
         const result = await this.ctx.fileUpload.upload(
-          owner,
+          sessionId,
           attachment.file,
           attachment.file.name === '' ? undefined : attachment.file.name,
           controller.signal,

+ 3 - 5
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -1,5 +1,4 @@
 // @vitest-environment jsdom
-import type { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -43,10 +42,9 @@ async function bench() {
   }))
   const uploads = new Map<SessionId, (...args: unknown[]) => Promise<unknown>>([[ROOT, rootUpload]])
   runtime.fileUpload.available = true
-  runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => {
-    const id = runtime.sessions.scopeOf(owner)
-    const upload = id === undefined ? undefined : uploads.get(id)
-    if (upload === undefined) throw new Error('test file upload has no Agent-scope fixture')
+  runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => {
+    const upload = uploads.get(sessionId)
+    if (upload === undefined) throw new Error('test file upload has no Session fixture')
     return upload(...args)
   }
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)

+ 5 - 4
packages/client/ui-conversation/tests/service-orchestration.client.spec.ts

@@ -9,6 +9,7 @@ import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-cl
 import type {
   BeginSubmissionInput, PendingSubmissionRetirement, QueuedMessage,
 } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
 import { InputHub } from '../src/client/input/hub.ts'
 import { ConversationController } from '../src/client/service.ts'
@@ -17,11 +18,11 @@ import { zh } from '../src/client/locales.ts'
 async function bench(maxConcurrentFileUploads = 2) {
   const runtime = await SlotTestRuntime.create()
   runtime.fileUpload.available = true
-  runtime.fileUpload.upload = (owner: Context, ...args: unknown[]) => {
-    const session = runtime.sessions.sessionOf(owner) as {
+  runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => {
+    const session = runtime.sessions.behavior(sessionId) as {
       uploadFile?: (...input: unknown[]) => Promise<unknown>
-    } | undefined
-    if (session?.uploadFile === undefined) throw new Error('test file upload has no Session override')
+    }
+    if (session.uploadFile === undefined) throw new Error('test file upload has no Session override')
     return session.uploadFile(...args)
   }
   const prompt = vi.fn((

+ 25 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -489,6 +489,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'admitted prompt parts in the same order as `content`.',
         throws: ['AttachmentError when the image batch is refused.'],
       },
+      {
+        signature: 'admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef>',
+        description: 'Decode and durably commit one canonical base64 file upload.',
+        parameters: [{ name: 'input', description: 'canonical base64 bytes and optional display name.' }],
+        returns: 'the durable content-addressed file reference.',
+        throws: ['AttachmentError when the encoding or storage operation is refused.'],
+      },
+      {
+        signature: 'isAttachmentError(error: unknown): error is AttachmentError',
+        description: 'Identify a failure emitted by this attachment capability by its stable code.',
+        parameters: [{ name: 'error', description: 'value caught from an attachment operation.' }],
+        returns: 'whether the value is an attachment failure.',
+      },
       {
         signature: 'abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>',
         description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.',
@@ -3616,6 +3629,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'AttachmentAdmissionPart',
     declaration: 'export type AttachmentAdmissionPart = PromptContentPart | {\n    readonly type: \'file\';\n    readonly attachment: FileAttachmentRef;\n};',
   },
+  {
+    name: 'AttachmentError',
+    declaration: 'export class AttachmentError extends Error {\n    readonly code: AttachmentErrorCode;\n    constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}',
+  },
+  {
+    name: 'AttachmentErrorCode',
+    declaration: 'export type AttachmentErrorCode = typeof ATTACHMENT_ERROR_CODES[number];',
+  },
   {
     name: 'AttachmentId',
     declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;',
@@ -4064,6 +4085,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'EditGoalRequest',
     declaration: 'export interface EditGoalRequest {\n    readonly objective?: string;\n    readonly maxGoalRounds?: number;\n}',
   },
+  {
+    name: 'EncodedFileAttachment',
+    declaration: 'export interface EncodedFileAttachment {\n    data: string;\n    name?: string;\n}',
+  },
   {
     name: 'EncodedFileUploadRequest',
     declaration: 'export interface EncodedFileUploadRequest {\n    readonly data: string;\n    readonly name?: string;\n}',

+ 2 - 1
packages/test-support/client-runtime/src/index.ts

@@ -28,6 +28,7 @@ import { createSlotRenderer as createRenderer } from '@deepseek-ai/dsh-client-ui
 import {
   apply as applyUiSession, inject as uiSessionInject,
 } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
   ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer,
   SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike,
@@ -115,7 +116,7 @@ export interface TestFileUpload {
   /** Availability reported to the feature under test. */
   available: boolean
   /** Test-supplied upload behavior; the default rejects every call. */
-  upload: (owner: Context, ...args: unknown[]) => Promise<unknown>
+  upload: (sessionId: SessionId, ...args: unknown[]) => Promise<unknown>
 }
 
 /**

+ 1 - 1
packages/test-support/client-runtime/tests/helpers.client.spec.tsx

@@ -41,7 +41,7 @@ describe('fixture helpers', () => {
   it('rejects an upload until a suite replaces the default stub', async () => {
     const runtime = await SlotTestRuntime.create()
     expect(runtime.fileUpload.available).toBe(false)
-    await expect(runtime.fileUpload.upload(runtime.ctx)).rejects.toThrow('file upload is not stubbed')
+    await expect(runtime.fileUpload.upload('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed')
     await runtime.dispose()
   })
 

+ 7 - 7
pnpm-lock.yaml

@@ -1746,12 +1746,9 @@ importers:
 
   packages/client/file-upload:
     dependencies:
-      '@deepseek-ai/dsh-attachment':
-        specifier: workspace:^
-        version: link:../../attachment/attachment
-      '@deepseek-ai/dsh-session':
+      '@deepseek-ai/dsh-brand':
         specifier: workspace:^
-        version: link:../../core/session
+        version: link:../../util/brand
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         version: link:../../typert/protocol
@@ -1768,9 +1765,9 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
-      '@deepseek-ai/dsh-brand':
+      '@deepseek-ai/dsh-attachment':
         specifier: workspace:^
-        version: link:../../util/brand
+        version: link:../../attachment/attachment
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../connection
@@ -1780,6 +1777,9 @@ importers:
       '@deepseek-ai/dsh-scope':
         specifier: workspace:^
         version: link:../../core/scope
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
       '@deepseek-ai/dsh-util-crypto':
         specifier: workspace:^
         version: link:../../util/crypto

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

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

+ 0 - 2
scripts/package-dependency-policy.ts

@@ -41,11 +41,9 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [
  * Runtime exports whose values remain valid when npm installs another package copy.
  */
 const SAFE_HOST_DEPENDENCY_EXPORTS = {
-  '@deepseek-ai/dsh-attachment': ['admitEncodedFile', 'isAttachmentError'],
   '@deepseek-ai/dsh-credentials': ['credentialKey'],
   '@deepseek-ai/dsh-deque': ['Deque'],
   '@deepseek-ai/dsh-llm': ['BlockAssembler', 'callConfigEquals', 'expandAssistantStream'],
-  '@deepseek-ai/dsh-session': ['SessionId'],
   '@deepseek-ai/dsh-session-format': ['sessionFormatLogFilename'],
   '@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'],
   '@deepseek-ai/schemastery': ['default'],

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä