ソースを参照

feat(session-controller): admit files across command flows

creatixchu 1 週間 前
コミット
8a0ff3aff8

+ 2 - 0
packages/api/session-controller/package.json

@@ -81,6 +81,7 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
@@ -119,6 +120,7 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",

+ 218 - 8
packages/api/session-controller/src/commands.ts

@@ -4,12 +4,13 @@ import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import { brandString } from '@deepseek-ai/dsh-brand'
 import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
-import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import { AttachmentError, admitEncodedFile, admitPromptContent } from '@deepseek-ai/dsh-attachment'
+import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands'
+import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import {
   ReasoningEffortId, createUserMessage, freezeMessage,
 } from '@deepseek-ai/dsh-llm'
-import type { MessageSource } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
@@ -35,6 +36,7 @@ import type {
   SessionCreateValue,
   SessionForkRequest,
   SessionForkValue,
+  FileUploadReceiptId,
   SessionPromptRequest,
   SessionPromptValue,
   SessionRenameRequest,
@@ -43,6 +45,9 @@ import type {
   SessionSelectModelValue,
   SessionUpdateQueueRequest,
   SessionUpdateQueueValue,
+  SessionUploadFileRequest,
+  SessionUploadFileValue,
+  SessionRequestId,
 } from './types.ts'
 
 interface SessionReadState {
@@ -51,8 +56,21 @@ interface SessionReadState {
   readonly events: readonly SessionEvent[]
 }
 
+interface StagedFileUpload {
+  readonly file: FileAttachmentRef
+  /** Prompt that accepted this receipt; absent until successful admission. */
+  requestId?: SessionRequestId
+}
+
 /** Implements Session business commands delegated by the Session Controller Remote service. */
 export class SessionCommandController {
+  /**
+   * Staged file uploads awaiting a prompt, keyed by Session. Entries are the
+   * prompt-time authority for file references: a prompt may only cite a file
+   * previously uploaded for the same Session in this process.
+   */
+  private readonly stagedFiles = new Map<SessionId, Map<FileUploadReceiptId, StagedFileUpload>>()
+
   /**
    * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services.
    * @param agents - sole owner of create, resume, and Session-local model selection.
@@ -62,7 +80,115 @@ export class SessionCommandController {
     private readonly ctx: Context,
     private readonly agents: ApiSessionAgentController,
     private readonly defaultCwd: string,
-  ) {}
+  ) {
+    ctx.inject(['commands'], (commandCtx) => {
+      const resolve: CommandFileReceiptResolver = (agent, receiptId) =>
+        this.resolveStagedFile(agent.id, receiptId as FileUploadReceiptId)
+      commandCtx.effect(
+        () => commandCtx.commands.registerFileReceiptResolver(resolve),
+        'session-controller: command file receipt resolver',
+      )
+    })
+  }
+
+  /**
+   * Persist one browser file upload verbatim and stage it for later prompts.
+   * @param request - Session identity, base64 payload, and optional display name.
+   * @returns an opaque per-upload receipt and the durable file reference.
+   */
+  async uploadFile(request: SessionUploadFileRequest): Promise<SessionUploadFileValue> {
+    const agent = await this.resolveAgent(request.sessionId)
+    return this.commitFileUpload(agent, async () => admitEncodedFile(this.ctx.attachments, {
+      data: request.data,
+      ...(request.name === undefined ? {} : { name: request.name }),
+    }))
+  }
+
+  /**
+   * Persist raw upload chunks without collecting the complete file in memory.
+   * @param request - Session identity, ordered exact bytes, cancellation, and optional display name.
+   * @returns an opaque per-upload receipt and the durable file reference.
+   */
+  async uploadFileStream(request: {
+    readonly sessionId: SessionId
+    readonly data: AsyncIterable<Uint8Array>
+    readonly signal?: AbortSignal
+    readonly name?: string
+  }): Promise<SessionUploadFileValue> {
+    const agent = await this.resolveAgent(request.sessionId)
+    return this.commitFileUpload(agent, async () => this.ctx.attachments.saveFileStream({
+      data: request.data,
+      ...(request.signal === undefined ? {} : { signal: request.signal }),
+      ...(request.name === undefined ? {} : { name: request.name }),
+    }))
+  }
+
+  private async commitFileUpload(
+    agent: Agent,
+    save: () => Promise<FileAttachmentRef>,
+  ): Promise<SessionUploadFileValue> {
+    let file: FileAttachmentRef
+    try {
+      file = await save()
+    } catch (error) {
+      if (error instanceof AttachmentError) {
+        throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
+      }
+      throw new RemoteError(
+        'gateway/internal',
+        `failed to store file upload: ${String(error)}`,
+        {},
+        { cause: error },
+      )
+    }
+    if (this.ctx.agents.get(agent.id) !== agent) {
+      throw new RemoteError(
+        'session/not-found',
+        `session "${agent.id}" was disposed before its file upload completed`,
+        { sessionId: agent.id },
+      )
+    }
+    let staged = this.stagedFiles.get(agent.id)
+    if (staged === undefined) {
+      staged = new Map()
+      this.stagedFiles.set(agent.id, staged)
+    }
+    const receiptId = randomUUID() as FileUploadReceiptId
+    staged.set(receiptId, { file })
+    return { receiptId, file }
+  }
+
+  /**
+   * Resolve one staged upload for the same Session without exposing the receipt table.
+   * @param sessionId - receiving Session identity.
+   * @param receiptId - Host-minted upload receipt.
+   * @returns the durable file reference, or `undefined` when the receipt is absent or belongs elsewhere.
+   */
+  resolveStagedFile(sessionId: SessionId, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined {
+    return this.stagedFiles.get(sessionId)?.get(receiptId)?.file
+  }
+
+  /**
+   * Retire file receipts only after their accepted prompt becomes observable.
+   * @param sessionId - Session whose log emitted the prompt.
+   * @param requestId - browser prompt identity echoed by the event.
+   */
+  retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void {
+    const staged = this.stagedFiles.get(sessionId)
+    if (staged === undefined) return
+    for (const [receiptId, upload] of staged) {
+      if (upload.requestId === requestId) staged.delete(receiptId)
+    }
+    if (staged.size === 0) this.stagedFiles.delete(sessionId)
+  }
+
+  /**
+   * Drop one Session's staged uploads (the stored objects remain durable).
+   * @param sessionId - Session leaving the live registry.
+   */
+  releaseStagedFiles(sessionId: SessionId): void {
+    this.stagedFiles.delete(sessionId)
+  }
 
   /**
    * Create or idempotently adopt one ordinary Session.
@@ -292,6 +418,7 @@ export class SessionCommandController {
       )
     }
     const agent = await this.resolveAgent(request.sessionId)
+    if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
     const selection = this.agents.selectionFor(agent).current
     if (!routeServed(this.ctx, selection.provider)) {
       throw new RemoteError(
@@ -319,10 +446,42 @@ export class SessionCommandController {
             )
           }
         }
-        const content = await admitPromptContent(this.ctx.attachments, request.content)
-        const message: UserMessage = createUserMessage({ content, source })
-        if (request.mode === 'steer') agent.steer(message)
-        else agent.followup(message)
+        const staged = this.stagedFiles.get(request.sessionId)
+        const durable = await durablePromptContent(
+          this.ctx,
+          request.content,
+          receiptId => staged?.get(receiptId)?.file,
+        )
+        const message: UserMessage = createUserMessage({ content: durable.content, source })
+        if (this.ctx.agents.get(agent.id) !== agent) {
+          throw new RemoteError(
+            'session/not-found',
+            `session "${agent.id}" was disposed during prompt admission`,
+            { sessionId: agent.id },
+          )
+        }
+        const bound = durable.receiptIds.map((receiptId) => {
+          const upload = staged?.get(receiptId)
+          if (upload === undefined) {
+            throw new RemoteError(
+              'session/attachment-invalid',
+              'File was not uploaded for this session.',
+              { reason: 'FILE_NOT_STAGED' },
+            )
+          }
+          return { upload, previous: upload.requestId }
+        })
+        for (const { upload } of bound) upload.requestId = request.requestId
+        try {
+          if (request.mode === 'steer') agent.steer(message)
+          else agent.followup(message)
+        } catch (error) {
+          for (const { upload, previous } of bound) {
+            if (previous === undefined) delete upload.requestId
+            else upload.requestId = previous
+          }
+          throw error
+        }
       } catch (error) {
         if (remoteErrorOf(error) !== undefined) throw error
         if (error instanceof AttachmentError) {
@@ -416,6 +575,12 @@ export class SessionCommandController {
       }))
     } else {
       agent.inbox.remove(request.itemId)
+      if (request.action.kind === 'remove') {
+        const source = message.source
+        if (source.kind === 'user' && 'rpcId' in source) {
+          this.retireObservedPrompt(request.sessionId, source.rpcId)
+        }
+      }
       if (request.action.kind === 'steer') agent.steer(message)
     }
     return { accepted: true }
@@ -492,6 +657,51 @@ export class SessionCommandController {
   }
 }
 
+async function durablePromptContent(
+  ctx: Context,
+  content: readonly SessionPromptRequest['content'][number][],
+  stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
+): Promise<{ readonly content: ContentBlock[]; readonly receiptIds: readonly FileUploadReceiptId[] }> {
+  const files = new Map<FileUploadReceiptId, FileAttachmentRef>()
+  for (const part of content) {
+    if (part.type !== 'file' || files.has(part.receiptId)) continue
+    const file = stagedFile(part.receiptId)
+    if (file === undefined) {
+      throw new RemoteError(
+        'session/attachment-invalid',
+        'File was not uploaded for this session.',
+        { reason: 'FILE_NOT_STAGED' },
+      )
+    }
+    files.set(part.receiptId, file)
+  }
+  type NonFilePart = Exclude<SessionPromptRequest['content'][number], { readonly type: 'file' }>
+  const admitted = await admitPromptContent(
+    ctx.attachments,
+    content.filter((part): part is NonFilePart => part.type !== 'file'),
+  )
+  let next = 0
+  const durable = content.map((part) => {
+    if (part.type === 'file') {
+      return { type: 'file' as const, attachment: files.get(part.receiptId) as FileAttachmentRef }
+    }
+    return admitted[next++] as ContentBlock
+  })
+  return { content: durable, receiptIds: [...files.keys()] }
+}
+
+function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
+  const matches = (message: UserMessage): boolean => {
+    const source = message.source
+    return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
+  }
+  if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
+  return agent.session.snapshotEvents().some((event) => {
+    if (event.type !== 'user/message') return false
+    const source = event.data.source
+    return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
+  })
+}
 function imageBlockIn(
   content: unknown,
   match: (ref: ImageAttachmentRef) => boolean,

+ 116 - 0
packages/api/session-controller/src/file-upload-http.ts

@@ -0,0 +1,116 @@
+/** Raw Fetch file intake used by the browser background-upload carrier. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+import { SESSION_FILE_UPLOAD_PATH } from './file-upload-path.ts'
+import type { SessionUploadFileValue } from './types.ts'
+import type { SessionCommandController } from './commands.ts'
+
+interface FileUploadConnection {
+  readonly fetch: {
+    register(route: {
+      readonly path: string
+      readonly methods: readonly ['POST']
+      readonly requestBody: 'streaming'
+      readonly fetch: (request: Request) => Promise<Response>
+    }): () => Promise<void>
+  }
+}
+
+type FileUploadResult =
+  | { readonly ok: true; readonly value: SessionUploadFileValue }
+  | {
+    readonly ok: false
+    readonly error: { readonly code: string; readonly message: string; readonly details: object }
+  }
+
+/**
+ * Install the authenticated raw-byte route on Connection's shared Fetch registry.
+ * @param ctx - Host context that provides Connection.
+ * @param commands - Session command owner that validates and stages stored bytes.
+ */
+export function registerSessionFileUploadHttp(ctx: Context, commands: SessionCommandController): void {
+  ctx.inject(['connection'], (connectionCtx) => {
+    const connection = connectionCtx.get('connection') as FileUploadConnection
+    connection.fetch.register({
+      path: SESSION_FILE_UPLOAD_PATH,
+      methods: ['POST'],
+      requestBody: 'streaming',
+      fetch: request => handleSessionFileUploadHttp(commands, request),
+    })
+  })
+}
+
+/**
+ * Handle one authenticated raw-byte upload after the physical carrier applies
+ * its trust policy.
+ * @param commands - Session command owner that validates and stages stored bytes.
+ * @param request - Fetch request carrying the raw file body.
+ * @returns JSON receipt or a precise validation response.
+ */
+export async function handleSessionFileUploadHttp(
+  commands: SessionCommandController,
+  request: Request,
+): Promise<Response> {
+  if (request.method !== 'POST') {
+    return new Response(null, { status: 405, headers: { allow: 'POST' } })
+  }
+  const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
+  if (mediaType !== 'application/octet-stream') {
+    return new Response('content type must be application/octet-stream', { status: 415 })
+  }
+  const url = new URL(request.url)
+  const sessionId = url.searchParams.get('sessionId')
+  if (sessionId === null || sessionId === '') {
+    return new Response('sessionId is required', { status: 400 })
+  }
+  const name = url.searchParams.get('name') ?? undefined
+  let result: FileUploadResult
+  try {
+    result = {
+      ok: true,
+      value: await commands.uploadFileStream({
+        sessionId: SessionId(sessionId),
+        data: requestBodyChunks(request.body),
+        signal: request.signal,
+        ...(name === undefined ? {} : { name }),
+      }),
+    }
+  } catch (error) {
+    const failure = remoteErrorOf(error)
+    result = {
+      ok: false,
+      error: failure !== undefined
+        ? { code: failure.code, message: failure.message, details: failure.details }
+        : {
+          code: 'gateway/internal',
+          message: error instanceof Error ? error.message : String(error),
+          details: {},
+        },
+    }
+  }
+  return new Response(JSON.stringify(result), {
+    status: 200,
+    headers: {
+      'content-type': 'application/json; charset=utf-8',
+      'cache-control': 'no-store',
+    },
+  })
+}
+
+async function* requestBodyChunks(
+  body: ReadableStream<Uint8Array> | null,
+): AsyncIterable<Uint8Array> {
+  if (body === null) return
+  const reader = body.getReader()
+  try {
+    while (true) {
+      const chunk = await reader.read()
+      if (chunk.done) return
+      yield chunk.value
+    }
+  } finally {
+    reader.releaseLock()
+  }
+}

+ 2 - 0
packages/api/session-controller/src/file-upload-path.ts

@@ -0,0 +1,2 @@
+/** Authenticated raw-byte upload route shared by Host and Client faces. */
+export const SESSION_FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'

+ 22 - 0
packages/api/session-controller/src/index.ts

@@ -16,6 +16,7 @@ import { SessionCommandController } from './commands.ts'
 import { SessionControlController } from './control.ts'
 import { SessionHistoryController } from './history.ts'
 import { SessionFileReferences } from './file-references.ts'
+import { registerSessionFileUploadHttp } from './file-upload-http.ts'
 import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
 import { buildModelCatalog } from './catalog.ts'
 import { installModelSelectionProjection } from './model-selection-projection.ts'
@@ -49,6 +50,8 @@ import type {
   SessionSelectModelValue,
   SessionUpdateQueueRequest,
   SessionUpdateQueueValue,
+  SessionUploadFileRequest,
+  SessionUploadFileValue,
 } from './types.ts'
 
 export type * from './types.ts'
@@ -116,6 +119,7 @@ export class SessionController extends TypertRemoteService {
     installModelSelectionProjection(ctx)
     this.agents = new ApiSessionAgentController(ctx)
     this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
+    registerSessionFileUploadHttp(ctx, this.commands)
     this.controlState = new SessionControlController(ctx)
     // Registered before history so reverse-order teardown closes every
     // follower before waiting for already-admitted promotions.
@@ -137,6 +141,7 @@ export class SessionController extends TypertRemoteService {
       ctx.emit('api-session/added', this.listState.summaryFor(session))
     })
     ctx.on('session/disposed', (session) => {
+      this.commands.releaseStagedFiles(session.id)
       ctx.emit('api-session/removed', session.id)
     })
     ctx.on('agent/status', ({ agent, status }) => {
@@ -146,6 +151,10 @@ export class SessionController extends TypertRemoteService {
       ctx.emit('api-session/error', agent.id, errorChain(error))
     })
     ctx.on('session/event', (session, event) => {
+      if (event.type === 'user/message' && event.data.source.kind === 'user'
+        && 'rpcId' in event.data.source) {
+        this.commands.retireObservedPrompt(session.id, event.data.source.rpcId)
+      }
       if (event.type === 'request/header') {
         const agent = ctx.agents.get(session.id)
         if (agent?.session === session) this.agents.consumeSelection(
@@ -334,6 +343,19 @@ export class SessionController extends TypertRemoteService {
     return this.commands.attachment(request)
   }
 
+  /**
+   * Persist one encoded file upload verbatim and stage it for a later prompt
+   * on the same Session.
+   * @param request - Session identity, base64 payload, and optional display name.
+   * @param signal - caller cancellation before storage begins.
+   * @returns an opaque per-upload receipt and the durable file reference.
+   */
+  @Remote('uploadFile')
+  uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise<SessionUploadFileValue> {
+    signal.throwIfAborted()
+    return this.commands.uploadFile(request)
+  }
+
   /**
    * Mutate one still-pending queue occurrence on a live Agent.
    * @param request - Session, queue item, and requested mutation.

+ 26 - 2
packages/api/session-controller/src/types.ts

@@ -1,7 +1,7 @@
 /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
 
 import type {
-  AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
+  AttachmentIdType, FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
 } from '@deepseek-ai/dsh-attachment'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -68,7 +68,11 @@ export interface SessionProjectionBaseline {
 export type SessionProjectionValues = Partial<SessionProjectionMap>
   & Readonly<Record<string, SessionProjectionValue>>
 
-/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
+/**
+ * Browser-submitted prompt content; the Host promotes image bytes to durable
+ * references. File parts carry the opaque receipt returned by a preceding
+ * `uploadFile` call on the same Session.
+ */
 export type PromptContentPart =
   | { readonly type: 'text'; readonly text: string }
   | {
@@ -77,6 +81,7 @@ export type PromptContentPart =
     readonly data: string
     readonly name?: string
   }
+  | { readonly type: 'file'; readonly receiptId: FileUploadReceiptId }
 
 /** Complete model selection for one Session. */
 export interface ModelSelection {
@@ -313,6 +318,22 @@ export interface SessionPromptValue {
   readonly accepted: true
 }
 
+/** One base64 file upload staged for a later prompt on the same Session. */
+export interface SessionUploadFileRequest {
+  readonly sessionId: SessionId
+  /** Canonical base64 encoding of the exact file bytes. */
+  readonly data: string
+  /** Optional display name; the Host sanitizes it into the stored leaf name. */
+  readonly name?: string
+}
+
+/** Durable receipt for one staged file upload. */
+export interface SessionUploadFileValue {
+  /** Per-upload authority consumed by a later prompt on the same Session. */
+  readonly receiptId: FileUploadReceiptId
+  readonly file: FileAttachmentRef
+}
+
 /** Durable image read request. */
 export interface SessionAttachmentRequest {
   readonly sessionId: SessionId
@@ -361,6 +382,9 @@ export interface SessionOpenWorkspacePathValue {
 /** Client-minted prompt identity used to reconcile optimistic and durable messages. */
 export type SessionRequestId = Branded<'session-request-id'>
 
+/** Host-minted authority for one staged file upload on one Session. */
+export type FileUploadReceiptId = Branded<'file-upload-receipt-id'>
+
 declare module '@deepseek-ai/dsh-llm' {
   interface MessageSourceMap {
     /** Browser prompt correlation and optional Host-validated time zone. */

+ 2 - 0
packages/api/session-controller/tsconfig.host.json

@@ -14,6 +14,8 @@
     "src/commands.ts",
     "src/control.ts",
     "src/file-references.ts",
+    "src/file-upload-path.ts",
+    "src/file-upload-http.ts",
     "src/history.ts",
     "src/list.ts",
     "src/model-selection-projection.ts",

+ 5 - 5
packages/goal/command-goal/src/index.ts

@@ -108,15 +108,15 @@ function missingGoal(action: string): CommandResult {
 }
 
 /**
- * Submit the invocation's admitted composer images as one model-visible user
- * message ahead of the goal's next round. The images precede a fixed text
+ * Submit the invocation's admitted composer attachments as one model-visible user
+ * message ahead of the goal's next round. The attachments precede a fixed text
  * block naming their role, so a later goal round reads them from ordinary
  * session history without the goal domain storing attachment state.
  */
 function submitObjectiveAttachments(invocation: CommandInvocation): void {
   if (invocation.attachments.length === 0) return
   invocation.agent.followup(createUserMessage({
-    content: [...invocation.attachments, { type: 'text', text: 'Reference images for the goal objective.' }],
+    content: [...invocation.attachments, { type: 'text', text: 'Reference attachments for the goal objective.' }],
     source: { kind: 'user' },
   }))
 }
@@ -127,7 +127,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman
   if (invocation.attachments.length > 0 && command.kind !== 'create' && command.kind !== 'edit') {
     return {
       kind: 'error',
-      text: 'Image attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
+      text: 'Attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
     }
   }
   try {
@@ -190,7 +190,7 @@ export function apply(ctx: Context): void {
   ctx.commands.register({
     name: 'goal',
     description: 'set or view the goal for a long-running task',
-    input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', images: true },
+    input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
     handler: invocation => executeGoalCommand(ctx, invocation),
   })
 }

+ 91 - 24
packages/interaction/commands/src/index.ts

@@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
-import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
-import type { ImageBlock } from '@deepseek-ai/dsh-llm'
+import type { EncodedImageAttachment, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types'
+import type { FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
 import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
 import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
 import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
@@ -19,6 +19,7 @@ import type {
   CommandExecution,
   CommandInputDescriptor,
   CommandResult,
+  CommandSubmitAttachment,
 } from './types.ts'
 
 export { CommandId } from './brand.ts'
@@ -28,8 +29,11 @@ export const name = 'commands'
 
 const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
 
-/** Shared frozen attachments value for image-free invocations. */
-const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([])
+/** Shared frozen attachments value for attachment-free invocations. */
+const NO_ATTACHMENTS: readonly (ImageBlock | FileBlock)[] = Object.freeze([])
+
+/** Host resolver for Session-scoped staged file-upload receipts. */
+export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined
 
 /** Invocation passed to one registered command handler. */
 export interface CommandInvocation {
@@ -40,13 +44,13 @@ export interface CommandInvocation {
   /** Exact text following the registered command name, including separator whitespace. */
   readonly rawInput: string
   /**
-   * Durably admitted image blocks accompanying this invocation, in submission
-   * order; empty unless the definition declares `input.images`. The handler
+   * Durably admitted image and file blocks accompanying this invocation, in submission
+   * order; empty unless the definition declares `input.attachments`. The handler
    * owns their model-visible use — the registry never schedules them itself —
    * and a handler whose grammar cannot use them in this invocation returns an
    * error so the dispatching composer retains the originals.
    */
-  readonly attachments: readonly ImageBlock[]
+  readonly attachments: readonly (ImageBlock | FileBlock)[]
   /** Cancellation signal owned by the dispatching UI request. */
   readonly signal: AbortSignal
 }
@@ -191,12 +195,12 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
     if (rawInput.hint.trim().length === 0) {
       throw new TypeError(`command "${definition.name}" input hint must not be empty`)
     }
-    if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') {
-      throw new TypeError(`command "${definition.name}" input images flag must be a boolean`)
+    if ('attachments' in rawInput && rawInput.attachments !== undefined && typeof rawInput.attachments !== 'boolean') {
+      throw new TypeError(`command "${definition.name}" input attachments flag must be a boolean`)
     }
     input = Object.freeze({
       hint: rawInput.hint,
-      ...('images' in rawInput && rawInput.images === true) ? { images: true } : {},
+      ...('attachments' in rawInput && rawInput.attachments === true) ? { attachments: true } : {},
     })
   }
   const normalized = Object.freeze({
@@ -258,6 +262,8 @@ export class CommandRuntime extends TypertRemoteService {
   private commandSeq = 0
   /** Instance token keeping minted ids unique across process restarts over one resumed log. */
   private readonly instanceToken = randomUUID().slice(0, 8)
+  /** Optional provider installed by the Session upload owner. */
+  private readonly fileReceipts: { resolver: CommandFileReceiptResolver | undefined } = { resolver: undefined }
 
   constructor(ctx: Context) {
     super(ctx, 'commands')
@@ -277,6 +283,21 @@ export class CommandRuntime extends TypertRemoteService {
     )
   }
 
+  /**
+   * Register the sole authority that resolves staged file receipts for command submissions.
+   * @param resolver - Session-aware receipt resolver.
+   * @returns disposer that removes this exact resolver.
+   */
+  registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void {
+    if (this.fileReceipts.resolver !== undefined) {
+      throw new Error('commands: a file receipt resolver is already registered')
+    }
+    this.fileReceipts.resolver = resolver
+    return () => {
+      if (this.fileReceipts.resolver === resolver) this.fileReceipts.resolver = undefined
+    }
+  }
+
   /**
    * List the effective immutable command descriptors for one agent.
    * @param agent - exact receiving agent and scoped-layer key.
@@ -313,15 +334,17 @@ export class CommandRuntime extends TypertRemoteService {
    * handler-failure path is contained so the handler's own error stays the
    * reported failure.
    *
-   * Image admission is enforced here, not in the composer: images sent to a
-   * command that does not declare `input.images`, an absent attachment store,
-   * and an exceeded attachment limit each settle as an error result before
-   * the handler runs, and a rejected batch publishes no durable object.
+   * Attachment admission is enforced here, not in the composer: attachments sent to a
+   * command that does not declare `input.attachments`, an absent attachment store,
+   * and an exceeded image limit each settle as an error result before
+   * the handler runs. Validation rejection starts no attachment writes;
+   * a storage failure can leave only unreachable content-addressed objects
+   * for deferred collection.
    *
    * @param agent - exact receiving agent.
    * @param line - complete slash-command line.
-   * @param images - base64-encoded composer images accompanying the line, in
-   *   submission order; empty for a plain invocation.
+   * @param submittedAttachments - encoded images and staged file receipts accompanying the line,
+   *   in submission order; empty for a plain invocation.
    * @param signal - cancellation signal owned by the UI request.
    * @returns the settled execution (result + lifecycle pairing id), or
    *   `undefined` when syntax or name does not resolve.
@@ -330,7 +353,7 @@ export class CommandRuntime extends TypertRemoteService {
   async execute(
     agent: Agent,
     line: string,
-    images: readonly EncodedImageAttachment[],
+    submittedAttachments: readonly CommandSubmitAttachment[],
     signal: AbortSignal,
   ): Promise<CommandExecution | undefined> {
     const parsed = parseCommand(line)
@@ -355,18 +378,21 @@ export class CommandRuntime extends TypertRemoteService {
       })
       return Object.freeze({ commandId, result: Object.freeze(result) })
     }
-    let attachments: readonly ImageBlock[] = NO_ATTACHMENTS
-    if (images.length > 0) {
-      if (command.definition.input?.images !== true) {
-        return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` })
+    let attachments: readonly (ImageBlock | FileBlock)[] = NO_ATTACHMENTS
+    if (submittedAttachments.length > 0) {
+      if (command.definition.input?.attachments !== true) {
+        return settle({ kind: 'error', text: `/${parsed.name} does not accept attachments` })
       }
       const store = this.ctx.get('attachments')
       if (store === undefined) {
-        return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` })
+        return settle({ kind: 'error', text: `/${parsed.name}: attachments are unavailable because no attachment store is composed` })
       }
       try {
-        const refs = await admitEncodedImages(store, images)
-        attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref })))
+        attachments = await admitCommandAttachments(
+          store,
+          submittedAttachments,
+          receiptId => this.fileReceipts.resolver?.(agent, receiptId),
+        )
       } catch (error: unknown) {
         if (error instanceof AttachmentError) {
           return settle({ kind: 'error', text: error.message })
@@ -455,4 +481,45 @@ export class CommandRuntime extends TypertRemoteService {
   }
 }
 
+/** Admit a mixed command batch and restore its original image/file order. */
+async function admitCommandAttachments(
+  store: Parameters<typeof admitEncodedImages>[0],
+  attachments: readonly CommandSubmitAttachment[],
+  resolveFileReceipt: (receiptId: string) => FileAttachmentRef | undefined,
+): Promise<readonly (ImageBlock | FileBlock)[]> {
+  const files = new Map<string, FileAttachmentRef>()
+  for (const attachment of attachments) {
+    if (attachment.type !== 'file' || files.has(attachment.receiptId)) continue
+    const file = resolveFileReceipt(attachment.receiptId)
+    if (file === undefined) {
+      throw new AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND')
+    }
+    files.set(attachment.receiptId, file)
+  }
+  const images: EncodedImageAttachment[] = []
+  for (const attachment of attachments) {
+    if (attachment.type !== 'image') continue
+    images.push({
+      mediaType: attachment.mediaType,
+      data: attachment.data,
+      ...(attachment.name === undefined ? {} : { name: attachment.name }),
+    })
+  }
+  const imageRefs = images.length === 0 ? [] : await admitEncodedImages(store, images)
+  let imageIndex = 0
+  const blocks: Array<ImageBlock | FileBlock> = []
+  for (const attachment of attachments) {
+    if (attachment.type === 'image') {
+      const ref = imageRefs[imageIndex] as ImageAttachmentRef
+      imageIndex += 1
+      blocks.push(Object.freeze({ type: 'image', attachment: ref }))
+      continue
+    }
+    blocks.push(Object.freeze({
+      type: 'file', attachment: files.get(attachment.receiptId) as FileAttachmentRef,
+    }))
+  }
+  return Object.freeze(blocks)
+}
+
 export default CommandRuntime

+ 9 - 3
packages/interaction/commands/src/types.ts

@@ -8,19 +8,25 @@
  */
 
 import type { CommandId } from './brand.ts'
+import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
+
+/** One browser-submitted command attachment: encoded image input or a staged file receipt. */
+export type CommandSubmitAttachment =
+  | ({ readonly type: 'image' } & EncodedImageAttachment)
+  | { readonly type: 'file'; readonly receiptId: string }
 
 /** Immutable metadata for a command's optional unstructured input. */
 export interface CommandInputDescriptor {
   /** Placeholder shown before the user supplies free-form input. */
   readonly hint: string
   /**
-   * Whether composer image attachments may accompany an invocation. Absent or
-   * false = the executor rejects an invocation carrying images and capable
+   * Whether composer attachments may accompany an invocation. Absent or
+   * false = the executor rejects an invocation carrying attachments and capable
    * composers refuse the submission before dispatch. A declaring command's
    * handler receives the admitted durable blocks and owns every further
    * grammar decision, including rejecting sub-commands that cannot use them.
    */
-  readonly images?: boolean
+  readonly attachments?: boolean
 }
 
 /** Expected command outcome rendered directly by the dispatching UI. */

+ 2 - 2
packages/plan/plan-mode/src/index.ts

@@ -225,11 +225,11 @@ export class PlanModeController extends Service {
       commandCtx.commands.register({
         name: 'plan',
         description: 'Enter or leave plan mode',
-        input: { hint: '[off|message]', images: true },
+        input: { hint: '[off|message]', attachments: true },
         handler: ({ agent, rawInput, attachments }) => {
           const message = rawInput.trim()
           if (message === 'off' && attachments.length > 0) {
-            return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' }
+            return { kind: 'error', text: 'Attachments cannot accompany /plan off.' }
           }
           if (message === 'off') {
             switch (this.set(agent, false)) {

+ 1 - 0
tsconfig.host.json

@@ -50,6 +50,7 @@
     "apps/web/tests/composer-draft-scroll.e2e.ts",
     "apps/web/tests/cordis-tool-round.e2e.ts",
     "apps/web/tests/web-search-round.e2e.ts",
+    "apps/web/tests/file-upload-round.e2e.ts",
     "apps/web/tests/message-actions.e2e.ts",
     "apps/web/tests/message-feedback.e2e.ts",
     "apps/web/tests/message-feedback-layout.e2e.ts",