Explorar el Código

feat(conversation): stream files through submission lifecycle

creatixchu hace 1 semana
padre
commit
bafa6ae11d
Se han modificado 27 ficheros con 559 adiciones y 145 borrados
  1. 25 7
      packages/api/session-controller/src/client/contract/session.ts
  2. 8 2
      packages/api/session-controller/src/client/contract/snapshot.ts
  3. 5 1
      packages/api/session-controller/src/client/index.ts
  4. 3 0
      packages/api/session-controller/src/client/sessions/manager.ts
  5. 3 3
      packages/api/session-controller/src/client/sessions/queue-mirror.ts
  6. 2 2
      packages/api/session-controller/src/client/sessions/remotes.ts
  7. 3 0
      packages/api/session-controller/src/client/sessions/service.ts
  8. 120 13
      packages/api/session-controller/src/client/sessions/session.ts
  9. 1 0
      packages/api/session-controller/tsconfig.client.json
  10. 184 0
      packages/client/connection/src/client/background-upload.ts
  11. 8 8
      packages/client/connection/src/client/fixture.ts
  12. 20 8
      packages/client/connection/src/client/index.ts
  13. 48 36
      packages/client/connection/src/http-bridge.ts
  14. 1 0
      packages/client/connection/src/index.ts
  15. 10 3
      packages/client/connection/src/rpc-host.ts
  16. 13 1
      packages/client/connection/src/rpc.ts
  17. 1 0
      packages/client/connection/tsconfig.client.json
  18. 2 2
      packages/client/ui-commands/src/client/locales.ts
  19. 18 18
      packages/client/ui-commands/src/client/service.ts
  20. 1 1
      packages/client/ui-input-trigger/src/client/index.ts
  21. 3 3
      packages/client/ui-input-trigger/src/types.ts
  22. 4 3
      packages/experimental/webworker-runtime/src/client/client.ts
  23. 3 3
      packages/experimental/webworker-runtime/src/transport/frames.ts
  24. 10 1
      packages/experimental/webworker-runtime/src/transport/synthetic-http.ts
  25. 30 6
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  26. 25 24
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  27. 8 0
      packages/test-support/client-runtime/src/sessions.ts

+ 25 - 7
packages/api/session-controller/src/client/contract/session.ts

@@ -7,22 +7,25 @@
  * must stub); implementation-internal entry points (history staging, wire-frame
  * dispatch) stay on the class, invisible out here.
  */
-import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
-import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
-import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
+import type { FileUploadReceiptId, PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
+import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts'
 
 /**
  * Why a local submission echo left the snapshot: `observed` when its durable
  * `user/message` event or host queue occurrence arrived (with the admitted
- * image references in prompt order), `failed` when the prompt was rejected,
+ * attachment references in prompt order), `failed` when the prompt was rejected,
  * threw, or was aborted before acceptance.
  */
 export type PendingSubmissionRetirement =
-  | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] }
+  | {
+    readonly reason: 'observed'
+    readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]
+  }
   | { readonly reason: 'failed' }
 
 /** Input registering one local submission echo ahead of its prompt call. */
@@ -31,8 +34,8 @@ export interface BeginSubmissionInput {
   readonly mode: 'queue' | 'steer'
   /** Prompt text exactly as the upcoming prompt will send it. */
   readonly text: string
-  /** Ordered image previews matching the upcoming prompt's image parts. */
-  readonly images: readonly PendingSubmissionImage[]
+  /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */
+  readonly attachments: readonly PendingSubmissionAttachment[]
   /** Settlement callback fired exactly once when the echo retires. */
   readonly onRetire?: (retirement: PendingSubmissionRetirement) => void
 }
@@ -86,6 +89,21 @@ export interface ISession {
     signal?: AbortSignal,
     requestId?: SessionRequestId,
   ): Promise<RemoteResult<{ accepted: true }>>
+  /**
+   * Persist one browser file verbatim and stage it for a later prompt on this
+   * session. The returned opaque receipt is what a prompt file part cites.
+   * @param data - browser Blob or exact file bytes.
+   * @param name - optional display name; the host sanitizes the stored leaf name.
+   * @param signal - optional cancellation for the active upload.
+   * @param onProgress - optional byte-progress observer for background Blob uploads.
+   * @returns the staged-upload receipt and durable file reference, or the business error.
+   */
+  uploadFile(
+    data: Blob | Uint8Array,
+    name?: string,
+    signal?: AbortSignal,
+    onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void,
+  ): Promise<RemoteResult<{ receiptId: FileUploadReceiptId; file: FileAttachmentRef }>>
   /**
    * Resolve one durable image referenced by this session.
    * @param attachmentId - opaque id found in the folded session log.

+ 8 - 2
packages/api/session-controller/src/client/contract/snapshot.ts

@@ -1,5 +1,6 @@
 /** Session-owned observable state excluding Conversation target data. */
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
@@ -30,6 +31,11 @@ export interface PendingSubmissionImage {
   readonly height?: number
 }
 
+/** One attachment displayed by a local submission echo, in prompt order. */
+export type PendingSubmissionAttachment =
+  | ({ readonly type: 'image' } & PendingSubmissionImage)
+  | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
+
 /** Client surface selected when a local submission begins. */
 export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
 
@@ -48,8 +54,8 @@ export interface PendingSubmission {
   readonly time: number
   /** Prompt text exactly as it will be sent (one text block). */
   readonly text: string
-  /** Ordered image previews matching the prompt's image parts. */
-  readonly images: readonly PendingSubmissionImage[]
+  /** Ordered image previews and durable file metadata matching the prompt attachments. */
+  readonly attachments: readonly PendingSubmissionAttachment[]
 }
 
 /** History-open lifecycle of a Session event window. */

+ 5 - 1
packages/api/session-controller/src/client/index.ts

@@ -2,6 +2,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-agent/types'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import { createSessionControlStream } from './transport.ts'
 import { ClientSessions } from './sessions/service.ts'
 import type { SessionRemotes } from './sessions/remotes.ts'
@@ -59,6 +60,7 @@ export type {
 export type {
   OpenState,
   PendingSubmission,
+  PendingSubmissionAttachment,
   PendingSubmissionImage,
   PendingSubmissionPlacement,
   PromptError,
@@ -75,6 +77,7 @@ declare module '@deepseek-ai/cordis' {
 
 /** Required Remote and Context projection services. */
 export const inject = [
+  'connection',
   'typert',
   'remote',
   'remote.commands',
@@ -87,8 +90,9 @@ export const inject = [
  * @param ctx - Client Cordis context.
  */
 export function apply(ctx: Context): void {
+  const connection = ctx.get('connection') as ConnectionHandle
   const remotes = ctx.remote as unknown as SessionRemotes
-  const sessions = new ClientSessions(ctx, remotes)
+  const sessions = new ClientSessions(ctx, remotes, connection.backgroundUploads)
   ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
   ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
   ctx.remote.$on('api-session/status', (sessionId, running) => {

+ 3 - 0
packages/api/session-controller/src/client/sessions/manager.ts

@@ -25,6 +25,7 @@ import { Notifier } from './notifier.ts'
 import { ProjectionValueStore } from './projection-store.ts'
 import { Session } from './session.ts'
 import type { SessionRemotes } from './remotes.ts'
+import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client'
 
 /**
  * List arrival lifecycle, orthogonal to the pull-activity `state` axis:
@@ -149,6 +150,7 @@ export class SessionManager {
     private readonly remote: SessionRemotes,
     restoredSelection?: SessionId,
     restoredAddress?: SubagentAddress,
+    private readonly backgroundUploads?: BackgroundUploadTransport,
   ) {
     this.selected = restoredSelection
     if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
@@ -328,6 +330,7 @@ export class SessionManager {
         this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
       },
       projections: this.projectionStore(sessionId),
+      ...(this.backgroundUploads === undefined ? {} : { backgroundUploads: this.backgroundUploads }),
     })
   }
 

+ 3 - 3
packages/api/session-controller/src/client/sessions/queue-mirror.ts

@@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts'
 
 const QUEUE_PREVIEW_CHARS = 200
 
-// Image blocks are excluded: queue presentation renders them as thumbnails
-// from `content`, so the text preview covers only what has no visual form.
+// Attachment blocks are excluded: queue presentation renders them from
+// `content`, so the text preview covers only what has no visual form.
 function previewOf(content: readonly ContentBlock[]): string {
   const flat = content
-    .filter(block => block.type !== 'image')
+    .filter(block => block.type !== 'image' && block.type !== 'file')
     .map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
     .join(' ').replace(/\s+/g, ' ').trim()
   const chars = Array.from(flat)

+ 2 - 2
packages/api/session-controller/src/client/sessions/remotes.ts

@@ -5,8 +5,8 @@
  * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes
  */
 
-import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
   SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
@@ -19,7 +19,7 @@ export interface SessionCommandsRemote {
   execute(
     agentId: SessionId,
     line: string,
-    images: readonly EncodedImageAttachment[],
+    attachments: readonly CommandSubmitAttachment[],
     signal?: AbortSignal,
   ): Promise<RemoteResult<object | undefined>>
 }

+ 3 - 0
packages/api/session-controller/src/client/sessions/service.ts

@@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts'
 import { createScope, scopeOf as scopeTagOf } from '../scope.ts'
 import { SessionManager } from './manager.ts'
 import type { SessionRemotes } from './remotes.ts'
+import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client'
 import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
 import type { Session } from './session.ts'
 
@@ -221,6 +222,7 @@ export class ClientSessions implements ISessions {
   constructor(
     private readonly rootCtx: Context,
     remote: SessionRemotes,
+    backgroundUploads?: BackgroundUploadTransport,
   ) {
     this.selection = createSnapshotStore<SessionSelection>(
       {},
@@ -230,6 +232,7 @@ export class ClientSessions implements ISessions {
       remote,
       restored.sessionId,
       restored.subagentAddress,
+      backgroundUploads,
     )
     this.list = createSnapshotStore<SessionListState>({
       ids: [], byId: {}, current: undefined, phase: 'pending',

+ 120 - 13
packages/api/session-controller/src/client/sessions/session.ts

@@ -1,8 +1,11 @@
 // Sessions remain resident after creation so their open Remote sources keep running off-screen.
 
 import type { Context } from '@deepseek-ai/cordis'
-import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
-import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto'
+import type {
+  BackgroundUploadProgress, BackgroundUploadTransport,
+} from '@deepseek-ai/dsh-client-connection/client'
+import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -15,7 +18,9 @@ import type {
   SessionControlFrame,
   SessionQueuedItem,
   SessionRequestId,
+  SessionUploadFileValue,
 } from '../../types.ts'
+import { SESSION_FILE_UPLOAD_PATH } from '../../file-upload-path.ts'
 import type {
   BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
 } from '../contract/session.ts'
@@ -28,6 +33,7 @@ import type {
 } from '../contract/events.ts'
 import { Notifier } from './notifier.ts'
 import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionRemotes } from './remotes.ts'
 import { ProjectionValueStore } from './projection-store.ts'
@@ -62,6 +68,8 @@ export interface SessionOptions {
    * private store (bare object-layer construction).
    */
   projections?: ProjectionValueStore
+  /** Physical large-body carrier supplied by the active browser Connection. */
+  backgroundUploads?: BackgroundUploadTransport
 }
 
 /**
@@ -196,7 +204,7 @@ export class Session implements SessionFace {
         : 'transcript',
       time: Date.now(),
       text: input.text,
-      images: input.images,
+      attachments: input.attachments,
     }]
     this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false })
     // The blank → engaging edge flips here, ahead of prompt(): the composer
@@ -208,7 +216,7 @@ export class Session implements SessionFace {
 
   /**
    * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
-   * @param content - text plus browser-owned temporary image uploads.
+   * @param content - text, browser-owned temporary image uploads, and staged-file receipts.
    * @param mode - queue appends after the current turn; steer interrupts it.
    * @param signal - optional caller cancellation for the complete admission round-trip.
    * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
@@ -238,13 +246,25 @@ export class Session implements SessionFace {
         content,
         clientTimeZone,
       }, signal)
+    } else if (content.some(part => part.type === 'file')) {
+      result = {
+        ok: false,
+        error: new RemoteError(
+          'subagent/attachment-invalid',
+          'subagent continuation does not accept files',
+          { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
+        ),
+      }
     } else {
+      // The preceding branch rejects file parts before the narrower subagent
+      // wire type is used; this array is not filtered or reordered.
+      const routedContent = content as Exclude<PromptContentPart, { readonly type: 'file' }>[]
       const routed = await this.remote.subagents.prompt({
         requestId: randomUUID() as SessionRequestId,
         parentSessionId: this.address.parentSessionId,
         childSessionId: this.address.childSessionId,
         mode: 'continuable',
-        content,
+        content: routedContent,
         clientTimeZone: resolvedClientTimeZone(),
       }, signal)
       result = routed.ok ? { ok: true, value: { accepted: true } } : routed
@@ -271,6 +291,52 @@ export class Session implements SessionFace {
     return result
   }
 
+  /**
+   * Persist one browser file verbatim and stage it for a later prompt on this
+   * session (ordinary sessions only; subagent conversations refuse).
+   * @param data - exact file bytes.
+   * @param name - optional display name; the host sanitizes the stored leaf name.
+   * @returns the staged-upload receipt and durable file reference, or the business error.
+   */
+  async uploadFile(
+    data: Blob | Uint8Array,
+    name?: string,
+    signal?: AbortSignal,
+    onProgress?: (progress: BackgroundUploadProgress) => void,
+  ): Promise<RemoteResult<SessionUploadFileValue>> {
+    if (this.address !== undefined) {
+      return {
+        ok: false,
+        error: new RemoteError(
+          'subagent/attachment-invalid',
+          'subagent conversations do not accept file uploads',
+          { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
+        ),
+      }
+    }
+    if (data instanceof Blob && this.options.backgroundUploads !== undefined) {
+      const query = new URLSearchParams({ sessionId: this.sessionId })
+      if (name !== undefined) query.set('name', name)
+      const response = await this.options.backgroundUploads.post({
+        path: `${SESSION_FILE_UPLOAD_PATH}?${query.toString()}`,
+        body: data,
+        headers: { 'content-type': 'application/octet-stream' },
+        ...(signal === undefined ? {} : { signal }),
+        ...(onProgress === undefined ? {} : { onProgress }),
+      })
+      if (response.status !== 200) {
+        throw new Error(`file upload transport failed with HTTP ${String(response.status)}`)
+      }
+      return parseFileUploadResult(response.body)
+    }
+    const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer())
+    return this.remote.session.uploadFile({
+      sessionId: this.sessionId,
+      data: bytesToBase64(bytes),
+      ...(name === undefined ? {} : { name }),
+    }, signal)
+  }
+
   /**
    * Resolve one image referenced by this session into browser-consumable bytes.
    * @param attachmentId - opaque id found in the folded session log.
@@ -657,7 +723,7 @@ export class Session implements SessionFace {
     const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined
     const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined
     if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return
-    this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content))
+    this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content))
   }
 
   /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
@@ -665,7 +731,7 @@ export class Session implements SessionFace {
     if (this.submissionSettlements.size === 0) return
     for (const item of items) {
       if (item.rpcId !== undefined) {
-        this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content))
+        this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content))
       }
     }
   }
@@ -678,7 +744,7 @@ export class Session implements SessionFace {
    */
   private scheduleObservedRetirement(
     requestId: SessionRequestId,
-    attachments: readonly ImageAttachmentRef[],
+    attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[],
   ): void {
     const settlement = this.submissionSettlements.get(requestId)
     if (settlement === undefined || settlement.retiring) return
@@ -750,21 +816,62 @@ export class Session implements SessionFace {
   }
 }
 
+function parseFileUploadResult(body: string): RemoteResult<SessionUploadFileValue> {
+  const value = JSON.parse(body) as unknown
+  if (!isRecord(value) || typeof value.ok !== 'boolean') {
+    throw new TypeError('file upload transport returned an invalid result')
+  }
+  if (!value.ok) {
+    const error = value.error
+    if (!isRecord(error) || typeof error.code !== 'string'
+      || typeof error.message !== 'string' || !isRecord(error.details)) {
+      throw new TypeError('file upload transport returned an invalid failure')
+    }
+    return {
+      ok: false,
+      error: new RemoteError(error.code as never, error.message, error.details as never),
+    }
+  }
+  const result = value.value
+  const file = isRecord(result) ? result.file : undefined
+  if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file)
+    || typeof file.attachmentId !== 'string' || typeof file.name !== 'string'
+    || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) {
+    throw new TypeError('file upload transport returned an invalid receipt')
+  }
+  return {
+    ok: true,
+    value: {
+      receiptId: result.receiptId as SessionUploadFileValue['receiptId'],
+      file: {
+        attachmentId: file.attachmentId as SessionUploadFileValue['file']['attachmentId'],
+        name: file.name,
+        bytes: file.bytes,
+      },
+    },
+  }
+}
+
+function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
 /** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */
 function scheduleFrame(fn: () => void): void {
   if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() })
   else setTimeout(fn, 0)
 }
 
-/** Image attachment references in one structurally-read content block list, in block order. */
-function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
+/** Attachment references in one structurally-read content block list, in block order. */
+function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] {
   if (!Array.isArray(content)) return []
-  const refs: ImageAttachmentRef[] = []
+  const refs: Array<ImageAttachmentRef | FileAttachmentRef> = []
   for (const block of content) {
     if (typeof block !== 'object' || block === null) continue
     const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }
-    if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
-      refs.push(candidate.attachment as ImageAttachmentRef)
+    if ((candidate.type === 'image' || candidate.type === 'file')
+      && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
+      refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef)
     }
   }
   return refs

+ 1 - 0
packages/api/session-controller/tsconfig.client.json

@@ -7,6 +7,7 @@
   },
   "include": [
     "src/client/**/*.ts",
+    "src/file-upload-path.ts",
     "src/types.ts",
     "src/remote-events.ts"
   ],

+ 184 - 0
packages/client/connection/src/client/background-upload.ts

@@ -0,0 +1,184 @@
+/** Background browser upload transport for large opaque request bodies. */
+
+import type { RpcFetch } from './rpc.ts'
+
+/** Monotone byte progress reported by a browser upload carrier. */
+export interface BackgroundUploadProgress {
+  readonly loaded: number
+  readonly total?: number
+}
+
+/** One background upload request. */
+export interface BackgroundUploadRequest {
+  readonly path: string
+  readonly body: Blob
+  readonly headers?: Readonly<Record<string, string>>
+  readonly signal?: AbortSignal
+  readonly onProgress?: (progress: BackgroundUploadProgress) => void
+}
+
+/** Small response returned after the background carrier has sent the body. */
+export interface BackgroundUploadResponse {
+  readonly status: number
+  readonly body: string
+}
+
+/** Browser carrier that keeps file reads and network submission off the page thread. */
+export interface BackgroundUploadTransport {
+  /**
+   * Post one Blob without materializing its bytes on the page thread.
+   * @param request - target, body, cancellation, and progress observer.
+   * @returns the response status and text body.
+   */
+  post(request: BackgroundUploadRequest): Promise<BackgroundUploadResponse>
+}
+
+interface UploadWorkerStart {
+  readonly url: string
+  readonly body: Blob
+  readonly headers: Readonly<Record<string, string>>
+}
+
+type UploadWorkerOutput =
+  | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number }
+  | { readonly kind: 'complete'; readonly status: number; readonly body: string }
+  | { readonly kind: 'error'; readonly message: string }
+
+interface UploadWorkerScope {
+  onmessage: ((event: MessageEvent<UploadWorkerStart>) => void) | null
+  postMessage(message: UploadWorkerOutput): void
+}
+
+interface UploadXhr {
+  readonly upload: { onprogress: ((event: ProgressEvent) => void) | null }
+  status: number
+  responseText: string
+  withCredentials: boolean
+  onload: ((event: ProgressEvent) => void) | null
+  onerror: ((event: ProgressEvent) => void) | null
+  open(method: string, url: string): void
+  setRequestHeader(name: string, value: string): void
+  send(body: Blob): void
+}
+
+/**
+ * Self-contained Worker body; its string form becomes the Blob Worker source.
+ * @param scope - Worker global used for request and progress messages.
+ * @param createXhr - XMLHttpRequest factory; injectable for unit coverage.
+ */
+export function backgroundUploadWorker(
+  scope: UploadWorkerScope = self,
+  createXhr: () => UploadXhr = () => new XMLHttpRequest(),
+): void {
+  scope.onmessage = (event: MessageEvent<UploadWorkerStart>) => {
+    const request = event.data
+    const xhr = createXhr()
+    xhr.open('POST', request.url)
+    xhr.withCredentials = true
+    for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value)
+    xhr.upload.onprogress = (progress) => {
+      scope.postMessage({
+        kind: 'progress',
+        loaded: progress.loaded,
+        ...(progress.lengthComputable ? { total: progress.total } : {}),
+      } satisfies UploadWorkerOutput)
+    }
+    xhr.onload = () => {
+      scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput)
+    }
+    xhr.onerror = () => {
+      scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput)
+    }
+    xhr.send(request.body)
+  }
+}
+
+/**
+ * Create a background body carrier. A custom fetch already targets a Host
+ * Worker, while the served Web path creates a dedicated upload Worker.
+ * @param customFetch - worker-hosted transport hook, when present.
+ * @returns the selected background carrier.
+ */
+export function createBackgroundUploadTransport(customFetch?: RpcFetch): BackgroundUploadTransport {
+  return customFetch === undefined ? workerTransport() : customTransport(customFetch)
+}
+
+function customTransport(customFetch: RpcFetch): BackgroundUploadTransport {
+  return {
+    async post(request) {
+      const response = await customFetch(resolveUrl(request.path), {
+        method: 'POST',
+        ...(request.headers === undefined ? {} : { headers: request.headers }),
+        body: request.body,
+        ...(request.signal === undefined ? {} : { signal: request.signal }),
+      })
+      return { status: response.status, body: await response.text() }
+    },
+  }
+}
+
+function workerTransport(): BackgroundUploadTransport {
+  return {
+    post(request) {
+      if (typeof Worker !== 'function') {
+        return Promise.reject(new Error('background upload requires Web Worker support'))
+      }
+      const workerUrl = URL.createObjectURL(new Blob([
+        `(${backgroundUploadWorker.toString()})()`,
+      ], { type: 'text/javascript' }))
+      const worker = new Worker(workerUrl, { name: 'dsh-file-upload' })
+      URL.revokeObjectURL(workerUrl)
+      return new Promise((resolve, reject) => {
+        let settled = false
+        const abort = (): void => {
+          settled = true
+          worker.terminate()
+          request.signal?.removeEventListener('abort', abort)
+          reject(new DOMException('The operation was aborted.', 'AbortError'))
+        }
+        const finish = (settle: () => void): void => {
+          if (settled) return
+          settled = true
+          request.signal?.removeEventListener('abort', abort)
+          worker.terminate()
+          settle()
+        }
+        worker.onmessage = (event: MessageEvent<UploadWorkerOutput>) => {
+          const output = event.data
+          if (output.kind === 'progress') {
+            request.onProgress?.({
+              loaded: output.loaded,
+              ...(output.total === undefined ? {} : { total: output.total }),
+            })
+          } else if (output.kind === 'complete') {
+            finish(() => { resolve({ status: output.status, body: output.body }) })
+          } else {
+            finish(() => { reject(new Error(output.message)) })
+          }
+        }
+        worker.onerror = (event) => {
+          finish(() => { reject(new Error(event.message || 'background upload worker failed')) })
+        }
+        if (request.signal?.aborted === true) {
+          abort()
+          return
+        }
+        request.signal?.addEventListener('abort', abort, { once: true })
+        worker.postMessage({
+          url: resolveUrl(request.path).href,
+          body: request.body,
+          headers: request.headers ?? {},
+        } satisfies UploadWorkerStart)
+      })
+    },
+  }
+}
+
+function resolveUrl(path: string): URL {
+  const pageLocation = Reflect.get(globalThis, 'location') as unknown
+  const origin = typeof pageLocation === 'object' && pageLocation !== null
+    && 'origin' in pageLocation && typeof pageLocation.origin === 'string'
+    ? pageLocation.origin
+    : undefined
+  return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin)
+}

+ 8 - 8
packages/client/connection/src/client/fixture.ts

@@ -2099,13 +2099,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         value: [
           { name: 'compact', description: 'fixture:压缩当前会话上下文' },
           { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
-          { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>', images: true } },
+          { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>', attachments: true } },
           { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
-          { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } },
+          { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } },
         ],
       }
     },
-    execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult<CommandExecution | undefined> {
+    execute(id: SessionId, line: string, attachments: readonly unknown[] = []): RpcResult<CommandExecution | undefined> {
       const missing = requireGoalSession(id)
       if (missing !== undefined) return missing
       // Structured split mirroring the Host parser: name + verbatim rawInput
@@ -2116,17 +2116,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       // Mirror the Host image policy AFTER command resolution, matching the
       // executor's order (an unknown name answers undefined and logs no
       // lifecycle): the declaration rejection covers every known command
-      // without `input.images`, and the two producer grammar rejections cover
+      // without `input.attachments`, and the two producer grammar rejections cover
       // the declaring commands' control-only lines. The fixture stores no
       // bytes, so an accepted batch is acknowledged and dropped.
       const known = ['permission', 'goal', 'compact', 'echo', 'plan']
-      if (images.length > 0 && name !== undefined && known.includes(name)) {
+      if (attachments.length > 0 && name !== undefined && known.includes(name)) {
         const rejection = name !== 'goal' && name !== 'plan'
-          ? `/${name} does not accept image attachments`
+          ? `/${name} does not accept attachments`
           : name === 'goal' && args.trim() === ''
-            ? 'Image attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.'
+            ? 'Attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.'
             : name === 'plan' && args.trim() === 'off'
-              ? 'Image attachments cannot accompany /plan off.'
+              ? 'Attachments cannot accompany /plan off.'
               : undefined
         if (rejection !== undefined) {
           const commandId = `fx-cmd-${logOf(id).length}` as CommandId

+ 20 - 8
packages/client/connection/src/client/index.ts

@@ -1,7 +1,4 @@
-/**
- * Browser wire client. The plugin selects fixture or HTTP transport, provides
- * the shared API client, and lets API Gateway own the connection loop.
- */
+/** Browser wire client: Remote transport, connection generations, and background uploads. */
 import type { Context } from '@deepseek-ai/cordis'
 import {
   ConnectionController,
@@ -13,6 +10,10 @@ import {
 } from './connection.ts'
 import { createFixtureConnectionRpc } from './fixture.ts'
 import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts'
+import {
+  createBackgroundUploadTransport,
+  type BackgroundUploadTransport,
+} from './background-upload.ts'
 import { isLoopbackHostname } from '../loopback-hostname.ts'
 import type { ClientConnectionRpc } from '../rpc.ts'
 
@@ -53,6 +54,12 @@ export type {
   ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
 } from '../rpc.ts'
 export type { RpcFetch } from './rpc.ts'
+export type {
+  BackgroundUploadProgress,
+  BackgroundUploadRequest,
+  BackgroundUploadResponse,
+  BackgroundUploadTransport,
+} from './background-upload.ts'
 
 /** Observable identity and Host facts for the active connection generation. */
 export interface ConnectionGenerationState {
@@ -107,9 +114,8 @@ interface ClientTransportGlobal {
 }
 
 /**
- * The ctx.connection service API: the API client plus a one-shot controller
- * starter. API Gateway supplies generation readiness and reset callbacks;
- * Connection stays independent of downstream domain state.
+ * The ctx.connection service API. API Gateway supplies generation readiness
+ * and reset callbacks; Connection stays independent of downstream domain state.
  */
 export interface ConnectionHandle {
   /**
@@ -124,6 +130,8 @@ export interface ConnectionHandle {
   readonly state: ConnectionStateSource
   /** Generic logical RPC channels over the same Connection transport. */
   readonly rpc: ClientConnectionRpc
+  /** Large-body carrier; absent for the in-page fixture transport. */
+  readonly backgroundUploads?: BackgroundUploadTransport
   /** Reset retry progression and replace the current attempt immediately. */
   reconnect(): void
   /**
@@ -178,7 +186,7 @@ function watchBrowserNetwork(controller: ConnectionController): () => void {
 }
 
 /**
- * Client plugin body: pick the api by page mode and provide ctx.connection.
+ * Client plugin body: pick physical carriers by page mode and provide ctx.connection.
  * @param ctx - client cordis context.
  */
 export function apply(ctx: Context): void {
@@ -187,6 +195,9 @@ export function apply(ctx: Context): void {
   const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined
   const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
   const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
+  const backgroundUploads = fixtureRpc === undefined
+    ? createBackgroundUploadTransport(transport?.fetch)
+    : undefined
   let generationSource: ConnectionGenerationSource | undefined
   let owner: ConnectionOwner | undefined
   let generationId = 0
@@ -241,6 +252,7 @@ export function apply(ctx: Context): void {
       },
     },
     rpc,
+    ...(backgroundUploads === undefined ? {} : { backgroundUploads }),
     reconnect() {
       owner?.controller.reconnect()
     },

+ 48 - 36
packages/client/connection/src/http-bridge.ts

@@ -4,6 +4,8 @@
  */
 
 import type { IncomingMessage, ServerResponse } from 'node:http'
+import { Readable } from 'node:stream'
+import type { ConnectionFetchHandler } from './rpc.ts'
 
 /** Default carrier cap for all HTTP RPC bodies: sized for the default
  * aggregate image limit (200 MiB) after base64 expansion plus envelope
@@ -11,28 +13,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
  * each body in memory, so this cap is also the per-request resident bound. */
 export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024
 
-/** Transport-independent request handler consumed by the Host HTTP bridge. */
-export interface FetchHandler {
-  /**
-   * Handle one standard Fetch request.
-   * @param request - request produced by the active transport bridge.
-   * @returns complete or streaming Fetch response.
-   */
-  fetch(request: Request): Promise<Response>
-}
-
 /**
  * Bridge one node:http request to the fetch-shaped handler (client close
  * aborts; response bodies stream out chunk by chunk).
- * @param req - incoming node:http request (fully read before dispatch).
+ * @param req - incoming node:http request.
  * @param res - node:http response the bridge writes and owns to completion.
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
- * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
+ * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
  */
 export async function bridge(
   req: IncomingMessage,
   res: ServerResponse,
-  apiHandler: FetchHandler,
+  apiHandler: ConnectionFetchHandler,
   maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES,
 ): Promise<void> {
   const abort = new AbortController()
@@ -44,38 +36,57 @@ export async function bridge(
   res.on('close', () => {
     if (!res.writableEnded) abort.abort()
   })
-  const declaredLength = req.headers['content-length']
-  if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
-    res.writeHead(413, { connection: 'close' })
-    res.end()
-    req.destroy()
-    return
-  }
-  const chunks: Buffer[] = []
-  let received = 0
-  for await (const chunk of req) {
-    const buffer = chunk as Buffer
-    received += buffer.byteLength
-    if (received > maxRequestBodyBytes) {
+  /* v8 ignore next 2 -- node:http always sets url/method on server requests. */
+  const url = new URL(req.url ?? '/', 'http://dsh.internal')
+  const method = req.method ?? 'GET'
+  const headers = Object.fromEntries(
+    Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][],
+  )
+  const bodyMode = apiHandler.requestBodyMode({ method, url })
+  let request: Request
+  if (bodyMode === 'buffered') {
+    const declaredLength = req.headers['content-length']
+    if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
       res.writeHead(413, { connection: 'close' })
       res.end()
       req.destroy()
       return
     }
-    chunks.push(buffer)
+    const chunks: Buffer[] = []
+    let received = 0
+    for await (const chunk of req) {
+      const buffer = chunk as Buffer
+      received += buffer.byteLength
+      if (received > maxRequestBodyBytes) {
+        res.writeHead(413, { connection: 'close' })
+        res.end()
+        req.destroy()
+        return
+      }
+      chunks.push(buffer)
+    }
+    request = new Request(url, {
+      method,
+      headers,
+      ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
+      signal: abort.signal,
+    })
+  } else {
+    request = new Request(url, {
+      method,
+      headers,
+      body: Readable.toWeb(req) as ReadableStream<Uint8Array>,
+      signal: abort.signal,
+      duplex: 'half',
+    } as RequestInit & { duplex: 'half' })
   }
-  /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
-  requests; the fields are only optional on the client-side IncomingMessage type */
-  const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
-    method: req.method ?? 'GET',
-    headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
-    ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
-    signal: abort.signal,
-  })
   const response = await apiHandler.fetch(request)
-  res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
+  const requestUnread = bodyMode === 'streaming' && !req.readableEnded
+  const responseHeaders = Object.fromEntries(response.headers.entries())
+  res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders)
   if (response.body === null) {
     res.end()
+    if (requestUnread) req.destroy()
     return
   }
   for await (const chunk of response.body) {
@@ -96,4 +107,5 @@ export async function bridge(
     }
   }
   res.end()
+  if (requestUnread) req.destroy()
 }

+ 1 - 0
packages/client/connection/src/index.ts

@@ -22,6 +22,7 @@ export type {
   ConnectionRpcHandler,
   ConnectionRequestRejection,
   ConnectionRpcResult,
+  ConnectionRequestBodyMode,
   ConnectionTrustRequest,
   ClientRequest,
   HostConnectionHandle,

+ 10 - 3
packages/client/connection/src/rpc-host.ts

@@ -8,7 +8,7 @@ import {
   type RpcId as RpcIdType,
 } from './rpc.ts'
 import { clientRequestSchema } from './rpc-schema.ts'
-import { bridge, type FetchHandler } from './http-bridge.ts'
+import { bridge } from './http-bridge.ts'
 import { isTrustedApiRequest } from './api-request-trust.ts'
 import { API_PATH } from './api-path.ts'
 import type { BrowserAuth } from './browser-auth.ts'
@@ -34,11 +34,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
 
 interface ConnectionRpcInterceptor {
   readonly matches: ConnectionRpcEndpointMatcher
-  readonly fetchHandler: FetchHandler
+  readonly fetchHandler: ConnectionFetchHandler
 }
 
 interface RegisteredFetchRoute {
   readonly methods: ReadonlySet<string>
+  readonly requestBody: ConnectionFetchRoute['requestBody']
   readonly fetch: ConnectionFetchRoute['fetch']
 }
 
@@ -117,6 +118,10 @@ export class HostConnectionService extends Service implements HostConnectionHand
     channel: '/api',
   ): ConnectionFetchHandler {
     return {
+      requestBodyMode: ({ method, url }) => {
+        const route = this.fetchRoutes.get(url.pathname)
+        return route?.methods.has(method) === true ? route.requestBody : 'buffered'
+      },
       fetch: (request) => {
         const pathname = new URL(request.url).pathname
         const route = this.fetchRoutes.get(pathname)
@@ -138,6 +143,7 @@ export class HostConnectionService extends Service implements HostConnectionHand
     assertFetchRoute(route)
     const registered: RegisteredFetchRoute = {
       methods: new Set(route.methods),
+      requestBody: route.requestBody,
       fetch: route.fetch,
     }
     return owner.effect(() => {
@@ -203,8 +209,9 @@ export class HostConnectionService extends Service implements HostConnectionHand
 function rpcFetchHandler(
   channel: string,
   handler: ConnectionRpcHandler,
-): FetchHandler {
+): ConnectionFetchHandler {
   return {
+    requestBodyMode: () => 'buffered',
     async fetch(request: Request): Promise<Response> {
       const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
       if (request.method !== 'POST' || endpoint === undefined) {

+ 13 - 1
packages/client/connection/src/rpc.ts

@@ -107,7 +107,10 @@ export type ConnectionRpcHandler = (
 export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
 
 /** HTTP methods supported by exact Fetch routes on the shared API channel. */
-export type ConnectionFetchMethod = 'GET' | 'HEAD'
+export type ConnectionFetchMethod = 'GET' | 'HEAD' | 'POST'
+
+/** How the node:http bridge presents one request body to its Fetch route. */
+export type ConnectionRequestBodyMode = 'buffered' | 'streaming'
 
 /** One exact, transport-independent Fetch route owned by a Host feature. */
 export interface ConnectionFetchRoute {
@@ -115,6 +118,8 @@ export interface ConnectionFetchRoute {
   readonly path: string
   /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */
   readonly methods: readonly ConnectionFetchMethod[]
+  /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */
+  readonly requestBody: ConnectionRequestBodyMode
   /** Handle one request after the physical carrier has applied its trust and authentication policy. */
   readonly fetch: (request: Request) => Promise<Response>
 }
@@ -196,6 +201,13 @@ export interface HostConnectionHandle {
 
 /** Transport-independent Fetch handler used by HTTP and worker carriers. */
 export interface ConnectionFetchHandler {
+  /**
+   * Resolve body handling before the bridge reads any request bytes.
+   * @param request - request method and URL available from node:http headers.
+   * @returns the registered route's body handling mode.
+   */
+  requestBodyMode(request: { readonly method: string; readonly url: URL }): ConnectionRequestBodyMode
+
   /**
    * Dispatch one already-authenticated request.
    * @param request - Fetch request below the shared channel.

+ 1 - 0
packages/client/connection/tsconfig.client.json

@@ -8,6 +8,7 @@
   "files": [
     "src/api-path.ts",
     "src/client/api.ts",
+    "src/client/background-upload.ts",
     "src/client/connection.ts",
     "src/client/fixture.ts",
     "src/client/index.ts",

+ 2 - 2
packages/client/ui-commands/src/client/locales.ts

@@ -9,7 +9,7 @@ export const zh = {
   'status.empty': '无选项',
   'overlay.aria': '/{command} 选项',
   'listbox.aria': '/{command} 匹配项',
-  'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片',
+  'notice.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件',
 } satisfies Record<string, string>
 
 /** The command namespace key union. */
@@ -24,5 +24,5 @@ export const en = {
   'status.empty': 'No options',
   'overlay.aria': '/{command} options',
   'listbox.aria': '/{command} matches',
-  'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first',
+  'notice.attachmentsUnsupported': '/{command} does not accept attachments; remove them first',
 } satisfies Record<CommandKey, string>

+ 18 - 18
packages/client/ui-commands/src/client/service.ts

@@ -19,7 +19,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import type {
   CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick,
-  SubmitEnvelope, SubmitImageAttachment, SubmitOutcome,
+  SubmitAttachment, SubmitEnvelope, SubmitOutcome,
 } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts'
 import type { CommandDescriptor } from './directory.ts'
@@ -310,10 +310,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * bare host commands act on the bare token only; leadingInput claims
    * args-tolerant.
    *
-   * Envelope policy: an enter submission carrying images resolves only
-   * through a command declaring image acceptance. Every other command route —
+   * Envelope policy: an enter submission carrying attachments resolves only
+   * through a command declaring attachment acceptance. Every other command route —
    * popup, non-accepting claim, bare detached execute — throws the refusal
-   * so the machine surfaces one composer notice and the draft and images
+   * so the machine surfaces one composer notice and the draft and attachments
    * stay in place; nothing executes and nothing is dropped.
    */
   private async matchEnter(
@@ -329,13 +329,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     const bare = ws === -1
     const name = token.slice(1)
     if (name === '') return undefined
-    const refuseImages = (): never => {
-      throw new Error(this.t('notice.imagesUnsupported', { command: name }))
+    const refuseAttachments = (): never => {
+      throw new Error(this.t('notice.attachmentsUnsupported', { command: name }))
     }
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(session)) {
       if (!bare) return undefined
-      if (envelope.images > 0) refuseImages()
+      if (envelope.attachments > 0) refuseAttachments()
       this.openPopup(name, contribution.ui, session, { via: 'enter', token })
       return 'handled'
     }
@@ -347,17 +347,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     if (bare) {
       const decoration = this.live.decorations.get(name)
       if (decoration !== undefined && decoration.available(session)) {
-        if (envelope.images > 0) refuseImages()
+        if (envelope.attachments > 0) refuseAttachments()
         this.openPopup(name, decoration.ui, session, { via: 'enter', token })
         return 'handled'
       }
     }
     if (desc.input !== undefined) {
-      if (envelope.images > 0 && desc.input.images !== true) refuseImages()
+      if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments()
       return { claim: this.leadingClaim(desc, session) }
     }
     if (!bare) return undefined
-    if (envelope.images > 0) refuseImages()
+    if (envelope.attachments > 0) refuseAttachments()
     this.consumeVia(session.sessionId, { via: 'enter', token })
     this.runDetached(desc, session, trimmed)
     return 'handled'
@@ -381,8 +381,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     return {
       token,
       ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
-      ...(desc.input?.images === true ? { images: true } : {}),
-      submit: (args, _actx, images) => this.execute(session, token + args, images),
+      ...(desc.input?.attachments === true ? { attachments: true } : {}),
+      submit: (args, _actx, attachments) => this.execute(session, token + args, attachments),
     }
   }
 
@@ -394,21 +394,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * executor durably logged the lifecycle (`command/run`/`command/done`) and
    * the outcome renders as a persistent flow node — the composer never
    * echoes it. A handler error result reports an error outcome so the
-   * composer keeps the submission (draft and images) for correction.
+   * composer keeps the draft and attachments for correction.
    * A refused call throws.
    */
   private async execute(
     session: ClientSessionContext,
     line: string,
-    images: readonly SubmitImageAttachment[] = [],
+    attachments: readonly SubmitAttachment[] = [],
   ): Promise<SubmitOutcome> {
-    const result = await this.ctx.remote.commands.execute(session.sessionId, line, images)
+    const result = await this.ctx.remote.commands.execute(session.sessionId, line, attachments)
     if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
     if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
     this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result)
-    // An image-carrying submission consumed its images only on handler
-    // success; an error outcome keeps draft and images in the composer.
-    if (images.length > 0 && result.value.result.kind === 'error') {
+    // A submission consumes its attachments only after handler success; an
+    // error outcome keeps the draft and attachments in the composer.
+    if (attachments.length > 0 && result.value.result.kind === 'error') {
       return { kind: 'error', text: result.value.result.text }
     }
     return { kind: 'success' }

+ 1 - 1
packages/client/ui-input-trigger/src/client/index.ts

@@ -25,7 +25,7 @@ export type {
   ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext,
   CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia,
   ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick,
-  InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar,
+  InputTriggerSource, SubmitAttachment, SubmitEnvelope, SubmitOutcome, TokenSpan, TriggerChar,
   TriggerGuard, TriggerPosition,
 } from '../types.ts'
 export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts'

+ 3 - 3
packages/client/ui-input-trigger/src/types.ts

@@ -14,7 +14,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 export type {
   ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest,
-  InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment,
+  InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitAttachment,
   SubmitOutcome, TokenSpan,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
@@ -95,8 +95,8 @@ export interface HeaderRequest {
  * presence to accept or refuse a whole submission.
  */
 export interface SubmitEnvelope {
-  /** Number of image attachments accompanying the draft. */
-  readonly images: number
+  /** Number of attachments accompanying the draft. */
+  readonly attachments: number
 }
 
 /** Candidate request passed to a source. The signal is superseded on query change / menu close. */

+ 4 - 3
packages/experimental/webworker-runtime/src/client/client.ts

@@ -119,10 +119,11 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne
   }
 }
 
-/** Normalize a RequestInit body to a transferable ArrayBuffer. */
-function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined {
+/** Keep opaque Blobs clone-cheap; normalize text and typed arrays to transferable bytes. */
+function toTunnelBody(body: RequestInit['body']): ArrayBuffer | Blob | undefined {
   if (body === undefined || body === null) return undefined
   if (typeof body === 'string') return encoder.encode(body).buffer
+  if (body instanceof Blob) return body
   if (body instanceof ArrayBuffer) return body
   if (ArrayBuffer.isView(body)) {
     return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)
@@ -205,7 +206,7 @@ export class WorkerTunnel {
       headers: Object.fromEntries(new Headers(init?.headers).entries()),
       ...(init?.body === undefined || init.body === null
         ? {}
-        : { body: toBodyBuffer(init.body) }),
+        : { body: toTunnelBody(init.body) }),
     }
     const response = new Promise<Response>((resolve, reject) => {
       this.unary.set(id, { resolve, reject })

+ 3 - 3
packages/experimental/webworker-runtime/src/transport/frames.ts

@@ -14,7 +14,7 @@ export interface TunnelRequestFrame {
   readonly method: string
   readonly url: string
   readonly headers: Readonly<Record<string, string>>
-  readonly body?: ArrayBuffer | undefined
+  readonly body?: ArrayBuffer | Blob | undefined
 }
 
 /** Open one Gateway Remote stream over the worker-local carrier. */
@@ -171,8 +171,8 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame {
     if (typeof value === 'string') headers[key.toLowerCase()] = value
   }
   const body = frame.body
-  if (body !== undefined && !(body instanceof ArrayBuffer)) {
-    throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer`)
+  if (body !== undefined && !(body instanceof ArrayBuffer) && !(body instanceof Blob)) {
+    throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer or Blob`)
   }
   return { t: 'req', id, method: frame.method, url: frame.url, headers, body }
 }

+ 10 - 1
packages/experimental/webworker-runtime/src/transport/synthetic-http.ts

@@ -66,7 +66,15 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons
     headers: frame.headers,
     destroy: (): void => { aborted = true },
     async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
-      if (frame.body === undefined || frame.body.byteLength === 0) return
+      if (frame.body === undefined) return
+      if (frame.body instanceof Blob) {
+        for await (const chunk of frame.body.stream()) {
+          if (aborted) return
+          if (chunk.byteLength > 0) yield chunk
+        }
+        return
+      }
+      if (aborted || frame.body.byteLength === 0) return
       yield new Uint8Array(frame.body)
     },
   }
@@ -133,6 +141,7 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons
       if (finished) return
       aborted = true
       finished = true
+      emit('aborted')
       emit('close')
     },
   }

+ 30 - 6
packages/extensions/cordis-client-runner/src/client/api-catalog.ts

@@ -433,13 +433,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'AgentContext',
     declaration: 'export type AgentContext = Omit<Context, \'remote\'> & {\n    readonly remote: ClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};',
   },
+  {
+    name: 'BackgroundUploadProgress',
+    declaration: 'export interface BackgroundUploadProgress {\n    readonly loaded: number;\n    readonly total?: number;\n}',
+  },
+  {
+    name: 'BackgroundUploadRequest',
+    declaration: 'export interface BackgroundUploadRequest {\n    readonly path: string;\n    readonly body: Blob;\n    readonly headers?: Readonly<Record<string, string>>;\n    readonly signal?: AbortSignal;\n    readonly onProgress?: (progress: BackgroundUploadProgress) => void;\n}',
+  },
+  {
+    name: 'BackgroundUploadResponse',
+    declaration: 'export interface BackgroundUploadResponse {\n    readonly status: number;\n    readonly body: string;\n}',
+  },
+  {
+    name: 'BackgroundUploadTransport',
+    declaration: 'export interface BackgroundUploadTransport {\n    post(request: BackgroundUploadRequest): Promise<BackgroundUploadResponse>;\n}',
+  },
   {
     name: 'BakedActions',
     declaration: 'export type BakedActions<T, A extends ActionsDecl<T>> = {\n    [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};',
   },
   {
     name: 'BeginSubmissionInput',
-    declaration: 'export interface BeginSubmissionInput {\n    readonly mode: \'queue\' | \'steer\';\n    readonly text: string;\n    readonly images: readonly PendingSubmissionImage[];\n    readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}',
+    declaration: 'export interface BeginSubmissionInput {\n    readonly mode: \'queue\' | \'steer\';\n    readonly text: string;\n    readonly attachments: readonly PendingSubmissionAttachment[];\n    readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}',
   },
   {
     name: 'BoundActions',
@@ -499,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ConnectionHandle',
-    declaration: 'export interface ConnectionHandle {\n    readonly isLoopback: boolean;\n    readonly generation: ConnectionGenerationState;\n    readonly state: ConnectionStateSource;\n    readonly rpc: ClientConnectionRpc;\n    reconnect(): void;\n    registerGenerationSource(source: ConnectionGenerationSource): () => void;\n    start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}',
+    declaration: 'export interface ConnectionHandle {\n    readonly isLoopback: boolean;\n    readonly generation: ConnectionGenerationState;\n    readonly state: ConnectionStateSource;\n    readonly rpc: ClientConnectionRpc;\n    readonly backgroundUploads?: BackgroundUploadTransport;\n    reconnect(): void;\n    registerGenerationSource(source: ConnectionGenerationSource): () => void;\n    start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}',
   },
   {
     name: 'ConnectionHostInfo',
@@ -533,6 +549,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'EntryKeyOf',
     declaration: 'export type EntryKeyOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n    kind: \'keyed\';\n    keyProps: infer P extends object;\n} ? keyof P & string : string;',
   },
+  {
+    name: 'FileUploadReceiptId',
+    declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;',
+  },
   {
     name: 'GlobalStandardProps',
     declaration: 'export interface GlobalStandardProps {\n}',
@@ -559,7 +579,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ISession',
-    declaration: 'export interface ISession {\n    readonly sessionId: SessionId;\n    readonly projections: ProjectionsFace;\n    beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n    prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise<RemoteResult<{\n        accepted: true;\n    }>>;\n    readAttachment(attachmentId: AttachmentIdType): Promise<RemoteResult<{\n        attachment: ImageAttachmentRef;\n        data: Uint8Array;\n    }>>;\n    updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{\n        accepted: true;\n    }>>;\n    cancel(): Promise<RemoteResult<{\n        accepted: true;\n    }>>;\n    rename(title: string): Promise<RemoteResult<{\n        title: string;\n        seq: number;\n    }>>;\n    loadOlder(): Promise<void>;\n    loadThrough(seq: number): Promise<void>;\n    command(line: string): Promise<RemoteResult<{\n        matched: boolean;\n    }>>;\n}',
+    declaration: 'export interface ISession {\n    readonly sessionId: SessionId;\n    readonly projections: ProjectionsFace;\n    beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n    prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise<RemoteResult<{\n        accepted: true;\n    }>>;\n    uploadFile(data: Blob | Uint8Array, name?: string, signal?: AbortSignal, onProgress?: (progress: {\n        readonly loaded: number;\n        readonly total?: number;\n    }) => void): Promise<RemoteResult<{\n        receiptId: FileUploadReceiptId;\n        file: FileAttachmentRef;\n    }>>;\n    readAttachment(attachmentId: AttachmentIdType): Promise<RemoteResult<{\n        attachment: ImageAttachmentRef;\n        data: Uint8Array;\n    }>>;\n    updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{\n        accepted: true;\n    }>>;\n    cancel(): Promise<RemoteResult<{\n        accepted: true;\n    }>>;\n    rename(title: string): Promise<RemoteResult<{\n        title: string;\n        seq: number;\n    }>>;\n    loadOlder(): Promise<void>;\n    loadThrough(seq: number): Promise<void>;\n    command(line: string): Promise<RemoteResult<{\n        matched: boolean;\n    }>>;\n}',
   },
   {
     name: 'KeyPropsOf',
@@ -615,7 +635,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PendingSubmission',
-    declaration: 'export interface PendingSubmission {\n    readonly requestId: SessionRequestId;\n    readonly placement: PendingSubmissionPlacement;\n    readonly time: number;\n    readonly text: string;\n    readonly images: readonly PendingSubmissionImage[];\n}',
+    declaration: 'export interface PendingSubmission {\n    readonly requestId: SessionRequestId;\n    readonly placement: PendingSubmissionPlacement;\n    readonly time: number;\n    readonly text: string;\n    readonly attachments: readonly PendingSubmissionAttachment[];\n}',
+  },
+  {
+    name: 'PendingSubmissionAttachment',
+    declaration: 'export type PendingSubmissionAttachment = ({\n    readonly type: \'image\';\n} & PendingSubmissionImage) | {\n    readonly type: \'file\';\n    readonly attachment: FileAttachmentRef;\n};',
   },
   {
     name: 'PendingSubmissionImage',
@@ -627,7 +651,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PendingSubmissionRetirement',
-    declaration: 'export type PendingSubmissionRetirement = {\n    readonly reason: \'observed\';\n    readonly attachments: readonly ImageAttachmentRef[];\n} | {\n    readonly reason: \'failed\';\n};',
+    declaration: 'export type PendingSubmissionRetirement = {\n    readonly reason: \'observed\';\n    readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[];\n} | {\n    readonly reason: \'failed\';\n};',
   },
   {
     name: 'ProjectionsFace',
@@ -635,7 +659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PromptContentPart',
-    declaration: 'export type PromptContentPart = {\n    readonly type: \'text\';\n    readonly text: string;\n} | {\n    readonly type: \'image\';\n    readonly mediaType: ImageMediaType;\n    readonly data: string;\n    readonly name?: string;\n};',
+    declaration: 'export type PromptContentPart = {\n    readonly type: \'text\';\n    readonly text: string;\n} | {\n    readonly type: \'image\';\n    readonly mediaType: ImageMediaType;\n    readonly data: string;\n    readonly name?: string;\n} | {\n    readonly type: \'file\';\n    readonly receiptId: FileUploadReceiptId;\n};',
   },
   {
     name: 'PromptError',

+ 25 - 24
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -407,7 +407,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n      { name: \'conversation.composer\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:119',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145',
   },
   {
     key: 'conversation.composer.bar',
@@ -443,7 +443,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n      { name: \'conversation.composer.bar\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163',
   },
   {
     key: 'conversation.composer.dock',
@@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n      { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157',
   },
   {
     key: 'conversation.details.tool',
@@ -565,7 +565,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n      { name: \'conversation.hero.agentPreset\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151',
   },
   {
     key: 'conversation.hero.brand.mark',
@@ -591,7 +591,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149',
   },
   {
     key: 'conversation.hero.workspace',
@@ -621,7 +621,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n      { name: \'conversation.hero.workspace\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147',
   },
   {
     key: 'conversation.hero.workspace.directoryFlow',
@@ -656,15 +656,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     key: 'conversation.input.attachments',
     kind: 'single',
     scope: 'session-maybe',
-    summary: 'Optional draft-image rail and drop target.',
-    doc: 'Optional draft-image rail and drop target.',
+    summary: 'Optional draft-attachment rail and drop target.',
+    doc: 'Optional draft-attachment rail and drop target.',
     registerOptions: [],
     ownerProps: [
-      '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n  /** Browser-owned draft images in input order. */\n  attachments: readonly ComposerAttachment[]\n  /** Whether a document-level file drop may add images now. */\n  canAcceptDrop: boolean\n  /** Add one dropped batch through the composer\'s validation path. */\n  onAddImages: (files: readonly File[]) => void\n  /** Remove one draft image through the Conversation service. */\n  onRemoveImage: (id: DraftAttachmentId) => void\n  /** Display-ready limits for the drop invitation. */\n  dropLimits?: { readonly count: number; readonly size: string } | undefined\n}',
+      '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n  /** Browser-owned draft attachments in input order. */\n  attachments: readonly ComposerAttachment[]\n  /** Whether a document-level file drop may add attachments now. */\n  canAcceptDrop: boolean\n  /** Add one dropped batch through the composer\'s validation path. */\n  onAddFiles: (files: readonly File[]) => void\n  /** Remove one draft attachment through the Conversation service. */\n  onRemoveAttachment: (id: DraftAttachmentId) => void\n  /** Current per-draft upload states for file-kind attachments. */\n  uploads: DraftFileUploads\n  /** Restart one failed file upload. */\n  onRetryFile: (id: DraftAttachmentId) => void\n  /** Display-ready limits for the drop invitation. */\n  dropLimits?: { readonly count: number; readonly size: string } | undefined\n}',
     ],
     ownerPropsReferences: [
       'ComposerAttachment',
       'DraftAttachmentId',
+      'DraftFileUploads',
     ],
     standardProps: [
       'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
@@ -687,7 +688,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n      { name: \'conversation.input.attachments\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165',
   },
   {
     key: 'conversation.input.dock',
@@ -747,7 +748,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n      { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:153',
   },
   {
     key: 'conversation.input.left',
@@ -803,7 +804,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n      { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159',
   },
   {
     key: 'conversation.input.model',
@@ -839,7 +840,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n      { name: \'conversation.input.model\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173',
   },
   {
     key: 'conversation.input.overlay',
@@ -893,7 +894,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155',
   },
   {
     key: 'conversation.input.plan',
@@ -929,7 +930,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n      { name: \'conversation.input.plan\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171',
   },
   {
     key: 'conversation.input.right',
@@ -985,7 +986,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n      { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161',
   },
   {
     key: 'conversation.message.images',
@@ -995,7 +996,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.',
     registerOptions: [],
     ownerProps: [
-      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n}',
+      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n  /** Force every image into the compact message-attachment tile size. */\n  compact?: boolean\n}',
     ],
     ownerPropsReferences: [
       'Message',
@@ -1059,7 +1060,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n      { name: \'conversation.session\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:95',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121',
   },
   {
     key: 'conversation.session.header',
@@ -1093,7 +1094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n      { name: \'conversation.session.header\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:97',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
   },
   {
     key: 'conversation.session.header.actions',
@@ -1151,7 +1152,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n      { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131',
   },
   {
     key: 'conversation.session.header.lineage',
@@ -1189,7 +1190,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n      { name: \'conversation.session.header.lineage\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:99',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125',
   },
   {
     key: 'conversation.session.header.utilities',
@@ -1244,7 +1245,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n      { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:111',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137',
   },
   {
     key: 'conversation.trajectory.images',
@@ -1254,7 +1255,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.',
     registerOptions: [],
     ownerProps: [
-      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n}',
+      '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n  /** Durable references or submission-echo previews in source order. */\n  images: readonly MessageImageSource[]\n  /** Session-authorized image URL loader for the durable arm. */\n  loadImage: MessageImageLoader\n  /** Horizontal placement inside the owning record. */\n  align: \'start\' | \'end\'\n  /** Force every image into the compact message-attachment tile size. */\n  compact?: boolean\n}',
     ],
     ownerPropsReferences: [
       'Message',
@@ -1342,7 +1343,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n      { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:117',
+    source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143',
   },
   {
     key: 'details',

+ 8 - 0
packages/test-support/client-runtime/src/sessions.ts

@@ -120,6 +120,14 @@ export class FixtureSession implements SessionFace {
     throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`)
   }
 
+  /**
+   * Fail-loud stub; supply `uploadFile` on the fixture's session face to exercise it.
+   * @returns never — always throws.
+   */
+  uploadFile(): never {
+    throw new Error(`test session "${this.sessionId}": uploadFile is not stubbed — supply it on the fixture's session face`)
+  }
+
   /**
    * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
    * @returns never — always throws.