Ver Fonte

test: cover generic file submission flows

creatixchu há 1 semana atrás
pai
commit
e9b7ed0aab
100 ficheiros alterados com 2981 adições e 432 exclusões
  1. 12 12
      apps/web/tests/command-image-envelope.expected.e2e.ts
  2. 9 0
      apps/web/tests/expected/file-upload-round/draft.expected.md
  3. 10 0
      apps/web/tests/expected/file-upload-round/history.expected.md
  4. 2 0
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  5. 2 0
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  6. 2 0
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  7. 2 0
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  8. 2 0
      apps/web/tests/expected/markdown-images/ui.expected.md
  9. 2 0
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  10. 2 0
      apps/web/tests/expected/math-rendering/ui.expected.md
  11. 2 0
      apps/web/tests/expected/reference-composer/order.expected.md
  12. 2 0
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  13. 2 0
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  14. 2 0
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  15. 2 0
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  16. 2 0
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  17. 2 0
      apps/web/tests/expected/steer-all/settled.expected.md
  18. 283 0
      apps/web/tests/file-upload-round.e2e.ts
  19. 12 16
      apps/web/tests/image-display.expected.e2e.ts
  20. 31 11
      apps/web/tests/scaffold.ts
  21. 2 0
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  22. 2 2
      apps/web/tests/submission-echo.e2e.ts
  23. 1 0
      apps/web/tsconfig.json
  24. 1 0
      packages/api/session-controller/tests/client-apply.client.spec.ts
  25. 3 2
      packages/api/session-controller/tests/client-contract.client.spec.ts
  26. 17 0
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  27. 376 0
      packages/api/session-controller/tests/commands-upload-file.host.spec.ts
  28. 25 1
      packages/api/session-controller/tests/controller.host.spec.ts
  29. 8 0
      packages/api/session-controller/tests/fake-api.client.ts
  30. 143 0
      packages/api/session-controller/tests/file-upload-http.host.spec.ts
  31. 13 5
      packages/api/session-controller/tests/queue-store.client.spec.ts
  32. 12 3
      packages/api/session-controller/tests/session-cold.host.spec.ts
  33. 56 22
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  34. 144 0
      packages/api/session-controller/tests/session.client.spec.ts
  35. 193 0
      packages/client/connection/tests/background-upload.client.spec.ts
  36. 16 4
      packages/client/connection/tests/fetch-routes.host.spec.ts
  37. 6 6
      packages/client/connection/tests/fixture-commands.client.spec.ts
  38. 72 0
      packages/client/connection/tests/http-bridge.host.spec.ts
  39. 14 19
      packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx
  40. 127 21
      packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx
  41. 9 4
      packages/client/ui-attachment/tests/message-image.client.spec.tsx
  42. 26 0
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  43. 71 14
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  44. 6 6
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  45. 33 33
      packages/client/ui-commands/tests/service.client.spec.ts
  46. 46 1
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  47. 1 0
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  48. 58 38
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  49. 39 33
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  50. 19 19
      packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts
  51. 16 14
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  52. 42 2
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  53. 393 31
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  54. 6 4
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  55. 2 2
      packages/client/ui-conversation/tests/submit-machine.client.spec.ts
  56. 5 5
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  57. 14 0
      packages/client/ui-primitives/tests/file-size.client.spec.ts
  58. 3 3
      packages/client/ui-tool/tests/diff-card.client.spec.tsx
  59. 3 3
      packages/client/ui-tool/tests/read-card.client.spec.tsx
  60. 3 3
      packages/client/ui-tool/tests/search-card.client.spec.tsx
  61. 8 2
      packages/client/ui-tool/tests/terminal-card.client.spec.tsx
  62. 3 3
      packages/client/ui-tool/tests/web-card.client.spec.tsx
  63. 43 0
      packages/client/ui-trajectory/tests/layout.client.spec.tsx
  64. 8 8
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  65. 4 4
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  66. 4 4
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  67. 3 3
      packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
  68. 45 11
      packages/experimental/inspector/tests/port-selection.host.spec.ts
  69. 16 0
      packages/experimental/webworker-runtime/tests/transport/frames.spec.ts
  70. 9 0
      packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts
  71. 38 0
      packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts
  72. 86 7
      packages/extensions/tool-cordis/src/api-catalog.ts
  73. 28 17
      packages/goal/command-goal/tests/command-goal.spec.ts
  74. 65 21
      packages/interaction/commands/tests/commands.spec.ts
  75. 24 8
      packages/plan/plan-mode/tests/plan-mode.spec.ts
  76. 1 1
      packages/subagent/subagent-codex/tests/real-product.spec.ts
  77. 1 0
      packages/test-support/client-runtime/tests/runtime.client.spec.tsx
  78. 4 4
      packages/test-support/session-snapshot/tests/harness.spec.ts
  79. 3 0
      pnpm-lock.yaml
  80. 8 0
      scripts/gen-cordis-catalog.ts
  81. 3 0
      scripts/verify-client-ui-i18n.ts
  82. 2 0
      snapshots/web/bash-abort-row/ui.expected.md
  83. 2 0
      snapshots/web/cordis-tool-round/ui.expected.md
  84. 2 0
      snapshots/web/feedback-command/ack-expanded.expected.md
  85. 2 0
      snapshots/web/feedback-command/ack.expected.md
  86. 2 0
      snapshots/web/feedback-release/ack-expanded.expected.md
  87. 2 0
      snapshots/web/feedback-release/ack.expected.md
  88. 43 0
      snapshots/web/file-upload-round/replay.override.json
  89. 31 0
      snapshots/web/file-upload-round/session.jsonl
  90. 9 0
      snapshots/web/file-upload-round/snapshot.yml
  91. 3 0
      snapshots/web/file-upload-round/trajectory.expected.md
  92. 52 0
      snapshots/web/file-upload-round/ui.expected.md
  93. 2 0
      snapshots/web/fresh-round-trip/ui-expanded.expected.md
  94. 2 0
      snapshots/web/fresh-round-trip/ui.expected.md
  95. 2 0
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  96. 2 0
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  97. 2 0
      snapshots/web/lifecycle-chrome/hero.expected.md
  98. 2 0
      snapshots/web/lifecycle-chrome/plan-active.expected.md
  99. 2 0
      snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
  100. 2 0
      snapshots/web/lifecycle-chrome/reloaded.expected.md

+ 12 - 12
apps/web/tests/command-image-envelope.expected.e2e.ts

@@ -1,8 +1,8 @@
 // @vitest-environment jsdom
-// The command image-attachment envelope over the BUILT client graph (real
+// The command attachment envelope over the BUILT client graph (real
 // bundles via AppWebEntry, keyless fixture Connection RPC): an enter
-// submission carrying composer images resolves only through a command whose
-// descriptor declares `input.images`. A non-declaring command refuses with
+// submission carrying composer attachments resolves only through a command whose
+// descriptor declares `input.attachments`. A non-declaring command refuses with
 // one composer error banner and everything retained; a declaring command
 // consumes the images — serialized through the real draft-image chain into
 // the commands/execute payload — and clears the composer on success, including
@@ -47,7 +47,7 @@ async function pasteImage(textarea: HTMLElement, name: string): Promise<void> {
     },
   })
   await waitFor(() => {
-    const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (rail === null) throw new Error('attachment rail missing')
     expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name)
   }, { timeout: 5_000 })
@@ -58,7 +58,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   const textarea = await freshComposer()
   await pasteImage(textarea, 'ref.png')
 
-  // /echo is a leadingInput fixture command without `input.images`.
+  // /echo is a leadingInput fixture command without `input.attachments`.
   await pasteText(textarea, '/echo hello')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
@@ -66,16 +66,16 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft
   // failures; session activity remains on its separate status live region.
   const notice = await waitFor(() => {
     const el = [...document.querySelectorAll('[role="alert"]')]
-      .find(candidate => candidate.textContent?.includes('image attachments') ?? false)
+      .find(candidate => candidate.textContent?.includes('attachments') ?? false)
     if (el === undefined) throw new Error('composer refusal banner missing')
     return el
   }, { timeout: 5_000 })
-  expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first')
+  expect(notice.textContent).toBe('/echo does not accept attachments; remove them first')
   expect([...document.querySelectorAll('[role="status"]')]
-    .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false)
+    .some(candidate => candidate.textContent?.includes('attachments') ?? false)).toBe(false)
   // The whole envelope is retained: draft text and the rail thumbnail.
   await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') })
-  const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+  const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
   expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
 })
 
@@ -84,14 +84,14 @@ it('consumes images through a declaring command and clears the composer on succe
   const textarea = await freshComposer()
   await pasteImage(textarea, 'goal-ref.png')
 
-  // /goal declares `input.images` in the fixture catalog; the claim submit
+  // /goal declares `input.attachments` in the fixture catalog; the claim submit
   // serializes the pasted bytes and the fixture executor admits them.
   await pasteText(textarea, '/goal rebuild the cathedral')
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
   await waitFor(() => {
     expect(textarea.textContent).toBe('')
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   }, { timeout: 5_000 })
 })
 
@@ -107,7 +107,7 @@ it('submits a bare /plan with an image as an image-only plan request', async ()
 
   await waitFor(() => {
     expect(textarea.textContent).toBe('')
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   }, { timeout: 5_000 })
   expect([...document.querySelectorAll('[role="alert"]')]
     .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false)

+ 9 - 0
apps/web/tests/expected/file-upload-round/draft.expected.md

@@ -0,0 +1,9 @@
+# Mixed composer attachment rail
+
+- selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png
+- one attachment group: true
+- all cards share one row: true
+- every card is 64px high: true
+- the file card is wider than an image: true
+- overflowing cards scroll horizontally: true
+- the rail does not wrap: true

+ 10 - 0
apps/web/tests/expected/file-upload-round/history.expected.md

@@ -0,0 +1,10 @@
+# Mixed history attachment flow
+
+- source order: poem.txt > reference-1.png
+- one attachment group: true
+- file and image share one row: true
+- both cards are 64px high: true
+- the image is a 64px tile: true
+- the file card is wider than the image: true
+- the group wraps when needed: true
+- the group is right-aligned: true

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -51,6 +51,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
   - text: github-webhook-review-test/reply

+ 2 - 0
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -43,6 +43,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Read Only"': Read Only
 - button "Select model, current github-webhook-review-test/reply":
   - text: github-webhook-review-test/reply

+ 2 - 0
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -17,6 +17,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -51,6 +51,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -30,6 +30,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -42,6 +42,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -46,6 +46,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/reference-composer/order.expected.md

@@ -16,6 +16,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -43,6 +43,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -35,6 +35,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -462,6 +462,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -34,6 +34,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -53,6 +53,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
apps/web/tests/expected/steer-all/settled.expected.md

@@ -41,6 +41,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 283 - 0
apps/web/tests/file-upload-round.e2e.ts

@@ -0,0 +1,283 @@
+// Web e2e scenario: generic file upload round trip. A real chromium picks a
+// file through the composer paperclip input; the upload RPC stores the exact
+// bytes below the scaffold's isolated DSH_HOME, the prompt cites the staged
+// reference, request assembly projects the file block to handle text, and the
+// model (replayed or live) reads the saved copy with the REAL read tool. The
+// content-addressed store makes the saved path identical across record and
+// replay once the workspace cwd is tokenized, so the recorded read arguments
+// replay verbatim against a freshly re-uploaded object.
+// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
+// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.jsonl', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url))
+const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url))
+const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url))
+const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url))
+const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url))
+const IMAGE_FIXTURE = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
+const MODE = webSnapshotMode()
+
+/** The uploaded fixture file: constant bytes so record and replay share one content digest. */
+const FILE_NAME = 'poem.txt'
+const FILE_TEXT = 'UPLOAD_ROUND_OK\n'
+const PROMPT = 'Read the attached file with the read tool, reply with exactly the single word it contains, and stop.'
+const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${String(index + 1)}.png`)
+
+/** Browser-measured relations for the mixed composer attachment rail. */
+interface DraftRailGeometry {
+  readonly order: readonly string[]
+  readonly oneGroup: boolean
+  readonly oneRow: boolean
+  readonly equalHeight: boolean
+  readonly fileWider: boolean
+  readonly horizontalOverflow: boolean
+  readonly noWrap: boolean
+}
+
+/** Render stable relations instead of platform-dependent absolute coordinates. */
+function renderDraftRailGeometry(geometry: DraftRailGeometry): string {
+  return [
+    '# Mixed composer attachment rail',
+    '',
+    `- selection order: ${geometry.order.join(' > ')}`,
+    `- one attachment group: ${String(geometry.oneGroup)}`,
+    `- all cards share one row: ${String(geometry.oneRow)}`,
+    `- every card is 64px high: ${String(geometry.equalHeight)}`,
+    `- the file card is wider than an image: ${String(geometry.fileWider)}`,
+    `- overflowing cards scroll horizontally: ${String(geometry.horizontalOverflow)}`,
+    `- the rail does not wrap: ${String(geometry.noWrap)}`,
+  ].join('\n')
+}
+
+/** Browser-measured relations for one durable mixed-attachment message. */
+interface HistoryAttachmentGeometry {
+  readonly order: readonly string[]
+  readonly oneGroup: boolean
+  readonly oneRow: boolean
+  readonly equalHeight: boolean
+  readonly imageIsTile: boolean
+  readonly fileWider: boolean
+  readonly wrapsWhenNeeded: boolean
+  readonly rightAligned: boolean
+}
+
+/** Render stable history-layout relations instead of absolute coordinates. */
+function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): string {
+  return [
+    '# Mixed history attachment flow',
+    '',
+    `- source order: ${geometry.order.join(' > ')}`,
+    `- one attachment group: ${String(geometry.oneGroup)}`,
+    `- file and image share one row: ${String(geometry.oneRow)}`,
+    `- both cards are 64px high: ${String(geometry.equalHeight)}`,
+    `- the image is a 64px tile: ${String(geometry.imageIsTile)}`,
+    `- the file card is wider than the image: ${String(geometry.fileWider)}`,
+    `- the group wraps when needed: ${String(geometry.wrapsWhenNeeded)}`,
+    `- the group is right-aligned: ${String(geometry.rightAligned)}`,
+  ].join('\n')
+}
+
+describe('web e2e: generic file upload through the real assembly', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  const sessionEvents: SessionEvent[] = []
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      // The override rescripts the recorded read arguments with a
+      // `{{fromRequest:…}}` placeholder: the saved-copy path differs per run,
+      // and the live handle line in the request carries the current one.
+      ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE, paceMs: 15 }),
+    })
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    await page.setViewportSize({ width: 900, height: 900 })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('uploads on pick, gates send on the staged receipt, and settles the turn (all modes)', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-drive'))
+    if (MODE !== 'record') {
+      // Drift guard: the committed fixture must carry exactly the drive prompt.
+      expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    }
+    const input = page.locator('[data-composer-input]').first()
+    await input.waitFor({ timeout: 10_000 })
+    const modelTrigger = page.getByRole('button', { name: /^Select model, current/ })
+    await modelTrigger.click()
+    await page.getByRole('menuitem', { name: /^Model\b/ }).click()
+    await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).click()
+    await expect.poll(() => modelTrigger.getAttribute('aria-label'), { timeout: 10_000 })
+      .toContain('DeepSeek-V4-Flash-Vision-Exp')
+    const imageBytes = await readFile(IMAGE_FIXTURE)
+    // Pick through the composer's hidden file input: the upload RPC runs
+    // immediately and the pending card appears before any prompt is typed.
+    await page.locator('input[type="file"]').setInputFiles([
+      { name: FILE_NAME, mimeType: 'text/plain', buffer: Buffer.from(FILE_TEXT) },
+      ...IMAGE_NAMES.map(name => ({ name, mimeType: 'image/png', buffer: imageBytes })),
+    ])
+    await page.getByTitle(FILE_NAME).waitFor({ timeout: 10_000 })
+    const rail = page.getByRole('group', { name: 'Pending attachments' })
+    await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 })
+      .toBe(IMAGE_NAMES.length + 1)
+    const geometry = await rail.evaluate((element): DraftRailGeometry => {
+      const cards = [...element.children] as HTMLElement[]
+      const boxes = cards.map(card => card.getBoundingClientRect())
+      const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0
+      const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0
+      return {
+        order: cards.map(card => card.querySelector('img')?.getAttribute('alt')
+          ?? card.querySelector<HTMLElement>('[title]')?.title ?? ''),
+        oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1,
+        oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
+        equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
+        fileWider: fileWidth > imageWidth,
+        horizontalOverflow: element.scrollWidth > element.clientWidth,
+        noWrap: getComputedStyle(element).flexWrap === 'nowrap',
+      }
+    })
+    await compareOrRefreshGolden(DRAFT_EXPECTED, renderDraftRailGeometry(geometry), MODE)
+    for (const name of IMAGE_NAMES.slice(1)) {
+      await page.getByRole('button', { name: `Remove image ${name}` }).click({ force: true })
+    }
+    await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }).toBe(2)
+    await input.fill(PROMPT)
+    // Send unlocks only after the upload receipt lands (the staged file gate).
+    const send = page.getByRole('button', { name: 'Send message' })
+    await send.waitFor({ state: 'visible', timeout: 15_000 })
+    await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true)
+    const settled = scaffold.whenTurnSettled()
+    await send.click()
+    const restoreDeadline = Date.now() + 10_000
+    let retainedCards = 0
+    while (retainedCards === 0
+      && !sessionEvents.some(event => event.type === 'turn/start')
+      && Date.now() < restoreDeadline) {
+      await page.waitForTimeout(100)
+      retainedCards = await rail.locator(':scope > *').count()
+    }
+    if (retainedCards !== 0) {
+      const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents()
+      throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`)
+    }
+    const sessionId = await settled
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+  }, 200_000)
+
+  it.skipIf(MODE === 'record')('persists the file block and reads the stored copy with the real read tool', () => {
+    const userMessage = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'user/message' }> =>
+        event.type === 'user/message' && event.data.source.kind === 'user',
+    )
+    if (userMessage === undefined) throw new Error('the replayed turn recorded no user message')
+    const fileBlock = userMessage.data.content.find(block => block.type === 'file')
+    if (fileBlock?.type !== 'file') throw new Error('the user message carries no file block')
+    expect(fileBlock.attachment.name).toBe(FILE_NAME)
+    expect(fileBlock.attachment.bytes).toBe(Buffer.byteLength(FILE_TEXT))
+    expect(String(fileBlock.attachment.attachmentId)).toMatch(/^sha256:[0-9a-f]{64}$/)
+
+    const readCall = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
+        event.type === 'tool/call' && event.data.name === 'read',
+    )
+    if (readCall === undefined) throw new Error('the replayed turn did not call the read tool')
+    expect(readCall.data.arguments).toContain(FILE_NAME)
+    const readResult = sessionEvents.find(
+      (event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
+        event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId,
+    )
+    if (readResult === undefined) throw new Error('the read call produced no durable result')
+    const content = readResult.data.message.content[0]
+    expect(content.isError).toBe(false)
+    expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
+      .toContain('UPLOAD_ROUND_OK')
+
+    const turnEnds = sessionEvents.filter(event => event.type === 'turn/end')
+    expect(turnEnds.length).toBe(1)
+    expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
+  })
+
+  it.skipIf(MODE === 'record')('renders the durable file card beside the settled answer', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-aria'))
+    await expect.poll(() => page.getByText('UPLOAD_ROUND_OK', { exact: false }).count(), { timeout: 15_000 })
+      .toBeGreaterThanOrEqual(1)
+    await page.getByTitle(FILE_NAME).first().waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('keeps a mixed durable message in one ordered wrapping attachment flow', async () => {
+    const groups = page.locator('[data-message-attachments]')
+    await expect.poll(() => groups.count(), { timeout: 10_000 }).toBe(1)
+    const geometry = await groups.first().evaluate((element): HistoryAttachmentGeometry => {
+      const cards = [...element.children] as HTMLElement[]
+      const renderedCards = cards.map((card) => {
+        const box = card.getBoundingClientRect()
+        return box.width === 0 && box.height === 0 && card.firstElementChild instanceof HTMLElement
+          ? card.firstElementChild
+          : card
+      })
+      const boxes = renderedCards.map(card => card.getBoundingClientRect())
+      const imageIndex = cards.findIndex(card => card.querySelector('img') !== null)
+      const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt')
+      const imageBox = boxes[imageIndex]
+      const fileBox = boxes[fileIndex]
+      return {
+        order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''),
+        oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1,
+        oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5),
+        equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5),
+        imageIsTile: imageBox !== undefined && Math.abs(imageBox.width - 64) < 0.5,
+        fileWider: fileBox !== undefined && imageBox !== undefined && fileBox.width > imageBox.width,
+        wrapsWhenNeeded: getComputedStyle(element).flexWrap === 'wrap',
+        rightAligned: getComputedStyle(element).justifyContent === 'flex-end',
+      }
+    })
+    await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE)
+  })
+
+  it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => {
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
+    await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(
+      page,
+      '[data-trajectory-row-key][aria-label*="Files ×1"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md',
+    ])
+  })
+})

+ 12 - 16
apps/web/tests/image-display.expected.e2e.ts

@@ -106,7 +106,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
   // The rail is an accessible group holding the draft thumbnail (queried via
   // DOM: jsdom's a11y-visibility computation hides the composer subtree).
   const rail = await waitFor(() => {
-    const el = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const el = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (el === null) throw new Error('attachment rail missing')
     return el
   }, { timeout: 5_000 })
@@ -137,23 +137,19 @@ it('accepts pasted images into the composer rail in order and removes them', asy
   if (remove.length !== 2) throw new Error('remove buttons missing')
   for (const button of remove) fireEvent.click(button)
   await waitFor(() => {
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
   })
 
-  // An unsupported file announces a transient toast (the inline strip is
-  // gone) and the banner dismisses itself after its hold-and-fade lifetime.
+  // A non-image paste follows the generic-file path and remains in the
+  // composer as a file card.
   fireEvent.paste(textarea, {
     clipboardData: {
       items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }],
       getData: () => '',
     },
   })
-  const unsupportedMessage = 'Only PNG, JPG, WebP, and GIF images are supported'
-  const toast = await screen.findByText(unsupportedMessage)
-  expect(toast.closest('[role="alert"]')).not.toBeNull()
-  await waitFor(() => {
-    expect(screen.queryByText(unsupportedMessage)).toBeNull()
-  }, { timeout: 6_000 })
+  const files = await screen.findByRole('group', { name: 'Pending attachments' })
+  expect(files.textContent).toContain('notes.txt')
 })
 
 it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => {
@@ -178,15 +174,15 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
   fireEvent.dragEnter(document.body, { dataTransfer })
   const overlay = await screen.findByRole('status')
-  expect(overlay.textContent).toContain('Drag images here to add them')
+  expect(overlay.textContent).toContain('Drag files or images here to add them')
   await waitFor(() => {
-    expect(overlay.textContent).toContain('Up to 20 images, 5MB each')
+    expect(overlay.textContent).toContain('Image limit: up to 20 images, 5MB each')
   })
 
   // Dropping on the transcript area (not the composer card) lands in the rail.
   fireEvent.drop(document.body, { dataTransfer })
   await waitFor(() => {
-    const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+    const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
     if (rail === null) throw new Error('attachment rail missing after page drop')
     expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png'])
   }, { timeout: 5_000 })
@@ -206,7 +202,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   const limitMessage = 'A message can include up to 20 images'
   const banner = await screen.findByText(limitMessage)
   expect(banner.closest('[role="alert"]')).not.toBeNull()
-  const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
+  const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
   expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1)
 })
 
@@ -233,7 +229,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
     },
   })
   await waitFor(() => {
-    expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+    expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull()
   })
   fireEvent.keyDown(textarea, { key: 'Enter' })
 
@@ -245,5 +241,5 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
       "text": "Image sides must be at most 2000px; downscale it and try again",
     }
   `)
-  expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull()
+  expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull()
 })

+ 31 - 11
apps/web/tests/scaffold.ts

@@ -148,7 +148,18 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
 const REPLAY_PROVIDERS = [{
   id: 'deepseek-official',
   name: 'DeepSeek',
-  models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
+  models: [
+    { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 },
+    {
+      id: 'deepseek-v4-flash-vision-exp',
+      name: 'DeepSeek-V4-Flash-Vision-Exp',
+      contextWindow: 1_000_000,
+      inputModalities: ['text', 'image'] as const,
+      defaultMaxTokens: 256_000,
+      reasoningEfforts: ['off', 'low', 'high', 'max'],
+      defaultReasoningEffort: 'high',
+    },
+  ],
 }]
 
 /**
@@ -768,6 +779,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
             options.replayFixture,
             mode,
             `http://${browserHost}:${port}`,
+            harnessHome,
           )
         } catch (error) {
           failures.push(error)
@@ -829,10 +841,13 @@ function normalizeWebSessionVolatiles(log: string): string {
   }).join('\n')
 }
 
-function stableSessionFixture(session: Session, existing: string, workspaceCwd: string): string {
+function stableSessionFixture(session: Session, existing: string, harnessHome: string): string {
+  const sessionCwd = session.header.cwd
+  if (sessionCwd === undefined) throw new Error('session harvest has no cwd')
   const fresh = scrubSessionSnapshot(normalizeWebSessionVolatiles(rawSessionLog(session)))
     .split(session.id).join('{{sessionId}}')
-    .split(workspaceCwd).join('{{cwd}}')
+    .split(sessionCwd).join('{{cwd}}')
+    .split(harnessHome).join('{{harnessHome}}')
   const stable = redactSessionSnapshotIds(stabilizeFixtureMessageIds([fresh], [existing]))[0]
   if (stable === undefined) throw new Error('session harvest produced no stabilized fixture')
   return stable
@@ -843,6 +858,7 @@ async function assertReplaySession(
   fixturePath: string,
   mode: WebSnapshotMode,
   webUrl: string,
+  harnessHome: string,
 ): Promise<void> {
   let expected = await readFile(fixturePath, 'utf8')
   const userPrompts = fixtureUserPrompts(expected)
@@ -861,7 +877,7 @@ async function assertReplaySession(
   if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`)
   const actual = rawSessionLog(session)
   if (mode === 'refresh') {
-    expected = stableSessionFixture(session, expected, sessionCwd)
+    expected = stableSessionFixture(session, expected, harnessHome)
     await writeFile(fixturePath, expected)
   }
   const expectedHeader = JSON.parse(expected.split('\n').find(line => line.trim() !== '') ?? '{}') as {
@@ -873,8 +889,11 @@ async function assertReplaySession(
     sessionIds: typeof expectedHeader.id === 'string' ? [expectedHeader.id] : [],
     cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0',
   }
-  expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`)
-    .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0])
+  const actualSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0]
+    ?.split(harnessHome).join('{{harnessHome}}')
+  const expectedSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]
+    ?.split(harnessHome).join('{{harnessHome}}')
+  expect(actualSnapshot, `${fixturePath}: persisted replay`).toBe(expectedSnapshot)
 
   const fixtureDir = dirname(fixturePath)
   const manifestPath = join(fixtureDir, 'snapshot.yml')
@@ -898,8 +917,8 @@ async function assertReplaySession(
 
 /**
  * Record-mode fixture write-back: harvest the live session, scrub request
- * headers to {{system}}/{{tools}}, tokenize the run-local cwd, redact opaque
- * identities with typed relationship-preserving tokens, and write the fixture.
+ * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home,
+ * redact opaque identities with typed relationship-preserving tokens, and write the fixture.
  * @param scaffold - the record-mode scaffold.
  * @param sessionId - the driven session.
  * @param fixturePath - the committed session.jsonl target.
@@ -908,7 +927,7 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId,
   const agent = scaffold.ctx.agents.get(sessionId)
   if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
   const existing = existsSync(fixturePath) ? await readFile(fixturePath, 'utf8') : ''
-  await writeFile(fixturePath, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd))
+  await writeFile(fixturePath, stableSessionFixture(agent.session, existing, scaffold.harnessHome))
 }
 
 /**
@@ -955,8 +974,8 @@ export function fixtureIdentity(
  */
 /**
  * Realize a recorded seed fixture against one scaffold: substitute the
- * `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the
- * scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
+ * `{{sessionId}}`/`{{cwd}}`/`{{harnessHome}}` placeholders and rewrite the
+ * recorded cwd to the scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
  * price content exactly as the host will fold it) and still pass the result
  * through {@link seedSession}.
  * @param scaffold - the booted scaffold whose workspace the seed targets.
@@ -971,6 +990,7 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i
     .replace(/\{\{session:([2-9]\d*)\}\}/g, (_token, ordinal: string) => `${id}-child-${ordinal}`)
     .replace(/\{\{(message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}/g, (_token, kind: string, ordinal: string) =>
       fixtureIdentity(kind as 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id', Number(ordinal)))
+    .split('{{harnessHome}}').join(scaffold.harnessHome)
     .split('{{cwd}}').join(scaffold.workspaceCwd)
   const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
   return fixtureCwd === undefined

+ 2 - 0
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -27,6 +27,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current streaming-fence-highlight-test/streaming-fence":
   - text: streaming-fence-highlight-test/streaming-fence

+ 2 - 2
apps/web/tests/submission-echo.e2e.ts

@@ -33,7 +33,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
     },
   })
   await waitFor(() => {
-    if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) {
+    if (document.querySelector('[role="group"][aria-label="Pending attachments"] img') === null) {
       throw new Error('attachment rail missing')
     }
   }, { timeout: 5_000 })
@@ -53,7 +53,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl
   expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob')
   expect(composer.textContent).toBe('')
   expect(composer.getAttribute('contenteditable')).toBe('true')
-  expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
+  expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
 
   // The fixture's durable user/message (source.rpcId echoes the prompt
   // requestId) replaces the echo: one bubble, no marker left, and the image

+ 1 - 0
apps/web/tsconfig.json

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

+ 1 - 0
packages/api/session-controller/tests/client-apply.client.spec.ts

@@ -65,6 +65,7 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
     registerGenerationSource: () => () => {},
     start: () => ({ stop: () => {} }),
   }
+  ctx.reflect.provide('connection', connection)
   ctx.reflect.provide('remote', {
     ...remote,
     $stream: <Item>(options: RemoteStreamOptions<Item>) => (

+ 3 - 2
packages/api/session-controller/tests/client-contract.client.spec.ts

@@ -18,8 +18,9 @@ function entry(seq: number): SessionLiveEventEntry {
 }
 
 describe('Client Session contracts', () => {
-  it('keeps its catalog-visible prompt parts identical to attachment intake', () => {
-    expectTypeOf<SessionPromptContentPart>().toEqualTypeOf<AttachmentPromptContentPart>()
+  it('keeps its text and image prompt parts identical to attachment intake', () => {
+    expectTypeOf<Exclude<SessionPromptContentPart, { type: 'file' }>>()
+      .toEqualTypeOf<AttachmentPromptContentPart>()
   })
 
   it('publishes exact replace, prepend, and append event-window changes', () => {

+ 17 - 0
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -112,6 +112,23 @@ describe('Session queue commands', () => {
     })).toEqual({ accepted: true })
     expect(steer).toHaveBeenCalledWith(steered)
 
+    const queuedFile = createUserMessage({
+      content: [{
+        type: 'file',
+        attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 },
+      }],
+      source: { kind: 'user', rpcId: 'file-rpc' as never },
+    })
+    inbox.append('next-turn', queuedFile)
+    expect(controller.updateQueue({
+      sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' },
+    })).toEqual({ accepted: true })
+    expect(steer).toHaveBeenLastCalledWith(queuedFile)
+    expect(queuedFile).toMatchObject({
+      source: { kind: 'user', rpcId: 'file-rpc' },
+      content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }],
+    })
+
     await expectFailure(Promise.resolve().then(() => controller.cancel({
       sessionId: SessionId('missing'),
     })), 'session/not-found')

+ 376 - 0
packages/api/session-controller/tests/commands-upload-file.host.spec.ts

@@ -0,0 +1,376 @@
+import { Context } from '@deepseek-ai/cordis'
+import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
+import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
+import { AttachmentId } from '@deepseek-ai/dsh-attachment'
+import type {
+  FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
+} from '@deepseek-ai/dsh-attachment'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { UserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import CommandRuntime from '@deepseek-ai/dsh-commands'
+import { describe, expect, it, vi } from 'vitest'
+import type { ApiSessionAgentController } from '../src/agent.ts'
+import { SessionCommandController } from '../src/commands.ts'
+import type { FileUploadReceiptId, SessionRequestId } from '../src/types.ts'
+
+const SESSION = SessionId('upload-session')
+
+async function uploadHarness(): Promise<{
+  ctx: Context
+  controller: SessionCommandController
+  agent: Agent
+  followup: ReturnType<typeof vi.fn>
+  saveFile: ReturnType<typeof vi.fn>
+  saveFileStream: ReturnType<typeof vi.fn>
+  saveImages: ReturnType<typeof vi.fn>
+  disposeAgent: () => void
+}> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(CommandRuntime)
+  const session = ctx.sessions.create(SESSION, { meta: { cwd: '/workspace' } })
+  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
+  const followup = vi.fn()
+  const agent = {
+    id: session.id,
+    session,
+    inbox,
+    status: 'idle',
+    ctx,
+    steer: vi.fn(),
+    followup,
+    cancel: vi.fn(),
+  } as unknown as Agent
+  const disposeAgent = ctx.agents.register(agent)
+  const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
+    attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
+    name: input.name ?? 'file',
+    bytes: input.data.byteLength,
+  }))
+  const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise<FileAttachmentRef> => {
+    let bytes = 0
+    for await (const chunk of input.data) bytes += chunk.byteLength
+    return {
+      attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`),
+      name: input.name ?? 'file',
+      bytes,
+    }
+  })
+  const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
+    Promise.reject(new Error('fixture did not expect image persistence')))
+  ctx.provide('attachments', { saveFile, saveFileStream, saveImages } as never)
+  ctx.provide('llm', {
+    listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
+    resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
+  } as never)
+  const selection: ModelSelectionRef = {
+    current: { provider: 'fixture', model: 'fixture-model' },
+    assembled: undefined,
+  }
+  const agents = {
+    resolveAgent: () => Promise.resolve({ agent }),
+    selectionFor: () => selection,
+    serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
+  } as unknown as ApiSessionAgentController
+  return {
+    ctx,
+    controller: new SessionCommandController(ctx, agents, '/workspace'),
+    agent,
+    followup,
+    saveFile,
+    saveFileStream,
+    saveImages,
+    disposeAgent,
+  }
+}
+
+function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
+  return {
+    requestId: 'req-1' as SessionRequestId,
+    sessionId: SESSION,
+    mode: 'queue' as const,
+    content,
+  }
+}
+
+describe('Session file uploads', () => {
+  it('stages one verbatim upload and cites it from a later prompt as a file block', async () => {
+    const { ctx, controller, agent, followup, saveFile } = await uploadHarness()
+    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'notes.pdf' })
+    expect(saveFile).toHaveBeenCalledTimes(1)
+    expect(receipt.file.name).toBe('notes.pdf')
+    expect(receipt.file.bytes).toBe(3)
+    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
+    expect(controller.resolveStagedFile(SessionId('other-session'), receipt.receiptId)).toBeUndefined()
+    expect(controller.resolveStagedFile(SESSION, 'missing' as FileUploadReceiptId)).toBeUndefined()
+    const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const }))
+    ctx.commands.register({
+      name: 'files', description: 'Use staged files', input: { hint: '<task>', attachments: true },
+      handler: commandHandler,
+    })
+    await ctx.commands.execute(
+      agent,
+      '/files inspect',
+      [{ type: 'file', receiptId: receipt.receiptId }],
+      new AbortController().signal,
+    )
+    expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({
+      attachments: [{ type: 'file', attachment: receipt.file }],
+    })
+    await controller.prompt(promptRequest([
+      { type: 'file', receiptId: receipt.receiptId },
+      { type: 'text', text: 'read it' },
+    ]))
+    expect(followup).toHaveBeenCalledTimes(1)
+    const message = followup.mock.calls[0]?.[0] as UserMessage
+    expect(message.content).toEqual([
+      { type: 'file', attachment: receipt.file },
+      { type: 'text', text: 'read it' },
+    ])
+  })
+
+  it('stages a bounded byte stream and forwards cancellation to storage', async () => {
+    const { controller, followup, saveFileStream } = await uploadHarness()
+    const abort = new AbortController()
+    const receipt = await controller.uploadFileStream({
+      sessionId: SESSION,
+      data: (async function* (): AsyncIterable<Uint8Array> {
+        yield Uint8Array.of(1, 2)
+        yield Uint8Array.of(3, 4, 5)
+      })(),
+      signal: abort.signal,
+      name: 'huge.bin',
+    })
+    expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({
+      signal: abort.signal,
+      name: 'huge.bin',
+    }))
+    expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
+      { type: 'file', attachment: receipt.file },
+    ])
+  })
+
+  it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => {
+    const { controller, saveFileStream } = await uploadHarness()
+    const stream = (async function* (): AsyncIterable<Uint8Array> {})()
+    await expect(controller.uploadFileStream({ sessionId: SESSION, data: stream }))
+      .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } })
+    expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream })
+    saveFileStream.mockRejectedValueOnce('disk offline')
+    await expect(controller.uploadFileStream({
+      sessionId: SESSION,
+      data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
+    }))
+      .rejects.toMatchObject({
+        code: 'gateway/internal', message: 'failed to store file upload: disk offline',
+      })
+  })
+
+  it('unregisters its command receipt resolver with the owning plugin fiber', async () => {
+    const ctx = new Context()
+    const commands = ctx.plugin(CommandRuntime)
+    await commands.await()
+    const agents = {} as ApiSessionAgentController
+    const plugin = {
+      apply(ownerCtx: Context) {
+        new SessionCommandController(ownerCtx, agents, '/workspace')
+      },
+    }
+
+    const first = ctx.plugin(plugin)
+    await first.await()
+    await first.dispose()
+    const replacement = ctx.plugin(plugin)
+    await replacement.await()
+    await replacement.dispose()
+    await commands.dispose()
+  })
+
+  it('rejects a prompt citing a file that was never staged for the session', async () => {
+    const { controller, followup, saveImages } = await uploadHarness()
+    await expect(controller.prompt(promptRequest([
+      { type: 'image', mediaType: 'image/png', data: 'AAAA' },
+      { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
+    ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
+    expect(followup).not.toHaveBeenCalled()
+    expect(saveImages).not.toHaveBeenCalled()
+  })
+
+  it('publishes no receipt when its exact Agent is disposed during storage', async () => {
+    const { controller, saveFile, disposeAgent } = await uploadHarness()
+    const saved = Promise.withResolvers<FileAttachmentRef>()
+    saveFile.mockReturnValueOnce(saved.promise)
+    const uploading = controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'late.bin' })
+    await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
+    disposeAgent()
+    saved.resolve({
+      attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3,
+    })
+    await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
+  })
+
+  it('retires accepted receipts after their rpcId becomes observable', async () => {
+    const { controller } = await uploadHarness()
+    controller.retireObservedPrompt(SESSION, 'not-staged' as SessionRequestId)
+    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
+    controller.retireObservedPrompt(SESSION, 'other-request' as SessionRequestId)
+    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
+    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
+    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined()
+  })
+
+  it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
+    const { controller, agent, followup } = await uploadHarness()
+    const request = promptRequest([{ type: 'text', text: 'once' }])
+    await controller.prompt(request)
+    agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
+    await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
+    expect(followup).toHaveBeenCalledOnce()
+  })
+
+  it('deduplicates a retried rpcId already present in the durable log', async () => {
+    const { controller, agent, followup } = await uploadHarness()
+    const request = promptRequest([{ type: 'text', text: 'once' }])
+    agent.session.append('turn/start', { turn: 1 })
+    agent.session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'unidentified' }],
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    agent.session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'accepted' }],
+      source: { kind: 'user', rpcId: request.requestId },
+    }), { surfaceOp: 'append' })
+
+    await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
+    expect(followup).not.toHaveBeenCalled()
+  })
+
+  it('rejects when the Agent disappears during prompt admission', async () => {
+    const { controller, saveImages, disposeAgent } = await uploadHarness()
+    const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
+    saveImages.mockReturnValueOnce(admitted.promise)
+    const prompting = controller.prompt(promptRequest([
+      { type: 'image', mediaType: 'image/png', data: 'AAAA' },
+    ]))
+    await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
+    disposeAgent()
+    admitted.resolve([{
+      attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
+    }])
+    await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' })
+  })
+
+  it('rejects when a previously bound receipt retires during image admission', async () => {
+    const { controller, saveImages } = await uploadHarness()
+    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
+    saveImages.mockReturnValueOnce(admitted.promise)
+    const prompting = controller.prompt({
+      ...promptRequest([
+        { type: 'file', receiptId: receipt.receiptId },
+        { type: 'image', mediaType: 'image/png', data: 'AAAA' },
+      ]),
+      requestId: 'req-2' as SessionRequestId,
+    })
+    await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
+    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
+    admitted.resolve([{
+      attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
+    }])
+    await expect(prompting).rejects.toMatchObject({
+      code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' },
+    })
+  })
+
+  it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
+    const { controller, followup } = await uploadHarness()
+    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    followup.mockImplementationOnce(() => { throw new Error('busy') })
+    await expect(controller.prompt({
+      ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
+      requestId: 'req-2' as SessionRequestId,
+    })).rejects.toMatchObject({ code: 'session/agent-busy' })
+    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
+    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined()
+  })
+
+  it('restores an unbound receipt after prompt delivery fails', async () => {
+    const { controller, followup } = await uploadHarness()
+    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    followup.mockImplementationOnce(() => { throw new Error('busy') })
+    await expect(controller.prompt(promptRequest([
+      { type: 'file', receiptId: receipt.receiptId },
+    ]))).rejects.toMatchObject({ code: 'session/agent-busy' })
+    controller.retireObservedPrompt(SESSION, 'req-1' as SessionRequestId)
+    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toEqual(receipt.file)
+  })
+
+  it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
+    const { controller, agent, followup } = await uploadHarness()
+    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
+    const queued = followup.mock.calls[0]?.[0] as UserMessage
+    agent.inbox.append('next-turn', queued)
+    expect(controller.updateQueue({
+      sessionId: SESSION,
+      itemId: queued.id,
+      action: { kind: 'remove' },
+    })).toEqual({ accepted: true })
+    expect(controller.resolveStagedFile(SESSION, receipt.receiptId)).toBeUndefined()
+  })
+
+  it('drops staged uploads with the session while the stored object stays durable', async () => {
+    const { controller, followup } = await uploadHarness()
+    const receipt = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA' })
+    expect(receipt.file.name).toBe('file')
+    controller.releaseStagedFiles(SESSION)
+    await expect(controller.prompt(promptRequest([
+      { type: 'file', receiptId: receipt.receiptId },
+    ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
+    expect(followup).not.toHaveBeenCalled()
+  })
+
+  it('keeps separate names for identical bytes uploaded more than once', async () => {
+    const { controller, followup } = await uploadHarness()
+    const first = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'first.txt' })
+    const second = await controller.uploadFile({ sessionId: SESSION, data: 'AAAA', name: 'second.txt' })
+    expect(first.file.attachmentId).toBe(second.file.attachmentId)
+    expect(first.receiptId).not.toBe(second.receiptId)
+
+    await controller.prompt(promptRequest([
+      { type: 'file', receiptId: first.receiptId },
+      { type: 'file', receiptId: second.receiptId },
+    ]))
+
+    const message = followup.mock.calls[0]?.[0] as UserMessage
+    expect(message.content).toEqual([
+      { type: 'file', attachment: first.file },
+      { type: 'file', attachment: second.file },
+    ])
+  })
+
+  it('maps a non-canonical payload to the wire attachment error', async () => {
+    const { controller, saveFile } = await uploadHarness()
+    await expect(controller.uploadFile({ sessionId: SESSION, data: 'not base64!!' }))
+      .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } })
+    expect(saveFile).not.toHaveBeenCalled()
+  })
+
+  it('maps an unexpected storage failure to the internal wire error', async () => {
+    const { controller, saveFile } = await uploadHarness()
+    saveFile.mockRejectedValueOnce(new Error('disk unavailable'))
+    await expect(controller.uploadFile({ sessionId: SESSION, data: 'AAAA' }))
+      .rejects.toMatchObject({
+        code: 'gateway/internal',
+        message: 'failed to store file upload: Error: disk unavailable',
+      })
+  })
+})

+ 25 - 1
packages/api/session-controller/tests/controller.host.spec.ts

@@ -8,6 +8,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { describe, expect, it, vi } from 'vitest'
 import SessionController from '../src/index.ts'
 import type { ApiSessionAgentController } from '../src/agent.ts'
+import type { SessionRequestId } from '../src/types.ts'
 import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
 
 const defaults = {
@@ -38,6 +39,21 @@ describe('SessionController facade', () => {
       inspect,
     }) as never)
     const controller = createSessionTestController(ctx, defaults)
+    const uploadRequest = { sessionId, data: 'AAAA', name: 'notes.txt' }
+    const uploadValue = {
+      receiptId: 'receipt-1' as never,
+      file: {
+        attachmentId: `sha256:${'ab'.repeat(32)}` as never,
+        name: 'notes.txt',
+        bytes: 3,
+      },
+    }
+    const uploadFile = vi.spyOn((controller as unknown as {
+      commands: { uploadFile(request: typeof uploadRequest): Promise<typeof uploadValue> }
+    }).commands, 'uploadFile').mockResolvedValue(uploadValue)
+    await expect(controller.uploadFile(uploadRequest, new AbortController().signal))
+      .resolves.toBe(uploadValue)
+    expect(uploadFile).toHaveBeenCalledWith(uploadRequest)
     const status = vi.fn()
     const failure = vi.fn()
     const activity = vi.fn()
@@ -60,6 +76,9 @@ describe('SessionController facade', () => {
       (controller as unknown as { agents: ApiSessionAgentController }).agents,
       'consumeSelection',
     )
+    const retireObservedPrompt = vi.spyOn((controller as unknown as {
+      commands: { retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void }
+    }).commands, 'retireObservedPrompt')
 
     await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
     await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events })
@@ -70,9 +89,14 @@ describe('SessionController facade', () => {
       content: [{ type: 'text', text: 'hello' }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'browser prompt' }],
+      source: { kind: 'user', rpcId: 'controller-rpc' as never },
+    }), { surfaceOp: 'append' })
     expect(status).toHaveBeenCalledWith(sessionId, true)
     expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
     expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
+    expect(retireObservedPrompt).toHaveBeenCalledWith(sessionId, 'controller-rpc')
     session.append('request/header', {
       header: { config: { provider: 'fixture', model: 'fixture-model' } },
       reason: 'initial',
@@ -95,7 +119,7 @@ describe('SessionController facade', () => {
     }, abort.signal)[Symbol.asyncIterator]()
     await expect(iterator.next()).resolves.toMatchObject({
       done: false,
-      value: { type: 'snapshot', cursor: 1 },
+      value: { type: 'snapshot', cursor: 2 },
     })
     abort.abort()
     await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })

+ 8 - 0
packages/api/session-controller/tests/fake-api.client.ts

@@ -143,6 +143,13 @@ export class FakeApiClient {
   onPrompt: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
   onAttachment: (payload: unknown) => Promise<RemoteResult<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
     () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
+  onUploadFile: (payload: unknown) => Promise<RemoteResult<{
+    receiptId: never
+    file: { attachmentId: never; name: string; bytes: number }
+  }>> = () => Promise.resolve(ok({
+    receiptId: 'r' as never,
+    file: { attachmentId: 'f' as never, name: 'file', bytes: 1 },
+  }))
   onUpdateQueue: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
   onCancel: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
   onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
@@ -225,6 +232,7 @@ export class FakeApiClient {
         fork: payload => this.record('session.fork', payload, this.onFork(payload)),
         prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)),
         attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)),
+        uploadFile: payload => this.record('session.uploadFile', payload, this.onUploadFile(payload)),
         updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
         cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)),
         openWorkspacePath: payload => this.record(

+ 143 - 0
packages/api/session-controller/tests/file-upload-http.host.spec.ts

@@ -0,0 +1,143 @@
+import { runInNewContext } from 'node:vm'
+import { Context } from '@deepseek-ai/cordis'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { describe, expect, it, vi } from 'vitest'
+import type { Mock } from 'vitest'
+import type { SessionCommandController } from '../src/commands.ts'
+import {
+  handleSessionFileUploadHttp, registerSessionFileUploadHttp,
+} from '../src/file-upload-http.ts'
+
+function request(input: {
+  method?: string
+  sessionId?: string
+  name?: string
+  contentType?: string
+  body?: Uint8Array
+} = {}): Request {
+  const query = new URLSearchParams()
+  if (input.sessionId !== undefined) query.set('sessionId', input.sessionId)
+  if (input.name !== undefined) query.set('name', input.name)
+  const suffix = query.size === 0 ? '' : `?${query.toString()}`
+  return new Request(`http://host/api/session/uploadFileBinary${suffix}`, {
+    method: input.method ?? 'POST',
+    headers: input.contentType === undefined ? {} : { 'content-type': input.contentType },
+    ...(input.body === undefined ? {} : { body: new Blob([Uint8Array.from(input.body).buffer]) }),
+  })
+}
+
+function commands(result: unknown): SessionCommandController & {
+  uploadFileStream: Mock<SessionCommandController['uploadFileStream']>
+  uploadedChunks: Uint8Array[]
+} {
+  const uploadedChunks: Uint8Array[] = []
+  const uploadFileStream = vi.fn<SessionCommandController['uploadFileStream']>(async (input) => {
+    for await (const chunk of input.data) uploadedChunks.push(chunk)
+    return await result as Awaited<ReturnType<SessionCommandController['uploadFileStream']>>
+  })
+  return {
+    uploadedChunks,
+    uploadFileStream,
+  } as unknown as SessionCommandController & {
+    uploadFileStream: Mock<SessionCommandController['uploadFileStream']>
+    uploadedChunks: Uint8Array[]
+  }
+}
+
+describe('background file upload Fetch route', () => {
+  it('registers one authenticated POST route on Connection', async () => {
+    const ctx = new Context()
+    const register = vi.fn((_route: unknown) => vi.fn(async () => {}))
+    ctx.provide('connection', { fetch: { register } } as never)
+    registerSessionFileUploadHttp(ctx, commands(Promise.resolve({})))
+    await vi.waitFor(() => { expect(register).toHaveBeenCalledOnce() })
+    const route = register.mock.calls[0]?.[0] as {
+      path: string
+      methods: string[]
+      requestBody: string
+      fetch(request: Request): Promise<Response>
+    }
+    expect(route.path).toBe('/api/session/uploadFileBinary')
+    expect(route.methods).toEqual(['POST'])
+    expect(route.requestBody).toBe('streaming')
+    expect((await route.fetch(request({
+      sessionId: 's1', contentType: 'application/octet-stream',
+    }))).status).toBe(200)
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects the wrong method, media type, and missing Session id without storing', async () => {
+    const controller = commands(Promise.resolve({}))
+    const wrongMethod = await handleSessionFileUploadHttp(controller, request({ method: 'GET' }))
+    expect(wrongMethod.status).toBe(405)
+    expect(wrongMethod.headers.get('allow')).toBe('POST')
+
+    const wrongType = await handleSessionFileUploadHttp(controller, request({ contentType: 'application/json' }))
+    expect(wrongType.status).toBe(415)
+    expect(await wrongType.text()).toBe('content type must be application/octet-stream')
+
+    const missingSession = await handleSessionFileUploadHttp(
+      controller,
+      request({ contentType: 'application/octet-stream' }),
+    )
+    expect(missingSession.status).toBe(400)
+    expect(await missingSession.text()).toBe('sessionId is required')
+    expect(controller.uploadFileStream).not.toHaveBeenCalled()
+  })
+
+  it('stores the request bytes and returns the staged receipt', async () => {
+    const value = {
+      receiptId: 'receipt-1',
+      file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 },
+    }
+    const controller = commands(Promise.resolve(value))
+    const response = await handleSessionFileUploadHttp(controller, request({
+      sessionId: 's1',
+      name: 'large & final.bin',
+      contentType: 'application/octet-stream; charset=binary',
+      body: Uint8Array.of(1, 2, 3, 4),
+    }))
+    expect(controller.uploadFileStream).toHaveBeenCalledOnce()
+    const upload = controller.uploadFileStream.mock.calls[0]?.[0]
+    expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' })
+    expect(upload?.signal).toBeInstanceOf(AbortSignal)
+    expect(controller.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)])
+    expect(response.status).toBe(200)
+    expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8')
+    expect(response.headers.get('cache-control')).toBe('no-store')
+    expect(await response.json()).toEqual({ ok: true, value })
+  })
+
+  it('returns business and internal storage failures and keeps an absent name absent', async () => {
+    const business = commands(Promise.reject(new RemoteError(
+      'session/attachment-invalid', 'denied', { reason: 'NOPE' },
+    )))
+    const businessResponse = await handleSessionFileUploadHttp(business, request({
+      sessionId: 's1', contentType: 'application/octet-stream',
+    }))
+    expect(business.uploadFileStream).toHaveBeenCalledOnce()
+    const upload = business.uploadFileStream.mock.calls[0]?.[0]
+    expect(upload).toMatchObject({ sessionId: 's1' })
+    expect(upload?.signal).toBeInstanceOf(AbortSignal)
+    expect(business.uploadedChunks).toEqual([])
+    expect(await businessResponse.json()).toEqual({
+      ok: false,
+      error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
+    })
+
+    const internal = commands(Promise.reject(new Error('disk offline')))
+    expect(await (await handleSessionFileUploadHttp(internal, request({
+      sessionId: 's1', contentType: 'application/octet-stream',
+    }))).json()).toEqual({
+      ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} },
+    })
+
+    const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error
+    const exception = commands(Promise.reject(foreignError))
+    expect(await (await handleSessionFileUploadHttp(exception, request({
+      sessionId: 's1', contentType: 'application/octet-stream',
+    }))).json()).toEqual({
+      ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} },
+    })
+  })
+})

+ 13 - 5
packages/api/session-controller/tests/queue-store.client.spec.ts

@@ -73,21 +73,29 @@ describe('Session queue snapshot intake', () => {
     ])
   })
 
-  it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => {
+  it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => {
     const session = makeSession()
     session.handleControlFrame(queueFrame([{
       id: 'q-image',
       body: '',
-      content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
+      content: [
+        { type: 'text', text: 'hi' },
+        { type: 'image', data: 'x' } as never,
+        { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never,
+      ],
     }]))
     const queue = session.getSnapshot().queue
     expect(typeof queue[0]?.messageId).toBe('string')
     expect(queue).toMatchObject([
       {
         id: 'q-image', placement: 'queued',
-        content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
-        // Image blocks render as thumbnails from `content`, so the preview
-        // carries only the text; non-image foreign blocks keep their marker.
+        content: [
+          { type: 'text', text: 'hi' },
+          { type: 'image', data: 'x' },
+          { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } },
+        ],
+        // Attachment blocks render from `content`, so the preview carries
+        // only text; other foreign blocks keep their marker.
         preview: 'hi', text: null,
       },
     ])

+ 12 - 3
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import SessionStore from '@deepseek-ai/dsh-session'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
@@ -48,6 +48,10 @@ function promptRequest(
   }
 }
 
+function inboxFor(session: Session): Inbox {
+  return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
+}
+
 function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
   return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
 }
@@ -697,7 +701,9 @@ describe('subagent ownership fence', () => {
       meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 },
     })
     const followup = vi.fn()
-    const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
+    const agent = {
+      id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
+    } as unknown as Agent
     ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
@@ -716,7 +722,9 @@ describe('subagent ownership fence', () => {
     await ctx.plugin(AgentRegistry)
     const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
     const followup = vi.fn()
-    const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
+    const agent = {
+      id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
+    } as unknown as Agent
     ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, {
       defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -832,6 +840,7 @@ describe('sessions.prompt synchronous rejection', () => {
     ctx.agents.register({
       id: session.id,
       session,
+      inbox: inboxFor(session),
       status: 'idle',
       ctx,
       followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },

+ 56 - 22
packages/api/session-controller/tests/session-pending-submissions.client.spec.ts

@@ -2,7 +2,7 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
+import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Session } from '../src/client/sessions/session.ts'
@@ -31,8 +31,20 @@ function imageRef(id: string): ImageAttachmentRef {
   } as unknown as ImageAttachmentRef
 }
 
+function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
+  return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef
+}
+
+type AttachmentRef = ImageAttachmentRef | FileAttachmentRef
+
+function attachmentBlock(attachment: AttachmentRef) {
+  return 'mediaType' in attachment
+    ? { type: 'image' as const, attachment }
+    : { type: 'file' as const, attachment }
+}
+
 /** A durable browser-prompt user/message whose source echoes `rpcId`. */
-function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent {
+function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent {
   return {
     seq,
     time: 1_700_000_000_000 + seq,
@@ -40,7 +52,7 @@ function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageA
     surfaceOp: 'append',
     data: createUserMessage({
       content: [
-        ...refs.map(attachment => ({ type: 'image' as const, attachment })),
+        ...refs.map(attachmentBlock),
         { type: 'text' as const, text: '发送' },
       ],
       source: { kind: 'user', rpcId },
@@ -48,14 +60,14 @@ function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageA
   } as unknown as SessionEvent
 }
 
-function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem {
+function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem {
   return {
     id: 'm-queued' as SessionQueuedItem['id'],
     placement: 'queued',
     rpcId,
     message: {
       id: 'm-queued' as SessionQueuedItem['id'],
-      content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'],
+      content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'],
     },
   }
 }
@@ -72,23 +84,23 @@ describe('beginSubmission', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '你好',
-      images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
+      attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
     })
     expect(session.getSnapshot().promptAttempted).toBe(true)
     expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
       requestId: handle.requestId,
       placement: 'transcript',
       text: '你好',
-      images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
+      attachments: [{ type: 'image', previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
     }])
   })
 
   it('derives and captures the echo placement from running state and delivery mode', () => {
     const { session } = makeSession()
-    session.beginSubmission({ mode: 'queue', text: '空闲', images: [] })
+    session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
     session.handleRunning(true)
-    session.beginSubmission({ mode: 'queue', text: '排队', images: [] })
-    session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] })
+    session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
+    session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] })
     session.handleRunning(false)
     expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
       { text: '空闲', placement: 'transcript' },
@@ -103,7 +115,7 @@ describe('beginSubmission', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '放弃',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     handle.abandon()
@@ -121,7 +133,7 @@ describe('prompt-coupled retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '失败的',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
@@ -133,7 +145,7 @@ describe('prompt-coupled retirement', () => {
 
   it('sends the echo identity as the prompt requestId', async () => {
     const { api, session } = makeSession()
-    const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] })
+    const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
     await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
     expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
   })
@@ -141,7 +153,7 @@ describe('prompt-coupled retirement', () => {
   it('an unidentified prompt failure leaves registered echoes alone', async () => {
     const { api, session } = makeSession()
     api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
-    session.beginSubmission({ mode: 'queue', text: '还在', images: [] })
+    session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
     await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
   })
@@ -156,7 +168,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '发送',
-      images: [{ previewUrl: 'blob:p1' }],
+      attachments: [{ type: 'image', previewUrl: 'blob:p1' }],
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-1')]
@@ -176,7 +188,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '排队',
-      images: [{ previewUrl: 'blob:p1' }],
+      attachments: [{ type: 'image', previewUrl: 'blob:p1' }],
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-q')]
@@ -188,9 +200,31 @@ describe('observed retirement', () => {
     expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
   })
 
+  it('retires a mixed echo with durable references in original selection order', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+    await session.open()
+    const retirements: PendingSubmissionRetirement[] = []
+    const file = fileRef('file-1')
+    const handle = session.beginSubmission({
+      mode: 'queue',
+      text: 'mixed',
+      attachments: [
+        { type: 'image', previewUrl: 'blob:first' },
+        { type: 'file', attachment: file },
+        { type: 'image', previewUrl: 'blob:last' },
+      ],
+      onRetire: retirement => retirements.push(retirement),
+    })
+    const refs = [imageRef('image-1'), file, imageRef('image-2')]
+    await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never })
+    await settleFrames()
+    expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
+  })
+
   it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
     const { api, session } = makeSession()
-    const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] })
+    const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
     api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)])))
     await session.open()
     await settleFrames()
@@ -205,7 +239,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '先观察',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
@@ -222,7 +256,7 @@ describe('observed retirement', () => {
     const handle = session.beginSubmission({
       mode: 'queue',
       text: '同一请求',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
     session.handleControlFrame({
@@ -245,7 +279,7 @@ describe('observed retirement', () => {
     const { api, session } = makeSession()
     api.onHistory = () => Promise.resolve(ok(historyValue([])))
     await session.open()
-    const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] })
+    const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
     await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
     expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
     expect(frames).toHaveLength(1)
@@ -263,13 +297,13 @@ describe('disposal', () => {
     const observed = session.beginSubmission({
       mode: 'queue',
       text: '已观察',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push({ text: '已观察', retirement }),
     })
     session.beginSubmission({
       mode: 'queue',
       text: '未settle',
-      images: [],
+      attachments: [],
       onRetire: retirement => retirements.push({ text: '未settle', retirement }),
     })
     await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never })

+ 144 - 0
packages/api/session-controller/tests/session.client.spec.ts

@@ -45,6 +45,127 @@ function histResponse(events: SessionEvent[], hasMore = false) {
   return Promise.resolve(ok(historyValue(events, hasMore)))
 }
 
+describe('Session file upload', () => {
+  it('uses the background body carrier, reports progress, and validates its receipt', async () => {
+    const progress = vi.fn()
+    const post = vi.fn(async (request: {
+      path: string
+      body: Blob
+      headers?: Readonly<Record<string, string>>
+      signal?: AbortSignal
+      onProgress?: (progress: { loaded: number; total?: number }) => void
+    }) => {
+      request.onProgress?.({ loaded: 2, total: 4 })
+      return {
+        status: 200,
+        body: JSON.stringify({
+          ok: true,
+          value: {
+            receiptId: 'receipt-1',
+            file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
+          },
+        }),
+      }
+    })
+    const { api, session } = makeSession(undefined, { backgroundUploads: { post } })
+    const abort = new AbortController()
+    const file = new Blob([Uint8Array.of(1, 2, 3, 4)])
+
+    await expect(session.uploadFile(file, 'notes & refs.pdf', abort.signal, progress)).resolves.toEqual({
+      ok: true,
+      value: {
+        receiptId: 'receipt-1',
+        file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
+      },
+    })
+    expect(post).toHaveBeenCalledWith(expect.objectContaining({
+      path: '/api/session/uploadFileBinary?sessionId=fk-s1&name=notes+%26+refs.pdf',
+      body: file,
+      headers: { 'content-type': 'application/octet-stream' },
+      signal: abort.signal,
+    }))
+    expect(progress).toHaveBeenCalledWith({ loaded: 2, total: 4 })
+    expect(api.callsOf('session.uploadFile')).toEqual([])
+  })
+
+  it('preserves a background business failure and supports unnamed files without observers', async () => {
+    const post = vi.fn((_request: { readonly body: Blob }) => Promise.resolve({
+      status: 200,
+      body: JSON.stringify({
+        ok: false,
+        error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
+      }),
+    }))
+    const { session } = makeSession(undefined, { backgroundUploads: { post } })
+    await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
+      ok: false,
+      error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
+    })
+    const request = post.mock.calls[0]?.[0]
+    expect(request).toMatchObject({
+      path: '/api/session/uploadFileBinary?sessionId=fk-s1',
+      headers: { 'content-type': 'application/octet-stream' },
+    })
+    expect(request?.body).toBeInstanceOf(Blob)
+  })
+
+  it('keeps byte and Blob fallback uploads on the generated Remote carrier', async () => {
+    const { api, session } = makeSession()
+    await expect(session.uploadFile(Uint8Array.of(0, 0, 0), 'bytes.bin')).resolves.toMatchObject({ ok: true })
+    await expect(session.uploadFile(new Blob([Uint8Array.of(1)]))).resolves.toMatchObject({ ok: true })
+    expect(api.callsOf('session.uploadFile')).toEqual([
+      { sessionId: SID, data: 'AAAA', name: 'bytes.bin' },
+      { sessionId: SID, data: 'AQ==' },
+    ])
+  })
+
+  it('folds non-200 and malformed background responses into transport failures', async () => {
+    const bodies: unknown[] = [
+      null,
+      { ok: 'yes' },
+      { ok: false, error: null },
+      { ok: false, error: { code: 1, message: 'x', details: {} } },
+      { ok: false, error: { code: 'x', message: 1, details: {} } },
+      { ok: false, error: { code: 'x', message: 'x', details: null } },
+      { ok: true, value: null },
+      { ok: true, value: { receiptId: 1, file: {} } },
+      { ok: true, value: { receiptId: 'r', file: null } },
+      { ok: true, value: { receiptId: 'r', file: { attachmentId: 1, name: 'x', bytes: 1 } } },
+      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 1, bytes: 1 } } },
+      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: '1' } } },
+      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: 1.5 } } },
+      { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
+    ]
+    for (const body of bodies) {
+      const { session } = makeSession(undefined, {
+        backgroundUploads: { post: () => Promise.resolve({ status: 200, body: JSON.stringify(body) }) },
+      })
+      await expect(session.uploadFile(new Blob([])))
+        .rejects.toThrow(/file upload transport returned an invalid/)
+    }
+    const { session } = makeSession(undefined, {
+      backgroundUploads: { post: () => Promise.resolve({ status: 503, body: 'unavailable' }) },
+    })
+    await expect(session.uploadFile(new Blob([])))
+      .rejects.toThrow('file upload transport failed with HTTP 503')
+  })
+
+  it('refuses a direct file upload for a continuable subagent before either carrier runs', async () => {
+    const post = vi.fn()
+    const api = new FakeApiClient()
+    const session = new Session(SID, fakeRemote(api), {
+      address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
+      backgroundUploads: { post },
+    })
+    await expect(session.uploadFile(new Blob([]))).resolves.toMatchObject({
+      ok: false,
+      error: { code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } },
+    })
+    expect(post).not.toHaveBeenCalled()
+    expect(api.callsOf('session.uploadFile')).toEqual([])
+  })
+})
+
 describe('Session open', () => {
   it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => {
     const { session } = makeSession()
@@ -498,6 +619,29 @@ describe('prompt and cancel errors', () => {
     })
   })
 
+  it('rejects staged files instead of dropping them from subagent continuations', async () => {
+    const api = new FakeApiClient()
+    const session = new Session(SID, fakeRemote(api), {
+      address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
+      parentAvailable: true,
+    })
+    await session.open()
+
+    const prompted = await session.prompt([
+      { type: 'file', receiptId: 'receipt' as never },
+      { type: 'text', text: '继续' },
+    ], 'queue')
+
+    expect(prompted).toMatchObject({
+      ok: false,
+      error: {
+        code: 'subagent/attachment-invalid',
+        details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
+      },
+    })
+    expect(api.callsOf('subagents.prompt')).toEqual([])
+  })
+
   it('sends a one-shot address to the Host under the continuable marker', async () => {
     const api = new FakeApiClient()
     api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(

+ 193 - 0
packages/client/connection/tests/background-upload.client.spec.ts

@@ -0,0 +1,193 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  backgroundUploadWorker, createBackgroundUploadTransport,
+} from '../src/client/background-upload.ts'
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+})
+
+describe('background upload worker body', () => {
+  it('sends the Blob with credentials and reports progress, completion, and failure', () => {
+    const posted: unknown[] = []
+    const scope: {
+      onmessage: ((event: MessageEvent<{
+        url: string
+        body: Blob
+        headers: Readonly<Record<string, string>>
+      }>) => void) | null
+      postMessage(message: unknown): void
+    } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } }
+    const xhr: {
+      upload: { onprogress: ((event: ProgressEvent) => void) | null }
+      status: number
+      responseText: string
+      withCredentials: boolean
+      onload: ((event: ProgressEvent) => void) | null
+      onerror: ((event: ProgressEvent) => void) | null
+      open: ReturnType<typeof vi.fn>
+      setRequestHeader: ReturnType<typeof vi.fn>
+      send: ReturnType<typeof vi.fn>
+    } = {
+      upload: { onprogress: null },
+      status: 201,
+      responseText: '{"ok":true}',
+      withCredentials: false,
+      onload: null,
+      onerror: null,
+      open: vi.fn(),
+      setRequestHeader: vi.fn(),
+      send: vi.fn(),
+    }
+    backgroundUploadWorker(scope, () => xhr as never)
+    const body = new Blob(['large'])
+    scope.onmessage?.({
+      data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } },
+    } as never)
+    expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload')
+    expect(xhr.withCredentials).toBe(true)
+    expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream')
+    expect(xhr.send).toHaveBeenCalledWith(body)
+
+    xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent)
+    xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent)
+    xhr.onload?.({} as ProgressEvent)
+    xhr.onerror?.({} as ProgressEvent)
+    expect(posted).toEqual([
+      { kind: 'progress', loaded: 2, total: 4 },
+      { kind: 'progress', loaded: 3 },
+      { kind: 'complete', status: 201, body: '{"ok":true}' },
+      { kind: 'error', message: 'background upload transport failed' },
+    ])
+  })
+
+  it('uses the Worker globals when the emitted body supplies no test seams', () => {
+    const posted: unknown[] = []
+    const scope: {
+      onmessage: ((event: MessageEvent) => void) | null
+      postMessage(message: unknown): void
+    } = { onmessage: null, postMessage: (message) => { posted.push(message) } }
+    const xhr = {
+      upload: { onprogress: null },
+      status: 204,
+      responseText: '',
+      withCredentials: false,
+      onload: null,
+      onerror: null,
+      open: vi.fn(),
+      setRequestHeader: vi.fn(),
+      send: vi.fn(),
+    }
+    vi.stubGlobal('self', scope)
+    vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr }))
+    backgroundUploadWorker()
+    scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent)
+    expect(xhr.send).toHaveBeenCalledOnce()
+  })
+})
+
+describe('background upload page transport', () => {
+  it('hands Blob bodies to a custom Host Worker transport without reading them', async () => {
+    vi.stubGlobal('location', { origin: 'https://preview.test' })
+    const body = new Blob(['opaque'])
+    const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
+      Promise.resolve(new Response('accepted', { status: 202 })))
+    const signal = new AbortController().signal
+    await expect(createBackgroundUploadTransport(fetch).post({
+      path: '/api/upload', body, headers: { 'x-test': 'yes' }, signal,
+    })).resolves.toEqual({ status: 202, body: 'accepted' })
+    expect(fetch).toHaveBeenCalledWith(new URL('https://preview.test/api/upload'), {
+      method: 'POST', headers: { 'x-test': 'yes' }, body, signal,
+    })
+
+    vi.stubGlobal('location', { origin: 'null' })
+    await createBackgroundUploadTransport(fetch).post({ path: '/fallback', body })
+    expect(fetch.mock.calls[1]?.[0]).toEqual(new URL('http://dsh.internal/fallback'))
+    expect(fetch.mock.calls[1]?.[1]).toEqual({ method: 'POST', body })
+  })
+
+  it('fails loud when a served browser has no Worker implementation', async () => {
+    vi.stubGlobal('Worker', undefined)
+    await expect(createBackgroundUploadTransport().post({ path: '/upload', body: new Blob() }))
+      .rejects.toThrow('background upload requires Web Worker support')
+  })
+
+  it('forwards progress and completion from a dedicated Worker and then terminates it', async () => {
+    const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
+    const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+    class FakeWorker {
+      static last: FakeWorker | undefined
+      onmessage: ((event: MessageEvent) => void) | null = null
+      onerror: ((event: ErrorEvent) => void) | null = null
+      readonly postMessage = vi.fn()
+      readonly terminate = vi.fn()
+      constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this }
+    }
+    vi.stubGlobal('Worker', FakeWorker)
+    vi.stubGlobal('location', { origin: 'https://harness.test' })
+    const progress = vi.fn()
+    const body = new Blob(['bytes'])
+    const controller = new AbortController()
+    const pending = createBackgroundUploadTransport().post({
+      path: '/api/upload', body, onProgress: progress, signal: controller.signal,
+    })
+    const worker = FakeWorker.last
+    if (worker === undefined) throw new Error('worker missing')
+    expect(created).toHaveBeenCalledOnce()
+    expect(revoked).toHaveBeenCalledWith('blob:worker')
+    expect(worker.url).toBe('blob:worker')
+    expect(worker.options).toEqual({ name: 'dsh-file-upload' })
+    expect(worker.postMessage).toHaveBeenCalledWith({
+      url: 'https://harness.test/api/upload', body, headers: {},
+    })
+    worker.onmessage?.({ data: { kind: 'progress', loaded: 2 } } as MessageEvent)
+    worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent)
+    worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
+    worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent)
+    await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
+    controller.abort()
+    expect(progress).toHaveBeenCalledWith({ loaded: 2 })
+    expect(progress).toHaveBeenCalledWith({ loaded: 4, total: 5 })
+    expect(worker.terminate).toHaveBeenCalledOnce()
+  })
+
+  it('rejects worker messages, worker errors, and caller cancellation', async () => {
+    vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
+    vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
+    class FakeWorker {
+      static all: FakeWorker[] = []
+      onmessage: ((event: MessageEvent) => void) | null = null
+      onerror: ((event: ErrorEvent) => void) | null = null
+      readonly postMessage = vi.fn()
+      readonly terminate = vi.fn()
+      constructor() { FakeWorker.all.push(this) }
+    }
+    vi.stubGlobal('Worker', FakeWorker)
+    const transport = createBackgroundUploadTransport()
+
+    const reported = transport.post({ path: '/upload', body: new Blob() })
+    FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
+    await expect(reported).rejects.toThrow('network failed')
+
+    const errored = transport.post({ path: '/upload', body: new Blob() })
+    FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
+    await expect(errored).rejects.toThrow('worker crashed')
+
+    const unnamed = transport.post({ path: '/upload', body: new Blob() })
+    FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
+    await expect(unnamed).rejects.toThrow('background upload worker failed')
+
+    const controller = new AbortController()
+    const aborted = transport.post({ path: '/upload', body: new Blob(), signal: controller.signal })
+    controller.abort()
+    await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
+    expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
+
+    const already = new AbortController()
+    already.abort()
+    await expect(transport.post({ path: '/upload', body: new Blob(), signal: already.signal }))
+      .rejects.toMatchObject({ name: 'AbortError' })
+    expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
+  })
+})

+ 16 - 4
packages/client/connection/tests/fetch-routes.host.spec.ts

@@ -25,7 +25,8 @@ describe('Connection exact Fetch routes', () => {
       Response.json({ query: new URL(request.url).searchParams.get('sessionId') }))
     const dispose = connection.fetch.register({
       path: '/api/session.export',
-      methods: ['GET', 'HEAD'],
+      methods: ['GET', 'HEAD', 'POST'],
+      requestBody: 'streaming',
       fetch: route,
     })
     const shared = connection.createSharedFetchHandler('/api')
@@ -33,11 +34,18 @@ describe('Connection exact Fetch routes', () => {
     const response = await shared.fetch(new Request(
       'http://host/api/session.export?sessionId=session-1',
     ))
+    expect(shared.requestBodyMode({
+      method: 'POST', url: new URL('http://host/api/session.export'),
+    })).toBe('streaming')
+    expect(shared.requestBodyMode({
+      method: 'DELETE', url: new URL('http://host/api/session.export'),
+    })).toBe('buffered')
     expect(response.status).toBe(200)
     expect(await response.json()).toEqual({ query: 'session-1' })
     expect(route).toHaveBeenCalledOnce()
     const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' }))
-    expect(post.status).toBe(404)
+    expect(post.status).toBe(200)
+    expect(route).toHaveBeenCalledTimes(2)
 
     await dispose()
     const withdrawn = await shared.fetch(new Request('http://host/api/session.export'))
@@ -49,22 +57,26 @@ describe('Connection exact Fetch routes', () => {
     const { connection, dispose: disposeFiber } = await mounted()
     const fetch = async (): Promise<Response> => new Response()
 
-    expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch }))
+    expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], requestBody: 'buffered', fetch }))
       .toThrow('invalid exact Fetch route')
-    expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch }))
+    expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], requestBody: 'buffered', fetch }))
       .toThrow('declares no methods')
     expect(() => connection.fetch.register({
       path: '/api/session.export', methods: ['GET', 'GET'], fetch,
+      requestBody: 'buffered',
     })).toThrow('repeats a method')
     const dispose = connection.fetch.register({
       path: '/api/session.export', methods: ['GET'], fetch,
+      requestBody: 'buffered',
     })
     expect(() => connection.fetch.register({
       path: '/api/session.export', methods: ['HEAD'], fetch,
+      requestBody: 'buffered',
     })).toThrow('already registered')
     await dispose()
     expect(() => connection.fetch.register({
       path: '/api/session.export', methods: ['HEAD'], fetch,
+      requestBody: 'buffered',
     })).not.toThrow()
     await disposeFiber()
   })

+ 6 - 6
packages/client/connection/tests/fixture-commands.client.spec.ts

@@ -22,7 +22,7 @@ const sid = (id: string): SessionId => id as SessionId
 describe('createFixtureApi commands/skills', () => {
   it('serves the addressed session catalog', async () => {
     const { rpc } = createFixtureFaces()
-    const commands = await callRemote<{ name: string; input?: { hint: string; images?: boolean } }[]>(
+    const commands = await callRemote<{ name: string; input?: { hint: string; attachments?: boolean } }[]>(
       rpc, 'commands/list', { agentId: sid('fx-alpha') })
     expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
     // input hint rides only the commands declaring it.
@@ -30,7 +30,7 @@ describe('createFixtureApi commands/skills', () => {
     expect(echo?.input?.hint).toBeTruthy()
     expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
     // Image acceptance is declared per descriptor; only goal and plan carry it.
-    expect(commands.filter(c => c.input?.images === true).map(c => c.name)).toEqual(['goal', 'plan'])
+    expect(commands.filter(c => c.input?.attachments === true).map(c => c.name)).toEqual(['goal', 'plan'])
   })
 
   it('rejects a catalog request for an unknown session', async () => {
@@ -97,14 +97,14 @@ describe('createFixtureApi commands/skills', () => {
     const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>(
       rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] })
     expect(refused?.commandId).toBeTruthy()
-    expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' })
+    expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept attachments' })
     await pump
     const events = frames
       .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'event')
       .map(f => f.event)
     expect(events).toMatchObject([
       { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } },
-      { type: 'command/done', data: { kind: 'error', text: '/echo does not accept image attachments' } },
+      { type: 'command/done', data: { kind: 'error', text: '/echo does not accept attachments' } },
     ])
   })
 
@@ -129,13 +129,13 @@ describe('createFixtureApi commands/skills', () => {
       rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] })
     expect(bareGoal?.result).toEqual({
       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>.',
     })
     const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
       rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] })
     expect(refused?.result).toEqual({
       kind: 'error',
-      text: 'Image attachments cannot accompany /plan off.',
+      text: 'Attachments cannot accompany /plan off.',
     })
   })
 

+ 72 - 0
packages/client/connection/tests/http-bridge.host.spec.ts

@@ -24,6 +24,7 @@ describe('HTTP bridge abort', () => {
     }) as unknown as ServerResponse
 
     await bridge(request, response, {
+      requestBodyMode: () => 'buffered',
       fetch: () => { throw new Error('a rejected request must never reach the handler') },
     }, 1000)
     // The socket must not stay parked draining a body the client can trickle
@@ -55,6 +56,7 @@ describe('HTTP bridge abort', () => {
     const started = new Promise<void>((resolve) => { resolveStarted = resolve })
     let carrierSignal: AbortSignal | undefined
     const pending = bridge(request, response, {
+      requestBodyMode: () => 'buffered',
       fetch: async (input) => {
         const fetchRequest = input
         carrierSignal = fetchRequest.signal
@@ -72,4 +74,74 @@ describe('HTTP bridge abort', () => {
     await pending
     expect(carrierSignal?.aborted).toBe(true)
   })
+
+  it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => {
+    const request = new Readable({ read() {} }) as unknown as IncomingMessage
+    Object.assign(request, {
+      url: '/api/session/uploadFileBinary?sessionId=s1',
+      method: 'POST',
+      headers: {
+        'content-type': 'application/octet-stream',
+        'content-length': String(Math.ceil(2.19 * 1024 ** 3)),
+      },
+    })
+    let status: number | undefined
+    const responseBytes: Uint8Array[] = []
+    const response = Object.assign(new EventEmitter(), {
+      writableEnded: false,
+      writeHead(code: number) { status = code; return this },
+      write(chunk: Uint8Array) { responseBytes.push(chunk); return true },
+      end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
+    }) as unknown as ServerResponse
+
+    let resolveStarted!: () => void
+    const started = new Promise<void>((resolve) => { resolveStarted = resolve })
+    const received: Uint8Array[] = []
+    const pending = bridge(request, response, {
+      requestBodyMode: () => 'streaming',
+      fetch: async (input) => {
+        resolveStarted()
+        if (input.body === null) throw new Error('streaming request lost its body')
+        for await (const chunk of input.body) received.push(chunk)
+        return new Response('stored')
+      },
+    }, 1)
+
+    await started
+    expect(received).toEqual([])
+    request.push(Buffer.from([1, 2]))
+    request.push(Buffer.from([3, 4]))
+    request.push(null)
+    await pending
+    expect(status).toBe(200)
+    expect(received).toEqual([Uint8Array.of(1, 2), Uint8Array.of(3, 4)])
+    expect(Buffer.concat(responseBytes).toString()).toBe('stored')
+  })
+
+  it('closes an unread streaming request after returning an early validation response', async () => {
+    const destroyed: true[] = []
+    const request = new Readable({ read() {} }) as unknown as IncomingMessage
+    Object.assign(request, {
+      url: '/api/session/uploadFileBinary',
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      destroy: () => { destroyed.push(true) },
+    })
+    let status: number | undefined
+    let headers: unknown
+    const response = Object.assign(new EventEmitter(), {
+      writableEnded: false,
+      writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
+      write() { return true },
+      end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
+    }) as unknown as ServerResponse
+
+    await bridge(request, response, {
+      requestBodyMode: () => 'streaming',
+      fetch: () => Promise.resolve(new Response(null, { status: 415 })),
+    }, 1)
+    expect(status).toBe(415)
+    expect(headers).toMatchObject({ connection: 'close' })
+    expect(destroyed).toEqual([true])
+  })
 })

+ 14 - 19
packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx

@@ -26,15 +26,16 @@ afterEach(() => { vi.unstubAllGlobals() })
 
 const labels: AttachmentRailLabels = {
   group: '待发送图片',
-  open: '查看原图',
   scrollLeft: '向左滚动图片',
   scrollRight: '向右滚动图片',
 }
 
 function item(id: string): AttachmentRailItem {
-  return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` }
+  return { id }
 }
 
+const renderItem = (entry: AttachmentRailItem) => <span>{entry.id}</span>
+
 /** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */
 function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
   Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true })
@@ -53,22 +54,16 @@ function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollW
 }
 
 describe('AttachmentRail', () => {
-  it('renders thumbnails in order and routes open and remove clicks', () => {
-    const onOpen = vi.fn()
-    const onRemove = vi.fn()
+  it('renders owner-provided attachment cards in order', () => {
     const items = [item('a'), item('b')]
-    const view = render(<AttachmentRail items={items} labels={labels} onOpen={onOpen} onRemove={onRemove} />)
+    const view = render(<AttachmentRail items={items} labels={labels} renderItem={renderItem} />)
     const rail = view.getByRole('group', { name: '待发送图片' })
-    expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png'])
-    fireEvent.click(view.getAllByTitle('查看原图')[0]!)
-    expect(onOpen).toHaveBeenCalledWith(items[0])
-    fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' }))
-    expect(onRemove).toHaveBeenCalledWith(items[1])
+    expect([...rail.children].map(child => child.textContent)).toEqual(['a', 'b'])
   })
 
   it('shows edge arrows from scroll geometry and pages a viewport at a time', () => {
     const view = render(
-      <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+      <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} renderItem={renderItem} />,
     )
     const rail = view.getByRole('group', { name: '待发送图片' })
     const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
@@ -94,7 +89,7 @@ describe('AttachmentRail', () => {
 
   it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => {
     const view = render(
-      <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+      <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} renderItem={renderItem} />,
     )
     const rail = view.getByRole('group', { name: '待发送图片' })
     const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
@@ -110,7 +105,7 @@ describe('AttachmentRail', () => {
   it('keeps scrolling available when ResizeObserver is unavailable', () => {
     vi.stubGlobal('ResizeObserver', undefined)
     const view = render(
-      <AttachmentRail items={[item('a')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+      <AttachmentRail items={[item('a')]} labels={labels} renderItem={renderItem} />,
     )
     expect(view.getByRole('group', { name: '待发送图片' })).toBeTruthy()
     view.unmount()
@@ -118,7 +113,7 @@ describe('AttachmentRail', () => {
 
   it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => {
     const view = render(
-      <AttachmentRail items={[item('a'), item('b')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+      <AttachmentRail items={[item('a'), item('b')]} labels={labels} renderItem={renderItem} />,
     )
     const rail = view.getByRole('group', { name: '待发送图片' })
     const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
@@ -148,7 +143,7 @@ describe('AttachmentRail', () => {
     for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) {
       vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
       const view = render(
-        <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+        <AttachmentRail items={[item('a'), item('b'), item('c')]} labels={labels} renderItem={renderItem} />,
       )
       const rail = view.getByRole('group', { name: '待发送图片' })
       const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
@@ -162,16 +157,16 @@ describe('AttachmentRail', () => {
   it('reveals the rail end when an item is added, not when one is removed', () => {
     const first = [item('a'), item('b')]
     const view = render(
-      <AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+      <AttachmentRail items={first} labels={labels} renderItem={renderItem} />,
     )
     const rail = view.getByRole('group', { name: '待发送图片' })
     stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 })
     view.rerender(
-      <AttachmentRail items={[...first, item('c')]} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+      <AttachmentRail items={[...first, item('c')]} labels={labels} renderItem={renderItem} />,
     )
     expect(rail.scrollLeft).toBe(200)
     view.rerender(
-      <AttachmentRail items={first} labels={labels} onOpen={vi.fn()} onRemove={vi.fn()} />,
+      <AttachmentRail items={first} labels={labels} renderItem={renderItem} />,
     )
     // Removal keeps the position; only growth jumps to the end.
     expect(rail.scrollLeft).toBe(200)

+ 127 - 21
packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx

@@ -22,24 +22,37 @@ afterEach(() => {
 
 const t = ((key: string, params?: Readonly<Record<string, unknown>>): string => {
   const messages: Record<string, string> = {
+    'attachment.pending': '待发送附件',
+    'attachment.scrollLeft': '向左滚动附件',
+    'attachment.scrollRight': '向右滚动附件',
+    'file.pending': '待发送文件',
+    'file.uploading': '上传中…',
+    'file.uploadFailed': '上传失败,点击重试',
+    'file.label': '文件',
     'image.pending': '待发送图片',
     'image.original': '原图',
     'image.preview': '原图预览',
     'image.closePreview': '关闭原图预览',
     'image.openOriginal': '查看原图',
-    'image.scrollLeft': '向左滚动图片',
-    'image.scrollRight': '向右滚动图片',
-    'image.dropBlocked': '当前无法添加图片',
-    'image.dropTitle': '图片拖动到此处即可添加',
+    'attachment.dropBlocked': '当前无法添加文件或图片',
+    'attachment.dropTitle': '文件或图片拖动到此处即可添加',
+  }
+  if (key === 'file.remove') {
+    const name = params?.name
+    return `移除文件 ${typeof name === 'string' ? name : ''}`
+  }
+  if (key === 'file.retry') {
+    const name = params?.name
+    return `重试上传 ${typeof name === 'string' ? name : ''}`
   }
   if (key === 'image.remove') {
     const name = params?.name
     return `移除图片 ${typeof name === 'string' ? name : ''}`
   }
-  if (key === 'image.dropDesc') {
+  if (key === 'attachment.dropDesc') {
     const count = params?.count
     const size = params?.size
-    return `最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}`
+    return `图片限制:最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}`
   }
   return messages[key] ?? key
 }) as ComposerAttachmentsProps['t']
@@ -53,12 +66,22 @@ function attachment(id: string, name = `${id}.png`): ComposerAttachment {
   }
 }
 
+function fileDraft(id: string, name = `${id}.pdf`): ComposerAttachment {
+  return {
+    kind: 'file',
+    id: id as ComposerAttachment['id'],
+    file: new File([Uint8Array.of(1, 2, 3)], name, { type: 'application/pdf' }),
+  }
+}
+
 function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): ComposerAttachmentsProps {
   return {
     attachments: [],
     canAcceptDrop: true,
-    onAddImages: () => {},
-    onRemoveImage: () => {},
+    onAddFiles: () => {},
+    onRemoveAttachment: () => {},
+    uploads: {},
+    onRetryFile: () => {},
     t,
     ...overrides,
   } as unknown as ComposerAttachmentsProps
@@ -66,9 +89,9 @@ function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): Composer
 
 describe('ComposerAttachments', () => {
   it('accepts file drops anywhere on the document and keeps non-file drags native', () => {
-    const onAddImages = vi.fn()
+    const onAddFiles = vi.fn()
     const view = render(<ComposerAttachments {...props({
-      onAddImages,
+      onAddFiles,
       dropLimits: { count: 20, size: '5MB' },
     })} />)
 
@@ -82,12 +105,12 @@ describe('ComposerAttachments', () => {
     const image = attachment('dropped').file
     const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' }
     expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
-    expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
-    expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB')
+    expect(view.getByRole('status').textContent).toContain('文件或图片拖动到此处即可添加')
+    expect(view.getByRole('status').textContent).toContain('图片限制:最多 20 张,每张 5MB')
     expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
     expect(dataTransfer.dropEffect).toBe('copy')
     expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
-    expect(onAddImages).toHaveBeenCalledWith([image])
+    expect(onAddFiles).toHaveBeenCalledWith([image])
     expect(view.queryByRole('status')).toBeNull()
   })
 
@@ -118,30 +141,30 @@ describe('ComposerAttachments', () => {
   })
 
   it('shows a blocked drop without forwarding its files', () => {
-    const onAddImages = vi.fn()
-    const view = render(<ComposerAttachments {...props({ canAcceptDrop: false, onAddImages })} />)
+    const onAddFiles = vi.fn()
+    const view = render(<ComposerAttachments {...props({ canAcceptDrop: false, onAddFiles })} />)
     const image = attachment('blocked').file
     const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
     fireEvent.dragEnter(document.body, { dataTransfer })
-    expect(view.getByRole('status').textContent).toBe('当前无法添加图片')
+    expect(view.getByRole('status').textContent).toBe('当前无法添加文件或图片')
     fireEvent.dragOver(document.body, { dataTransfer })
     expect(dataTransfer.dropEffect).toBe('none')
     fireEvent.drop(document.body, { dataTransfer })
-    expect(onAddImages).not.toHaveBeenCalled()
+    expect(onAddFiles).not.toHaveBeenCalled()
     expect(view.queryByRole('status')).toBeNull()
   })
 
   it('routes rail removal and closes previews on Escape or attachment removal', () => {
-    const onRemoveImage = vi.fn()
+    const onRemoveAttachment = vi.fn()
     const image = attachment('draft-1', 'pixel.png')
-    const initial = props({ attachments: [image], onRemoveImage })
+    const initial = props({ attachments: [image], onRemoveAttachment })
     const view = render(<ComposerAttachments {...initial} />)
 
     fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
-    expect(onRemoveImage).toHaveBeenCalledWith(image.id)
+    expect(onRemoveAttachment).toHaveBeenCalledWith(image.id)
     fireEvent.click(view.getByTitle('查看原图'))
     expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
-    view.rerender(<ComposerAttachments {...props({ attachments: [], onRemoveImage })} />)
+    view.rerender(<ComposerAttachments {...props({ attachments: [], onRemoveAttachment })} />)
     expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
 
     view.rerender(<ComposerAttachments {...initial} />)
@@ -150,6 +173,24 @@ describe('ComposerAttachments', () => {
     expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
   })
 
+  it('keeps images and files in pick order inside one attachment rail', () => {
+    const view = render(<ComposerAttachments {...props({
+      attachments: [attachment('first'), fileDraft('middle'), attachment('last')],
+      uploads: {
+        middle: {
+          status: 'ready', receiptId: 'receipt-middle' as never,
+          file: { attachmentId: 'file-middle' as never, name: 'middle.pdf', bytes: 3 },
+        },
+      },
+    })} />)
+    const rail = view.getByRole('group', { name: '待发送附件' })
+    expect([...rail.children].map((child) => {
+      const image = child.querySelector('img')
+      return image?.getAttribute('alt') ?? child.querySelector('[title]')?.getAttribute('title')
+    })).toEqual(['first.png', 'middle.pdf', 'last.png'])
+    expect(view.queryByRole('group', { name: '待发送文件' })).toBeNull()
+  })
+
   it('labels an unnamed attachment and its original-image preview', () => {
     const image = attachment('unnamed', '')
     const view = render(<ComposerAttachments {...props({ attachments: [image] })} />)
@@ -158,3 +199,68 @@ describe('ComposerAttachments', () => {
     expect(view.getByAltText('原图')).toBeTruthy()
   })
 })
+
+describe('ComposerAttachments file drafts', () => {
+  it('renders uploading, ready, and failed cards with remove and retry affordances', () => {
+    const onRemoveAttachment = vi.fn()
+    const onRetryFile = vi.fn()
+    const view = render(<ComposerAttachments {...props({
+      attachments: [fileDraft('up'), fileDraft('ok'), fileDraft('bad')],
+      uploads: {
+        up: { status: 'uploading', loaded: 1, total: 4 },
+        ok: {
+          status: 'ready', receiptId: 'receipt-ok' as never,
+          file: { attachmentId: 'file-ok' as never, name: 'ok.pdf', bytes: 3 },
+        },
+        bad: { status: 'error', message: 'boom' },
+      },
+      onRemoveAttachment,
+      onRetryFile,
+    })} />)
+    const group = view.getByRole('group', { name: '待发送附件' })
+    expect(group.textContent).toContain('上传中…')
+    expect(view.container.querySelector('[style="width: 25%;"]')).toBeTruthy()
+    expect(group.textContent).toContain('ok.pdf')
+    expect(group.textContent).toContain('PDF 3B')
+    expect(group.textContent).toContain('上传失败,点击重试')
+    fireEvent.click(view.getByRole('button', { name: '重试上传 bad.pdf' }))
+    expect(onRetryFile).toHaveBeenCalledWith('bad')
+    fireEvent.click(view.getByRole('button', { name: '移除文件 ok.pdf' }))
+    expect(onRemoveAttachment).toHaveBeenCalledWith('ok')
+  })
+
+  it('treats a draft without upload state as uploading and keeps retry separate from remove', () => {
+    const onRetryFile = vi.fn()
+    const onRemoveAttachment = vi.fn()
+    const view = render(<ComposerAttachments {...props({
+      attachments: [fileDraft('pending'), fileDraft('bad')],
+      uploads: { bad: { status: 'error', message: 'boom' } },
+      onRetryFile,
+      onRemoveAttachment,
+    })} />)
+    expect(view.getByRole('group', { name: '待发送附件' }).textContent).toContain('上传中…')
+    const retry = view.getByRole('button', { name: '重试上传 bad.pdf' })
+    const remove = view.getByRole('button', { name: '移除文件 bad.pdf' })
+    expect(retry.contains(remove)).toBe(false)
+    fireEvent.click(remove)
+    expect(onRemoveAttachment).toHaveBeenCalledWith('bad')
+    expect(onRetryFile).not.toHaveBeenCalled()
+    fireEvent.click(retry)
+    expect(onRetryFile).toHaveBeenCalledWith('bad')
+  })
+
+  it('uses the localized file label when the browser supplies no name', () => {
+    const view = render(<ComposerAttachments {...props({
+      attachments: [fileDraft('unnamed', '')],
+      uploads: {
+        unnamed: {
+          status: 'ready', receiptId: 'receipt-unnamed' as never,
+          file: { attachmentId: 'file-unnamed' as never, name: 'file', bytes: 3 },
+        },
+      },
+    })} />)
+    const group = view.getByRole('group', { name: '待发送附件' })
+    expect(group.textContent).toContain('文件')
+    expect(group.textContent).toContain('3B')
+  })
+})

+ 9 - 4
packages/client/ui-attachment/tests/message-image.client.spec.tsx

@@ -219,11 +219,16 @@ describe('ImageGallery', () => {
     expect(view.getByAltText('echo.png')).toBeTruthy()
   })
 
-  it('renders a lone image large and several images as square tiles', () => {
+  it('renders a lone image large and compact or grouped images as square tiles', () => {
     const load = vi.fn(() => new Promise<string>(() => {}))
     const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
     expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
     lone.unmount()
+    const compact = render(
+      <ImageGallery images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
+    )
+    expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1)
+    compact.unmount()
     const several = render(
       <ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
     )
@@ -272,9 +277,9 @@ describe('ImageGallery', () => {
       useInput,
       inputActions: {
         setDraft: vi.fn(),
-        addImages: vi.fn(() => true),
-        removeImage: vi.fn(),
-        pruneImages: vi.fn(),
+        addAttachments: vi.fn(() => true),
+        removeAttachment: vi.fn(),
+        pruneAttachments: vi.fn(),
         submit: vi.fn(),
       },
       images: [{ attachment }],

+ 26 - 0
packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx

@@ -1041,3 +1041,29 @@ describe('small branch tails', () => {
     expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok')
   })
 })
+
+describe('user file attachments', () => {
+  it('renders one card per durable file block with its name and compact size', () => {
+    const view = render(
+      <MessageItem
+        t={t}
+        node={{
+          kind: 'user',
+          seq: 1,
+          time: 1_000,
+          content: [
+            { type: 'file', attachment: { attachmentId: 'sha256:cd', name: 'notes.pdf', bytes: 3 * 1024 * 1024 + 200 * 1024 } },
+            { type: 'file', attachment: { attachmentId: 'sha256:ef', name: 'tiny.txt', bytes: 12 } },
+            { type: 'file', attachment: { attachmentId: 'sha256:aa', name: 'mid.csv', bytes: 500 * 1024 } },
+            { type: 'text', text: 'summarize these' },
+          ] as never,
+          source: null,
+        }}
+      />,
+    )
+    expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB')
+    expect(view.getByTitle('tiny.txt').textContent).toContain('12B')
+    expect(view.getByTitle('mid.csv').textContent).toContain('500KB')
+    expect(view.getByText('summarize these')).toBeTruthy()
+  })
+})

+ 71 - 14
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -355,9 +355,9 @@ function makeHarness(
     useInput: (() => { throw new Error('unused') }),
     inputActions: {
       setDraft: () => {},
-      addImages: () => true,
-      removeImage: () => {},
-      pruneImages: () => {},
+      addAttachments: () => true,
+      removeAttachment: () => {},
+      pruneAttachments: () => {},
       submit: () => {},
     },
     useStore: bindSnapshotSelector(chat),
@@ -1012,7 +1012,7 @@ describe('ChatView', () => {
         pendingSubmissions: [
           {
             requestId: 'req-1' as never, placement: 'transcript',
-            time: 5_000, text: '即发即显', images: [],
+            time: 5_000, text: '即发即显', attachments: [],
           },
         ],
       },
@@ -1052,7 +1052,7 @@ describe('ChatView', () => {
           placement: 'steering',
           time: 5_500,
           text: '带图纠偏',
-          images: [{ previewUrl: 'blob:steer-preview', name: 'steer.png' }],
+          attachments: [{ type: 'image', previewUrl: 'blob:steer-preview', name: 'steer.png' }],
         }],
       },
     )
@@ -1086,7 +1086,7 @@ describe('ChatView', () => {
         pendingSubmissions: [
           {
             requestId: 'req-q' as never, placement: 'queued',
-            time: 6_000, text: '排队中', images: [],
+            time: 6_000, text: '排队中', attachments: [],
           },
         ],
       },
@@ -1120,9 +1120,9 @@ describe('ChatView', () => {
           placement: 'transcript',
           time: 7_000,
           text: '',
-          images: [
-            { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
-            { previewUrl: 'blob:echo-b' },
+          attachments: [
+            { type: 'image', previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
+            { type: 'image', previewUrl: 'blob:echo-b' },
           ],
         }],
       },
@@ -1130,17 +1130,74 @@ describe('ChatView', () => {
     const baseRenderSlot = h.props.renderSlot
     const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
       if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
-      const images = (owner as { images: readonly unknown[] }).images
-      return <div data-testid="echo-images" data-count={images.length} data-first={JSON.stringify(images[0])} />
+      const { images, compact } = owner as { images: readonly unknown[]; compact?: boolean }
+      return (
+        <div
+          data-testid="echo-image"
+          data-count={images.length}
+          data-compact={String(compact)}
+          data-first={JSON.stringify(images[0])}
+        />
+      )
     }) as unknown as ChatViewSlotProps['renderSlot']
     const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
-    const gallery = view.getByTestId('echo-images')
-    expect(gallery.getAttribute('data-count')).toBe('2')
-    expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({
+    const images = view.getAllByTestId('echo-image')
+    expect(images).toHaveLength(2)
+    expect(images.every(image => image.getAttribute('data-count') === '1')).toBe(true)
+    expect(images.every(image => image.getAttribute('data-compact') === 'true')).toBe(true)
+    expect(images[0]?.parentElement).toBe(images[1]?.parentElement)
+    expect(JSON.parse(images[0]?.getAttribute('data-first') ?? '{}')).toEqual({
       preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 },
     })
   })
 
+  it('a mixed echo renders the Web file card between its selected images', () => {
+    const h = makeHarness(
+      { nodes: [] },
+      {
+        pendingSubmissions: [{
+          requestId: 'req-mixed' as never,
+          placement: 'transcript',
+          time: 7_500,
+          text: '',
+          attachments: [
+            { type: 'image', previewUrl: 'blob:first', name: 'first.png' },
+            {
+              type: 'file',
+              attachment: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 },
+            },
+            { type: 'image', previewUrl: 'blob:last', name: 'last.png' },
+          ],
+        }],
+      },
+    )
+    const baseRenderSlot = h.props.renderSlot
+    const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
+      if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never)
+      const { images, compact } = owner as {
+        images: ReadonlyArray<{ preview?: { name?: string } }>
+        compact?: boolean
+      }
+      return (
+        <div
+          data-testid={`images-${images[0]?.preview?.name ?? 'unknown'}`}
+          data-compact={String(compact)}
+        />
+      )
+    }) as unknown as ChatViewSlotProps['renderSlot']
+    const view = render(<h.ChatView {...{ ...h.props, renderSlot }} />)
+    const first = view.getByTestId('images-first.png')
+    const file = view.getByTitle('notes.txt')
+    const last = view.getByTestId('images-last.png')
+    expect(first.getAttribute('data-compact')).toBe('true')
+    expect(last.getAttribute('data-compact')).toBe('true')
+    expect(first.parentElement).toBe(file.parentElement)
+    expect(file.parentElement).toBe(last.parentElement)
+    expect(first.compareDocumentPosition(file) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
+    expect(file.compareDocumentPosition(last) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
+    expect(view.getByText('TXT 23B')).toBeTruthy()
+  })
+
   it('animates only the latest unresolved model retry', () => {
     const retryNode = retry(2)
     const nextRetry = { ...retry(3), turn: 2, retry: 2 }

+ 6 - 6
packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx

@@ -153,9 +153,9 @@ describe('render branch tails', () => {
         useInput={(() => { throw new Error('unused') })}
         inputActions={{
           setDraft: () => {},
-          addImages: () => true,
-          removeImage: () => {},
-          pruneImages: () => {},
+          addAttachments: () => true,
+          removeAttachment: () => {},
+          pruneAttachments: () => {},
           submit: () => {},
         }}
         useStore={bindSnapshotSelector(chat)}
@@ -215,9 +215,9 @@ describe('render branch tails', () => {
         useInput={(() => { throw new Error('unused') })}
         inputActions={{
           setDraft: () => {},
-          addImages: () => true,
-          removeImage: () => {},
-          pruneImages: () => {},
+          addAttachments: () => true,
+          removeAttachment: () => {},
+          pruneAttachments: () => {},
           submit: () => {},
         }}
         useStore={bindSnapshotSelector(chat)}

+ 33 - 33
packages/client/ui-commands/tests/service.client.spec.ts

@@ -13,7 +13,7 @@ import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
-import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
+import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
 import type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandUiRuntime } from '../src/client/service.ts'
@@ -64,7 +64,7 @@ async function bench(opts: BenchOptions = {}) {
   const ctx = new Context()
   const registered = new Map<string, InputTriggerSource>()
   const listCalls: Array<{ sessionId: SessionId }> = []
-  const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = []
+  const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitAttachment[] }> = []
   // The service reads the generated commands Remote, which delivers the
   // carrier's outcome, so a programmed failure answers the error branch.
   const commandsRemote = {
@@ -77,7 +77,7 @@ async function bench(opts: BenchOptions = {}) {
         return value.commands
       })
     },
-    execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => {
+    execute: async (sessionId: SessionId, line: string, images: readonly SubmitAttachment[] = []) => {
       executeCalls.push({ sessionId, line, images })
       return await carried(async () => {
         const fallback = (): Promise<ExecuteValue> => Promise.resolve({ matched: true })
@@ -288,9 +288,9 @@ describe('decorations (bare-invocation UI on host commands)', () => {
     command.decorate(goalDecoration())
     const scope = mint('s1')
     await warm(proj('s1'))
-    expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled')
+    expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { attachments: 0 })).toBe('handled')
     expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
-    const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 })
+    const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { attachments: 0 })
     if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
     expect(argued.claim.token).toBe('/goal ')
   })
@@ -309,7 +309,7 @@ describe('decorations (bare-invocation UI on host commands)', () => {
     command.decorate(goalDecoration({ name: 'phantom' }))
     const scope = mint('s1')
     await warm(proj('s1'))
-    expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined()
+    expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { attachments: 0 })).toBeUndefined()
     expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
     expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
   })
@@ -318,7 +318,7 @@ describe('decorations (bare-invocation UI on host commands)', () => {
     const { command, source, warm, executeCalls } = await bench()
     command.decorate(goalDecoration({ name: 'plan', available: () => false }))
     await warm(proj('s1'))
-    expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled')
+    expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })).toBe('handled')
     expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
   })
 
@@ -431,7 +431,7 @@ describe('matchEnter (enter column)', () => {
     const { source } = await bench({
       commands: () => new Promise((resolve) => { release = resolve }),
     })
-    const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 })
+    const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 })
     release({ commands: S1_CMDS })
     const outcome = await wait
     if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
@@ -442,14 +442,14 @@ describe('matchEnter (enter column)', () => {
     const { source } = await bench({
       commands: () => Promise.reject(new Error('warmup boom')),
     })
-    await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom')
+    await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { attachments: 0 })).rejects.toThrow('warmup boom')
   })
 
   it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
     const { source, warm } = await bench()
     await warm(proj('s1'))
     for (const line of ['/goal', '/goal refactor the loop']) {
-      const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 })
+      const outcome = await source.matchEnter!(proj('s1'), line, signal(), { attachments: 0 })
       if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
       expect(outcome.claim.token).toBe('/goal ')
     }
@@ -464,7 +464,7 @@ describe('matchEnter (enter column)', () => {
       return true
     })
     await warm(proj('s1'))
-    await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled')
+    await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 0 })).resolves.toBe('handled')
     expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
     await Promise.resolve()
     expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
@@ -473,7 +473,7 @@ describe('matchEnter (enter column)', () => {
   it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
     const { source, warm, executeCalls } = await bench()
     await warm(proj('s1'))
-    await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined()
+    await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { attachments: 0 })).resolves.toBeUndefined()
     expect(executeCalls).toEqual([])
   })
 
@@ -481,18 +481,18 @@ describe('matchEnter (enter column)', () => {
     const { command, source, mint, listCalls } = await bench()
     command.register(themeContribution())
     const scope = mint('s1')
-    await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled')
+    await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 0 })).resolves.toBe('handled')
     expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
     expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
-    await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined()
+    await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { attachments: 0 })).resolves.toBeUndefined()
   })
 
   it('unknown name, bare "/", and non-slash lines → undefined', async () => {
     const { source, warm } = await bench()
     await warm(proj('s1'))
-    await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined()
-    await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined()
-    await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined()
+    await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { attachments: 0 })).resolves.toBeUndefined()
+    await expect(source.matchEnter!(proj('s1'), '/', signal(), { attachments: 0 })).resolves.toBeUndefined()
+    await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { attachments: 0 })).resolves.toBeUndefined()
   })
 })
 
@@ -500,19 +500,19 @@ describe('matchEnter envelope policy (images)', () => {
   const signal = () => new AbortController().signal
   const IMG_CMDS: CommandDescriptor[] = [
     ...S1_CMDS,
-    { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } },
+    { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', attachments: true } },
   ]
-  const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' }
+  const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' }
 
   it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => {
     const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) })
     await warm(proj('s1'))
-    await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 }))
-      .rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}')
-    const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 })
+    await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { attachments: 1 }))
+      .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"goal"}')
+    const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { attachments: 1 })
     if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
     expect(outcome.claim.token).toBe('/vision ')
-    expect(outcome.claim.images).toBe(true)
+    expect(outcome.claim.attachments).toBe(true)
   })
 
   it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => {
@@ -521,18 +521,18 @@ describe('matchEnter envelope policy (images)', () => {
     command.decorate({ name: 'plan', available: () => true, ui: themeUi() })
     const scope = mint('s1')
     await warm(proj('s1'))
-    await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 }))
-      .rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}')
-    await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 }))
-      .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}')
+    await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 1 }))
+      .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"theme"}')
+    await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 2 }))
+      .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}')
     expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
   })
 
   it('bare host detached execute refuses images before any RPC', async () => {
     const { source, warm, executeCalls } = await bench()
     await warm(proj('s1'))
-    await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 }))
-      .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}')
+    await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 1 }))
+      .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}')
     expect(executeCalls).toEqual([])
   })
 
@@ -543,7 +543,7 @@ describe('matchEnter envelope policy (images)', () => {
       execute: () => Promise.resolve({ matched: true, result }),
     })
     await warm(proj('s1'))
-    const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 })
+    const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { attachments: 1 })
     if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
     // Handler error: the error outcome keeps draft and images in the composer.
     await expect(outcome.claim.submit('x', new Context(), [png]))
@@ -643,7 +643,7 @@ describe('detached admission notices', () => {
 
     // Admission miss (matched:false): immediate composer feedback stays.
     mode = 'miss'
-    await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })
+    await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })
     await flush()
     expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
 
@@ -722,7 +722,7 @@ describe('popupFor', () => {
       consumes.push(r)
       return true
     })
-    await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 })
+    await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 })
     const popup = command.popupFor(scope.ctx)
     await Promise.resolve()
     await popup.select(0)
@@ -733,7 +733,7 @@ describe('popupFor', () => {
     const { command, source, mint } = await bench()
     command.register(themeContribution())
     const scope = mint('s1')
-    await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 })
+    await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 })
     const popup = command.popupFor(scope.ctx)
     expect(popup.state.getSnapshot().open).toBe(true)
 

+ 46 - 1
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -28,6 +28,13 @@ function sessionFakeFor() {
     loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
     prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
     cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
+    uploadFile: vi.fn<ISession['uploadFile']>(() => Promise.resolve({
+      ok: true,
+      value: {
+        receiptId: 'root-receipt' as never,
+        file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 },
+      },
+    })),
   } satisfies SessionBehaviorOverrides
 }
 
@@ -213,6 +220,31 @@ describe('Conversation inject API', () => {
     await b.runtime.dispose()
   })
 
+  it('releases a draft attachment only after the input shell accepts its removal', async () => {
+    const b = await bench()
+    const composer = b.composerApi(ROOT)
+    expect(composer.addFiles?.([
+      new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }),
+    ])).toBeNull()
+    const controller = b.runtime.ctx.get('conversation') as unknown as {
+      releaseDraftAttachment(id: string): void
+    }
+    const release = vi.spyOn(controller, 'releaseDraftAttachment')
+    const input = b.inputApi(ROOT).actions
+    const remove = vi.spyOn(input, 'removeAttachment').mockReturnValue(false)
+    const draft = composer.resolveDraftAttachments?.(
+      b.inputApi(ROOT).state.getSnapshot().attachmentIds,
+    )[0]
+    if (draft === undefined) throw new Error('missing draft attachment')
+
+    composer.removeAttachment?.(draft.id)
+    expect(release).not.toHaveBeenCalled()
+    remove.mockReturnValueOnce(true)
+    composer.removeAttachment?.(draft.id)
+    expect(release).toHaveBeenCalledWith(draft.id)
+    await b.runtime.dispose()
+  })
+
   it('fails loud for an unknown binding or an unloaded scoped service', async () => {
     const b = await bench()
     const entry = b.entryOf('conversation.composer.bar')
@@ -240,6 +272,10 @@ describe('Conversation inject API', () => {
     const resident = b.residentApi(ROOT)
     const { state, actions } = b.inputApi(ROOT)
     actions.setDraft('carry me')
+    expect(b.composerApi(ROOT).addFiles?.([
+      new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }),
+    ])).toBeNull()
+    await vi.waitFor(() => { expect(b.sessionFake.uploadFile).toHaveBeenCalledOnce() })
 
     b.connectWorkspace.mockResolvedValueOnce(ROOT)
     await resident.selectWorkspace('workspace-1' as WorkspaceId)
@@ -247,12 +283,21 @@ describe('Conversation inject API', () => {
     expect(state.getSnapshot().draft).toBe('carry me')
 
     const other = 'other-1' as SessionId
-    await b.runtime.sessions.add({ id: other }, { current: false })
+    const targetUpload = vi.fn<ISession['uploadFile']>(() => Promise.resolve({
+      ok: true,
+      value: {
+        receiptId: 'target-receipt' as never,
+        file: { attachmentId: 'target-file' as never, name: 'draft.pdf', bytes: 1 },
+      },
+    }))
+    await b.runtime.sessions.add({ id: other, session: { uploadFile: targetUpload } }, { current: false })
     b.connectWorkspace.mockResolvedValueOnce(other)
     await resident.selectWorkspace('workspace-2' as WorkspaceId)
     expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })
     expect(state.getSnapshot().draft).toBe('')
     expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me')
+    await vi.waitFor(() => { expect(targetUpload).toHaveBeenCalledOnce() })
+    expect(b.inputApi(other).state.getSnapshot().attachmentIds).toHaveLength(1)
     await b.runtime.dispose()
   })
 

+ 1 - 0
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -46,6 +46,7 @@ function fakeSession(): SessionFace {
     subscribe: listener => snapshot.subscribe(listener),
     beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }),
     prompt: () => Promise.reject(new Error('unused fake Session operation')),
+    uploadFile: () => Promise.reject(new Error('unused fake Session operation')),
     readAttachment: () => Promise.reject(new Error('unused fake Session operation')),
     updateQueue: () => Promise.reject(new Error('unused fake Session operation')),
     cancel: () => Promise.reject(new Error('unused fake Session operation')),

+ 58 - 38
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -51,6 +51,8 @@ interface BenchOptions {
   planEntry?: React.ReactNode
   /** The `plan` projection value the standard-kit useProjection serves. */
   plan?: { active: boolean; pending: boolean }
+  /** The `goal` projection value used only to prove attachment intake remains ordinary. */
+  goal?: { phase: 'active'; objective: string }
   modelEntry?: React.ReactNode
   /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
   lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
@@ -86,7 +88,7 @@ interface BenchOptions {
   leftItems?: React.ReactNode
   rightItems?: React.ReactNode
   attachments?: readonly ComposerAttachment[]
-  addImages?: (files: readonly File[]) => string | null
+  addFiles?: (files: readonly File[]) => string | null
   commandMenuOpen?: boolean
   busyEnter?: 'queue' | 'steer'
   toggleCommandMenu?: (selection: { start: number; end: number }) => void
@@ -104,7 +106,7 @@ function row(id: string): SessionSnapshot['queue'][number] {
 function bench(over?: BenchOptions) {
   const sink = vi.fn<(
     text: string,
-    imageIds: readonly DraftAttachmentId[],
+    attachmentIds: readonly DraftAttachmentId[],
     mode: 'queue' | 'steer',
     signal: AbortSignal,
   ) => Promise<SubmitOutcome>>(() => Promise.resolve({ kind: 'success' }))
@@ -120,7 +122,7 @@ function bench(over?: BenchOptions) {
   const shell = new SessionInputShell({
     actx: SCTX,
     defaultSink: sink,
-    commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` },
+    commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` },
     queue: {
       getSnapshot: () => session.getSnapshot().queue,
       subscribe: fn => session.subscribe(fn),
@@ -138,9 +140,9 @@ function bench(over?: BenchOptions) {
       : {}),
   })
   if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
-  if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id))
+  if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id))
   const stop = vi.fn()
-  const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) })
+  const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) })
   const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
   const slotCalls: { key: string; owner: unknown }[] = []
   const renderSlot = ((key: string, owner: object) => {
@@ -165,13 +167,17 @@ function bench(over?: BenchOptions) {
     useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
       (selector ?? (v => v))(key === 'permissions'
         ? over?.permissions
-        : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)),
+        : key === 'plan' ? over?.plan
+          : key === 'goal' ? over?.goal
+            : key === 'imageLimits' ? over?.imageLimits : undefined)),
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
-    addImages: over?.addImages ?? (() => null),
-    removeImage,
-    draftImages: ids => ids.flatMap((id) => {
+    addFiles: over?.addFiles ?? (() => null),
+    useFileUploads: bindSnapshotSelector(createSnapshotStore({})),
+    retryFileUpload: undefined,
+    removeAttachment,
+    resolveDraftAttachments: ids => ids.flatMap((id) => {
       const attachment = over?.attachments?.find(candidate => candidate.id === id)
       return attachment === undefined ? [] : [attachment]
     }),
@@ -210,7 +216,7 @@ function bench(over?: BenchOptions) {
   )!
   const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
   return {
-    view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls,
+    view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls,
     menuLauncher,
     steerQueue: over?.steerQueue,
     get placeholder() { return placeholderOf(view.container) },
@@ -243,8 +249,8 @@ function writeDraft(shell: SessionInputShell, text: string): void {
 
 describe('image draft rail', () => {
   it('collects clipboard files while preserving text from a mixed paste', async () => {
-    const addImages = vi.fn(() => null)
-    const { textarea, shell } = bench({ addImages })
+    const addFiles = vi.fn(() => null)
+    const { textarea, shell } = bench({ addFiles })
     const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
     fireEvent.paste(textarea, {
       clipboardData: {
@@ -255,7 +261,7 @@ describe('image draft rail', () => {
         getData: () => '同时粘贴的文字',
       },
     })
-    expect(addImages).toHaveBeenCalledWith([image])
+    expect(addFiles).toHaveBeenCalledWith([image])
     // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away.
     await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') })
   })
@@ -271,40 +277,40 @@ describe('image draft rail', () => {
     }
     const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
     const intake = (result: ReturnType<typeof bench>, files: File[]) => {
-      act(() => { attachmentOwner(result.slotCalls).onAddImages(files) })
+      act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) })
     }
     // Count: three at once over a two-image limit → the whole batch refused.
-    const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits })
+    const overCount = bench({ addFiles: vi.fn(() => null), imageLimits: limits })
     intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
     expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
-    expect(overCount.props.addImages).not.toHaveBeenCalled()
+    expect(overCount.props.addFiles).not.toHaveBeenCalled()
     cleanup()
     // Per-file bytes.
-    const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
+    const overFile = bench({ addFiles: vi.fn(() => null), imageLimits: limits })
     intake(overFile, [png(1024 * 1024 + 1, 'big.png')])
     expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
-    expect(overFile.props.addImages).not.toHaveBeenCalled()
+    expect(overFile.props.addFiles).not.toHaveBeenCalled()
     cleanup()
     // Aggregate bytes across the existing rail plus the new batch.
     const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
     const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
-    const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
+    const overTotal = bench({ addFiles: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
     intake(overTotal, [png(1024 * 1024, 'more.png')])
     expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
-    expect(overTotal.props.addImages).not.toHaveBeenCalled()
+    expect(overTotal.props.addFiles).not.toHaveBeenCalled()
     cleanup()
-    // Within every limit: the batch passes through to addImages.
-    const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
+    // Within every limit: the batch passes through to addFiles.
+    const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits })
     const fits = png(16, 'fits.png')
     intake(within, [fits])
-    expect(within.props.addImages).toHaveBeenCalledWith([fits])
+    expect(within.props.addFiles).toHaveBeenCalledWith([fits])
     expect(within.view.queryByRole('alert')).toBeNull()
   })
 
   it('announces the format problem before any limit when the batch holds a non-image', () => {
-    const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
+    const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
     const result = bench({
-      addImages,
+      addFiles,
       imageLimits: {
         maxImageBytes: 8,
         maxImagesPerMessage: 1,
@@ -319,14 +325,14 @@ describe('image draft rail', () => {
       new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }),
       new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }),
     ]
-    act(() => { attachmentOwner(result.slotCalls).onAddImages(files) })
-    expect(addImages).toHaveBeenCalledWith(files)
+    act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) })
+    expect(addFiles).toHaveBeenCalledWith(files)
     expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
   })
 
   it('projects display-ready limits into the attachment slot', () => {
     const result = bench({
-      addImages: vi.fn(() => null),
+      addFiles: vi.fn(() => null),
       imageLimits: {
         maxImageBytes: 5 * 1024 * 1024,
         maxImagesPerMessage: 20,
@@ -368,7 +374,7 @@ describe('image draft rail', () => {
   })
 
   it('marks the attachment slot unavailable while the composer is locked', () => {
-    const result = bench({ addImages: vi.fn(() => null), inert: true })
+    const result = bench({ addFiles: vi.fn(() => null), inert: true })
     expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false)
   })
 
@@ -380,11 +386,11 @@ describe('image draft rail', () => {
       { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' },
     ]
     const result = bench({ attachments })
-    const { view, textarea, sink, removeImage } = result
+    const { view, textarea, sink, removeAttachment } = result
     expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false)
     const owner = attachmentOwner(result.slotCalls)
-    act(() => { owner.onRemoveImage('draft-2' as DraftAttachmentId) })
-    expect(removeImage).toHaveBeenCalledWith('draft-2')
+    act(() => { owner.onRemoveAttachment('draft-2' as DraftAttachmentId) })
+    expect(removeAttachment).toHaveBeenCalledWith('draft-2')
     let settle!: (outcome: SubmitOutcome) => void
     sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
     fireEvent.keyDown(textarea, { key: 'Enter' })
@@ -416,8 +422,8 @@ describe('image draft rail', () => {
   it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
     vi.useFakeTimers()
     try {
-      const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
-      const { view, textarea } = bench({ addImages })
+      const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
+      const { view, textarea } = bench({ addFiles })
       const paste = () => {
         fireEvent.paste(textarea, {
           clipboardData: {
@@ -439,10 +445,10 @@ describe('image draft rail', () => {
   })
 
   it('announces a rejected attachment-slot intake through the same toast', () => {
-    const addImages = vi.fn(() => '图片读取服务不可用')
-    const result = bench({ addImages })
+    const addFiles = vi.fn(() => '图片读取服务不可用')
+    const result = bench({ addFiles })
     act(() => {
-      attachmentOwner(result.slotCalls).onAddImages([
+      attachmentOwner(result.slotCalls).onAddFiles([
         new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }),
       ])
     })
@@ -775,7 +781,7 @@ describe('running and lock semantics', () => {
   })
 
   it('running continuable subagent keeps Send beside an independent Stop', () => {
-    const { button, interruptButton, textarea, sink, stop } = bench({
+    const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({
       running: true,
       draft: '后续消息',
       subagent: {
@@ -790,12 +796,26 @@ describe('running and lock semantics', () => {
     expect(button.getAttribute('aria-label')).toBe('发送消息')
     expect(interruptButton).not.toBeNull()
     expect(textarea.getAttribute('aria-disabled')).not.toBe('true')
+    expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true)
+    expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(true)
+    expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
     fireEvent.click(button)
     expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal))
     fireEvent.click(interruptButton!)
     expect(stop).toHaveBeenCalledTimes(1)
   })
 
+  it.each([
+    ['active plan', { plan: { active: true, pending: false } }],
+    ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }],
+  ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => {
+    const added = vi.fn(() => null)
+    const { view, slotCalls } = bench({ ...projection, addFiles: added })
+    expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false)
+    expect(view.container.querySelector<HTMLInputElement>('input[type="file"]')?.disabled).toBe(false)
+    expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true)
+  })
+
   it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
     const { button, interruptButton, textarea, stop, view } = bench({
       running: true,

+ 39 - 33
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -15,7 +15,7 @@ import {
 } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { SubmitImageAttachment, SubmitOutcome } from '../src/client/contract/input.ts'
+import type { SubmitAttachment, SubmitOutcome } from '../src/client/contract/input.ts'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { DraftAttachmentId } from '../src/client/contract/input.ts'
@@ -63,10 +63,12 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
-    addImages: () => null,
-    removeImage: () => {},
+    addFiles: () => null,
+    useFileUploads: bindSnapshotSelector(createSnapshotStore({})),
+    retryFileUpload: undefined,
+    removeAttachment: () => {},
     // Every id resolves so the bar's registry prune never drops a test image.
-    draftImages: ids => ids.map(id => ({
+    resolveDraftAttachments: ids => ids.map(id => ({
       kind: 'image' as const, id,
       file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }),
       previewUrl: `blob:${id}`,
@@ -89,22 +91,22 @@ function bench(over?: {
   running?: boolean
   disabled?: boolean
   submit?: (args: string) => Promise<SubmitOutcome>
-  serialize?: (ids: readonly DraftAttachmentId[]) => Promise<readonly SubmitImageAttachment[]>
+  serialize?: (ids: readonly DraftAttachmentId[]) => Promise<readonly SubmitAttachment[]>
 }) {
   const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
-  const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve<readonly SubmitImageAttachment[]>([])))
+  const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve<readonly SubmitAttachment[]>([])))
   const release = vi.fn()
-  const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } })
+  const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } })
   const wiring = shell
   const view = mountBar(shell, over)
   const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
-  const claim = (token = '/goal ', hint = '目标', images?: true) => {
+  const claim = (token = '/goal ', hint = '目标', attachments?: true) => {
     act(() => {
       shell.setDraft(token)
       shell.beginCommand(
         {
           token, hint,
-          ...(images === true ? { images: true } : {}),
+          ...(attachments === true ? { attachments: true } : {}),
           submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })),
         },
         { start: 0, end: token.length, draftRev: shell.snapshot.draftRev },
@@ -168,50 +170,52 @@ describe('matrix row: claimed', () => {
   })
 })
 
-describe('matrix row: claimed with images', () => {
+describe('matrix row: claimed with attachments', () => {
   const img = 'img-1' as DraftAttachmentId
 
-  it('a claim without image acceptance blocks enter: one notice, draft/images/claim retained', async () => {
+  it('a claim without attachment acceptance blocks enter: one notice and the full draft retained', async () => {
     const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
     const { view, textarea, shell, sink, claim } = bench({ submit })
     claim()
-    act(() => { shell.addImages([img]) })
+    act(() => { shell.addAttachments([img]) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     await Promise.resolve()
     expect(shell.snapshot.phase).toBe('claimed')
     expect(submit).not.toHaveBeenCalled()
     expect(sink).not.toHaveBeenCalled()
-    expect(view.getByText('/goal images-unsupported')).toBeTruthy()
-    expect(shell.snapshot.imageIds).toEqual([img])
+    expect(view.getByText('/goal attachments-unsupported')).toBeTruthy()
+    expect(shell.snapshot.attachmentIds).toEqual([img])
     expect(shell.snapshot.draft).toBe('/goal ')
   })
 
-  it('an accepting claim serializes and forwards the images; success consumes and clears', async () => {
+  it('an accepting claim serializes and forwards a mixed batch; success consumes and clears', async () => {
     const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
-    const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' }
-    const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png]) })
+    const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' }
+    const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' }
+    const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) })
     claim('/goal ', '目标', true)
     // The claim currency carries the acceptance flag the pre-gate reads.
-    expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', images: true })
-    act(() => { shell.addImages([img]) })
+    expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', attachments: true })
+    act(() => { shell.addAttachments([img]) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
-    await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) })
+    await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) })
     expect(serialize).toHaveBeenCalledWith([img])
     await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') })
     expect(release).toHaveBeenCalledWith([img])
-    expect(shell.snapshot.imageIds).toEqual([])
+    expect(shell.snapshot.attachmentIds).toEqual([])
     expect(shell.snapshot.phase).toBe('plain')
   })
 
-  it('a handler error outcome keeps the images unreleased beside the notice and the draft', async () => {
+  it('a handler error keeps a serialized file unreleased beside the notice and draft', async () => {
     const submit = vi.fn(() => Promise.resolve({ kind: 'error' as const, text: '处理失败' }))
-    const { view, textarea, shell, claim, release } = bench({ submit })
+    const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' }
+    const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.resolve([file]) })
     claim('/goal ', '目标', true)
-    act(() => { shell.addImages([img]) })
+    act(() => { shell.addAttachments([img]) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(view.getByText('处理失败')).toBeTruthy() })
     expect(shell.snapshot.phase).toBe('claimed')
-    expect(shell.snapshot.imageIds).toEqual([img])
+    expect(shell.snapshot.attachmentIds).toEqual([img])
     expect(release).not.toHaveBeenCalled()
     expect(shell.snapshot.draft).toBe('/goal ')
   })
@@ -220,28 +224,28 @@ describe('matrix row: claimed with images', () => {
     const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
     const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.reject(new Error('附件已失效')) })
     claim('/goal ', '目标', true)
-    act(() => { shell.addImages([img]) })
+    act(() => { shell.addAttachments([img]) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(view.getByText('附件已失效')).toBeTruthy() })
     expect(submit).not.toHaveBeenCalled()
-    expect(shell.snapshot.imageIds).toEqual([img])
+    expect(shell.snapshot.attachmentIds).toEqual([img])
     expect(release).not.toHaveBeenCalled()
     expect(shell.snapshot.phase).toBe('claimed')
   })
 
   it('a disposed shell never lets a pending serialization reach claim.submit', async () => {
     const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
-    let resolveSerialize!: (images: readonly SubmitImageAttachment[]) => void
+    let resolveSerialize!: (images: readonly SubmitAttachment[]) => void
     const { shell, textarea, claim } = bench({
       submit,
       serialize: () => new Promise((resolve) => { resolveSerialize = resolve }),
     })
     claim('/goal ', '目标', true)
-    act(() => { shell.addImages([img]) })
+    act(() => { shell.addAttachments([img]) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(resolveSerialize).toBeDefined() })
     shell.dispose()
-    resolveSerialize([{ mediaType: 'image/png', data: 'AA==' }])
+    resolveSerialize([{ type: 'image', mediaType: 'image/png', data: 'AA==' }])
     await Promise.resolve()
     await Promise.resolve()
     expect(submit).not.toHaveBeenCalled()
@@ -251,11 +255,13 @@ describe('matrix row: claimed with images', () => {
     const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
     const { shell, textarea, claim } = bench({ submit, serialize: () => Promise.resolve([]) })
     claim('/goal ', '目标', true)
-    act(() => { shell.addImages([img]) })
+    act(() => { shell.addAttachments([img]) })
     fireEvent.keyDown(textarea, { key: 'Enter' })
     expect(shell.snapshot.phase).toBe('submitting')
-    act(() => { shell.removeImage(img) })
-    expect(shell.snapshot.imageIds).toEqual([img])
+    let removed = true
+    act(() => { removed = shell.removeAttachment(img) })
+    expect(removed).toBe(false)
+    expect(shell.snapshot.attachmentIds).toEqual([img])
   })
 })
 

+ 19 - 19
packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts

@@ -11,10 +11,10 @@ import type { DraftAttachmentId } from '../src/client/contract/input.ts'
 
 const mention = '@[Research](dsh-session:InNvdXJjZSI)'
 const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)'
-const commandImages = {
+const commandAttachments = {
   serialize: () => Promise.resolve([]),
   release: () => {},
-  unsupportedNotice: (token: string) => `${token.trim()} images-unsupported`,
+  unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported`,
 }
 
 function chip(shell: SessionInputShell): void {
@@ -38,7 +38,7 @@ describe('reference submission', () => {
     const first = new SessionInputShell({
       actx: {} as Context,
       defaultSink: vi.fn(),
-      commandImages,
+      commandAttachments,
     })
     first.bindMirror(mirror)
     first.setDraft('@res')
@@ -62,7 +62,7 @@ describe('reference submission', () => {
     const restored = new SessionInputShell({
       actx: {} as Context,
       defaultSink: sink,
-      commandImages,
+      commandAttachments,
     })
     restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string)
     restored.submit()
@@ -90,7 +90,7 @@ describe('reference submission', () => {
       actx: {} as Context,
       inputTriggers: () => inputTriggers,
       defaultSink: sink,
-      commandImages,
+      commandAttachments,
     })
     chip(shell)
     expect(shell.snapshot).toMatchObject({
@@ -136,7 +136,7 @@ describe('reference submission', () => {
       actx: {} as Context,
       inputTriggers: () => inputTriggers,
       defaultSink: sink,
-      commandImages,
+      commandAttachments,
     })
     chip(shell)
     shell.submit()
@@ -160,7 +160,7 @@ describe('reference submission', () => {
         signal = received
         return new Promise<SubmitOutcome>(() => {})
       },
-      commandImages,
+      commandAttachments,
     })
     shell.setDraft('send this')
     shell.submit()
@@ -177,7 +177,7 @@ describe('reference submission', () => {
     const shell = new SessionInputShell({
       actx: {} as Context,
       defaultSink: () => Promise.resolve({ kind: 'error' }),
-      commandImages,
+      commandAttachments,
     })
     shell.setDraft('retry this')
     shell.submit()
@@ -193,7 +193,7 @@ describe('reference submission', () => {
     const shell = new SessionInputShell({
       actx: {} as Context,
       defaultSink: () => new Promise<SubmitOutcome>((resolve) => { settlements.push(resolve) }),
-      commandImages,
+      commandAttachments,
     })
     shell.setDraft('first')
     shell.submit()
@@ -215,18 +215,18 @@ describe('submit transaction hardening', () => {
     const shell = new SessionInputShell({
       actx: {} as Context,
       defaultSink: sink,
-      commandImages,
+      commandAttachments,
     })
-    expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true)
+    expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true)
     shell.submit('queue')
     shell.submit('queue')
     expect(sink).toHaveBeenCalledTimes(1)
     settle({ kind: 'success' })
     await vi.waitFor(() => {
-      expect(shell.snapshot.imageIds).toEqual([])
+      expect(shell.snapshot.attachmentIds).toEqual([])
     })
 
-    expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true)
+    expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true)
     shell.submit('queue')
     expect(sink).toHaveBeenCalledTimes(2)
   })
@@ -236,14 +236,14 @@ describe('submit transaction hardening', () => {
     const shell = new SessionInputShell({
       actx: {} as Context,
       defaultSink: sink,
-      commandImages,
+      commandAttachments,
     })
     const imageId = 'img-1' as DraftAttachmentId
-    shell.addImages([imageId])
+    shell.addAttachments([imageId])
     shell.submit()
     await Promise.resolve()
     await Promise.resolve()
-    expect(shell.snapshot.imageIds).toEqual([imageId])
+    expect(shell.snapshot.attachmentIds).toEqual([imageId])
     expect(shell.notices.getSnapshot()).toBeNull()
   })
 
@@ -256,9 +256,9 @@ describe('submit transaction hardening', () => {
         signal = received
         return new Promise<SubmitOutcome>(() => {})
       },
-      commandImages,
+      commandAttachments,
     })
-    shell.addImages([imageId])
+    shell.addAttachments([imageId])
     shell.submit()
     expect(signal?.aborted).toBe(false)
     expect(shell.dispose()).toEqual([imageId])
@@ -272,7 +272,7 @@ describe('submit transaction hardening', () => {
       actx: {} as Context,
       inputTriggers: () => ({ track, lexicon } as unknown as InputTriggerController),
       defaultSink: vi.fn(),
-      commandImages,
+      commandAttachments,
     })
     shell.setDraft('@sr')
     const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true)

+ 16 - 14
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -17,7 +17,7 @@ import type {
   ClientSessionContext, SubmitEnvelope,
 } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type {
-  CommandClaim, PickOutcome, SubmitImageAttachment, SubmitOutcome,
+  CommandClaim, PickOutcome, SubmitAttachment, SubmitOutcome,
 } from '../src/client/contract/input.ts'
 import {
   bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime,
@@ -44,19 +44,19 @@ afterEach(cleanup)
 interface FakeCommand {
   name: string
   description: string
-  input?: { hint: string; images?: boolean }
+  input?: { hint: string; attachments?: boolean }
 }
 
 /** Decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */
 function commandSource(
   commands: FakeCommand[],
-  execute: (line: string, images?: readonly SubmitImageAttachment[]) => Promise<SubmitOutcome>,
+  execute: (line: string, images?: readonly SubmitAttachment[]) => Promise<SubmitOutcome>,
 ) {
   const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name)
   const leadingClaim = (desc: FakeCommand): CommandClaim => ({
     token: `/${desc.name} `,
     ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
-    ...(desc.input?.images === true ? { images: true } : {}),
+    ...(desc.input?.attachments === true ? { attachments: true } : {}),
     submit: (args, _actx, images) => execute(`/${desc.name} ${args}`, images),
   })
   const executed: string[] = []
@@ -105,10 +105,10 @@ function commandSource(
 const COMMANDS: FakeCommand[] = [
   { name: 'goal', description: '设定目标', input: { hint: '目标内容' } },
   { name: 'compact', description: '压缩上下文' },
-  { name: 'vision', description: '识别图片', input: { hint: '想问什么', images: true } },
+  { name: 'vision', description: '识别图片', input: { hint: '想问什么', attachments: true } },
 ]
 
-const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' }
+const PNG: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' }
 
 /** Real scope bench: a Controller-owned Session scope + InputTriggerController + shell listeners. */
 async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) {
@@ -125,7 +125,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
   const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ kind: 'success' }))
   const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG)))
   const release = vi.fn()
-  const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } })
+  const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } })
   // The hub's listener wiring, verbatim.
   actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined)
   actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined)
@@ -152,10 +152,12 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
-    addImages: () => null,
-    removeImage: () => {},
+    addFiles: () => null,
+    useFileUploads: bindSnapshotSelector(createSnapshotStore({})),
+    retryFileUpload: undefined,
+    removeAttachment: () => {},
     // Every id resolves so the bar's registry prune never drops a test image.
-    draftImages: ids => ids.map(id => ({
+    resolveDraftAttachments: ids => ids.map(id => ({
       kind: 'image' as const, id,
       file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }),
       previewUrl: `blob:${id}`,
@@ -272,16 +274,16 @@ describe('scenario D: execute-kind /compact', () => {
 describe('scenario: images ride an accepting command through the real pipeline', () => {
   it('adjudication reports the image count; the claim chain serializes, submits, and consumes', async () => {
     const b = await bench()
-    act(() => { b.shell.addImages(['img-1' as DraftAttachmentId]) })
+    act(() => { b.shell.addAttachments(['img-1' as DraftAttachmentId]) })
     act(() => { b.shell.setDraft('/vision 这张图是什么') })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/vision 这张图是什么', [PNG]) })
     // The envelope the controller forwarded to matchEnter carried the count.
-    expect(b.envelopes).toEqual([{ images: 1 }])
+    expect(b.envelopes).toEqual([{ attachments: 1 }])
     expect(b.serialize).toHaveBeenCalledWith(['img-1'])
     await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') })
     expect(b.release).toHaveBeenCalledWith(['img-1'])
-    expect(b.shell.snapshot.imageIds).toEqual([])
+    expect(b.shell.snapshot.attachmentIds).toEqual([])
     expect(b.sink).not.toHaveBeenCalled()
   })
 
@@ -290,7 +292,7 @@ describe('scenario: images ride an accepting command through the real pipeline',
     act(() => { b.shell.setDraft('/goal 发布') })
     fireEvent.keyDown(b.textarea, { key: 'Enter' })
     await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布', []) })
-    expect(b.envelopes).toEqual([{ images: 0 }])
+    expect(b.envelopes).toEqual([{ attachments: 0 }])
     expect(b.serialize).not.toHaveBeenCalled()
     expect(b.release).not.toHaveBeenCalled()
   })

+ 42 - 2
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -65,7 +65,7 @@ function liveSession(initial: SessionSnapshot) {
   }
 }
 
-const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
+const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
 
 const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
 
@@ -124,13 +124,24 @@ describe('QueueDock', () => {
         placement: 'queued' as const,
         time: 1,
         text: '等待上传',
-        images: [{ previewUrl: 'blob:queue-preview', name: 'queue.png' }],
+        attachments: [
+          { type: 'image' as const, previewUrl: 'blob:queue-preview', name: 'queue.png' },
+          {
+            type: 'file' as const,
+            attachment: {
+              attachmentId: 'file-local' as never,
+              name: 'notes.txt',
+              bytes: 2447 * 1024 * 1024,
+            },
+          },
+        ],
       }],
     }
     const source = liveSession(pending)
     const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
     expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull()
     expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview')
+    expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB')
 
     act(() => {
       source.push({
@@ -282,6 +293,35 @@ describe('QueueDock', () => {
     expect(container.querySelector('li')?.textContent).toBe('带图消息')
   })
 
+  it('renders durable files and images in their original queue order', async () => {
+    const loadImage = vi.fn(() => Promise.resolve('blob:mixed'))
+    const mixed: QueuedMessage = {
+      id: iid('i-mixed'), messageId: 'message-i-mixed' as never, placement: 'queued',
+      content: [
+        {
+          type: 'file',
+          attachment: { attachmentId: 'file-durable' as never, name: 'report.csv', bytes: 427 },
+        },
+        {
+          type: 'image',
+          attachment: {
+            attachmentId: 'image-durable' as never,
+            mediaType: 'image/png', bytes: 1, width: 1, height: 1,
+          },
+        },
+      ],
+      preview: '', text: null,
+    }
+    const snap = snapshotWith([mixed])
+    const source = liveSession(snap)
+    const view = render(<QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />)
+    await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
+    const group = view.getByLabelText('排队文件 report.csv').parentElement
+    expect(group?.children).toHaveLength(2)
+    expect(group?.children[0]?.getAttribute('aria-label')).toBe('排队文件 report.csv')
+    expect(group?.children[1]?.tagName).toBe('IMG')
+  })
+
   it('keeps the empty thumbnail placeholder when the image read fails', async () => {
     const loadImage = vi.fn(() => Promise.reject(new Error('read denied')))
     const snap = snapshotWith([imageRow('i-broken', 'att-x')])

+ 393 - 31
packages/client/ui-conversation/tests/service-orchestration.client.spec.ts

@@ -9,12 +9,14 @@ import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-cl
 import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client'
 import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
 import { InputHub } from '../src/client/input/hub.ts'
-import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
+import { ConversationController } from '../src/client/service.ts'
 import { zh } from '../src/client/locales.ts'
 
-async function bench() {
+async function bench(maxConcurrentFileUploads = 2) {
   const runtime = await SlotTestRuntime.create()
-  const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
+  const prompt = vi.fn((
+    _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string,
+  ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
   const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
   const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
   const loadOlder = vi.fn(() => Promise.resolve())
@@ -28,6 +30,7 @@ async function bench() {
   const fiber = runtime.ctx.plugin(ConversationController, {
     input: hub,
     blocks: new ComposerBlockRegistry(),
+    maxConcurrentFileUploads,
   })
   await fiber.await()
   const root = runtime.ctx.get('conversation') as ConversationController
@@ -87,13 +90,13 @@ describe('ConversationController', () => {
     const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
     const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
     try {
-      const [attachment] = b.root.createDraftImages([
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
         new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }),
       ])
       if (attachment === undefined) throw new Error('draft attachment missing')
-      b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id])
+      b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id])
       await b.runtime.sessions.remove('s1')
-      expect(b.root.draftImages([attachment.id])).toEqual([])
+      expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
       expect(revoked).toHaveBeenCalledWith('blob:draft-1')
     } finally {
       created.mockRestore()
@@ -107,15 +110,15 @@ describe('ConversationController', () => {
     const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached')
     const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
     try {
-      const [attachment] = b.root.createDraftImages([
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
         new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }),
       ])
       if (attachment === undefined) throw new Error('draft attachment missing')
-      b.shell.addImages([attachment.id])
+      b.shell.addAttachments([attachment.id])
       b.shell.submit()
-      expect(b.shell.snapshot.imageIds).toEqual([])
+      expect(b.shell.snapshot.attachmentIds).toEqual([])
       await b.runtime.sessions.remove('s1')
-      expect(b.root.draftImages([attachment.id])).toEqual([])
+      expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
       expect(revoked).toHaveBeenCalledWith('blob:detached')
     } finally {
       created.mockRestore()
@@ -124,18 +127,310 @@ describe('ConversationController', () => {
     await b.runtime.dispose()
   })
 
-  it('validates every MIME type before allocating previews', async () => {
+  it('classifies image MIME drafts as images and every other file as an uploading file draft', async () => {
     const b = await bench()
     const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
-    expect(() => b.root.createDraftImages([
+    const session = b.runtime.sessions.binding('s1')!.session
+    const uploadFile = vi.fn((_data: Blob | Uint8Array) => Promise.resolve({
+      ok: true as const,
+      value: {
+        receiptId: 'receipt-1' as never,
+        file: { attachmentId: 'sha256:1' as never, name: 'notes.pdf', bytes: 2 },
+      },
+    }))
+    ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
+    const drafts = b.root.createDrafts(session, [
       new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
-      new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
-    ])).toThrow(UnsupportedImageMediaTypeError)
-    expect(created).not.toHaveBeenCalled()
+      new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }),
+    ])
+    expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file'])
+    expect(created).toHaveBeenCalledTimes(1)
+    const fileDraft = drafts[1]!
+    expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('uploading')
+    await vi.waitFor(() => {
+      expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready')
+    })
+    expect(uploadFile).toHaveBeenCalledOnce()
+    expect(uploadFile.mock.calls[0]?.[0]).toBe((fileDraft as { file: File }).file)
     created.mockRestore()
     await b.runtime.dispose()
   })
 
+  it('serializes command files as staged receipts without reading their bytes again', async () => {
+    const b = await bench()
+    const session = b.runtime.sessions.binding('s1')!.session
+    ;(session as { uploadFile?: unknown }).uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) =>
+      Promise.resolve({
+        ok: true as const,
+        value: {
+          receiptId: `receipt-${name}` as never,
+          file: { attachmentId: `file-${name}` as never, name: name ?? 'file', bytes: 1 },
+        },
+      }))
+    const drafts = b.root.createDrafts(session, [
+      new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }),
+      new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }),
+    ])
+    await vi.waitFor(() => {
+      expect(drafts.every(draft => b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true)
+    })
+    function RejectingFileReader(): never {
+      throw new Error('generic file bytes were reread')
+    }
+    vi.stubGlobal('FileReader', RejectingFileReader)
+    try {
+      await expect(b.root.serializeDraftAttachments(drafts.map(draft => draft.id))).resolves.toEqual({
+        attachments: [
+          { type: 'file', receiptId: 'receipt-one.txt' },
+          { type: 'file', receiptId: 'receipt-two.txt' },
+        ],
+      })
+    } finally {
+      vi.unstubAllGlobals()
+    }
+    await b.runtime.dispose()
+  })
+
+  it('bounds upload Workers, advances on settlement, and skips a queued file removed by the user', async () => {
+    const b = await bench(2)
+    const session = b.runtime.sessions.binding('s1')!.session
+    type UploadResult = {
+      ok: true
+      value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } }
+    }
+    const gates = new Map<string, { resolve: (value: UploadResult) => void }>()
+    let active = 0
+    let maxActive = 0
+    const uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => {
+      const fileName = name ?? 'unnamed'
+      const gate = Promise.withResolvers<UploadResult>()
+      gates.set(fileName, gate)
+      active += 1
+      maxActive = Math.max(maxActive, active)
+      return gate.promise.finally(() => { active -= 1 })
+    })
+    ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
+    const drafts = b.root.createDrafts(session, ['one', 'two', 'three', 'four', 'removed'].map(name =>
+      new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' })))
+
+    expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt'])
+    await expect(b.root.serializeDraftAttachments([drafts[2]!.id]))
+      .rejects.toThrow('one or more files have not finished uploading')
+    b.root.releaseDraftAttachment(drafts[4]!.id)
+
+    const complete = (name: string) => gates.get(name)?.resolve({
+      ok: true,
+      value: {
+        receiptId: `receipt-${name}` as never,
+        file: { attachmentId: `file-${name}` as never, name, bytes: 1 },
+      },
+    })
+    complete('one.txt')
+    await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(3) })
+    complete('two.txt')
+    await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(4) })
+    complete('three.txt')
+    complete('four.txt')
+    await vi.waitFor(() => {
+      expect(drafts.slice(0, 4).every(draft =>
+        b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true)
+    })
+    expect(maxActive).toBe(2)
+    expect(uploadFile.mock.calls.map(call => call[1])).not.toContain('removed.txt')
+    expect(b.root.fileUploads.getSnapshot()[drafts[4]!.id]).toBeUndefined()
+    await b.runtime.dispose()
+  })
+
+  it('keeps one upload alive and observable while another Session is open', async () => {
+    const b = await bench()
+    const session = b.runtime.sessions.binding('s1')!.session
+    const settled = Promise.withResolvers<{
+      ok: true
+      value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } }
+    }>()
+    let reportProgress: ((progress: { loaded: number; total?: number }) => void) | undefined
+    const uploadFile = vi.fn((
+      _file: Blob | Uint8Array,
+      _name?: string,
+      _signal?: AbortSignal,
+      onProgress?: (progress: { loaded: number; total?: number }) => void,
+    ) => {
+      reportProgress = onProgress
+      return settled.promise
+    })
+    ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
+    const [attachment] = b.root.createDrafts(session, [
+      new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }),
+    ])
+    if (attachment === undefined) throw new Error('file draft missing')
+    b.shell.addAttachments([attachment.id])
+    await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() })
+
+    await b.runtime.sessions.add({
+      id: 's2',
+      session: {
+        prompt: b.prompt, updateQueue: b.updateQueue, cancel: b.cancel, loadOlder: b.loadOlder,
+      },
+    })
+    b.runtime.sessions.open('s2' as never)
+    reportProgress?.({ loaded: 3, total: 8 })
+    expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({
+      status: 'uploading', loaded: 3, total: 8,
+    })
+    expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id])
+
+    b.runtime.sessions.open('s1' as never)
+    settled.resolve({
+      ok: true,
+      value: {
+        receiptId: 'background-receipt' as never,
+        file: { attachmentId: 'background-file' as never, name: 'background.bin', bytes: 8 },
+      },
+    })
+    await vi.waitFor(() => {
+      expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready')
+    })
+    expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id])
+    await b.runtime.dispose()
+  })
+
+  it('cancels a superseded upload and stages the carried draft on the target Session', async () => {
+    const b = await bench()
+    const source = b.runtime.sessions.binding('s1')!.session
+    let sourceSignal: AbortSignal | undefined
+    const sourceUpload = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => {
+      sourceSignal = signal
+      return new Promise((resolve) => {
+        signal?.addEventListener('abort', () => {
+          resolve({ ok: false, error: { message: 'aborted' } })
+        }, { once: true })
+      })
+    })
+    ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload
+    const [attachment] = b.root.createDrafts(source, [
+      new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }),
+    ])
+    if (attachment === undefined) throw new Error('file draft missing')
+    await vi.waitFor(() => { expect(sourceUpload).toHaveBeenCalledOnce() })
+
+    const target = {
+      uploadFile: vi.fn(() => Promise.resolve({
+        ok: true as const,
+        value: {
+          receiptId: 'target-receipt' as never,
+          file: { attachmentId: 'target-file' as never, name: 'carry.pdf', bytes: 1 },
+        },
+      })),
+    }
+    b.root.rebindDraftFiles(target as never, [attachment.id])
+
+    expect(sourceSignal?.aborted).toBe(true)
+    await vi.waitFor(() => {
+      expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({
+        status: 'ready', receiptId: 'target-receipt',
+        file: { attachmentId: 'target-file', name: 'carry.pdf', bytes: 1 },
+      })
+    })
+    await b.runtime.dispose()
+  })
+
+  it('cancels a file upload when its draft is removed', async () => {
+    const b = await bench()
+    const session = b.runtime.sessions.binding('s1')!.session
+    let uploadSignal: AbortSignal | undefined
+    const uploadFile = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => {
+      uploadSignal = signal
+      return new Promise((resolve) => {
+        signal?.addEventListener('abort', () => {
+          resolve({ ok: false, error: { message: 'aborted' } })
+        }, { once: true })
+      })
+    })
+    ;(session as { uploadFile?: unknown }).uploadFile = uploadFile
+    const [attachment] = b.root.createDrafts(session, [
+      new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }),
+    ])
+    if (attachment === undefined) throw new Error('file draft missing')
+    await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() })
+
+    b.root.releaseDraftAttachment(attachment.id)
+
+    expect(uploadSignal?.aborted).toBe(true)
+    expect(b.root.fileUploads.getSnapshot()[attachment.id]).toBeUndefined()
+    await b.runtime.dispose()
+  })
+
+  it('keeps the accepted file draft until its rpcId appears in the Host queue', async () => {
+    const b = await bench()
+    const session = b.runtime.sessions.binding('s1')!.session
+    let retire: ((retirement: unknown) => void) | undefined
+    ;(session as unknown as { beginSubmission: (input: { onRetire?: (retirement: unknown) => void }) => unknown })
+      .beginSubmission = (input) => {
+        retire = input.onRetire
+        return { requestId: 'file-rpc-id', abandon: vi.fn() }
+      }
+    ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({
+      ok: true as const,
+      value: {
+        receiptId: 'send-receipt' as never,
+        file: { attachmentId: 'send-file' as never, name: 'sent.pdf', bytes: 1 },
+      },
+    }))
+    const [attachment] = b.root.createDrafts(session, [
+      new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }),
+    ])
+    if (attachment === undefined) throw new Error('file draft missing')
+    await vi.waitFor(() => {
+      expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready')
+    })
+
+    const sending = b.root.sendSession(session, 'read', [attachment.id], 'queue')
+    await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
+
+    expect(b.prompt).toHaveBeenCalledWith([
+      { type: 'file', receiptId: 'send-receipt' },
+      { type: 'text', text: 'read' },
+    ], 'queue', undefined, expect.any(String))
+    expect(b.root.resolveDraftAttachments([attachment.id])).toHaveLength(1)
+    expect(b.prompt.mock.calls[0]?.[3]).toBe('file-rpc-id')
+    retire?.({
+      reason: 'observed',
+      attachments: [{ attachmentId: 'send-file', name: 'sent.pdf', bytes: 1 }],
+    })
+    await expect(sending).resolves.toEqual({ kind: 'success' })
+    expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
+    await b.runtime.dispose()
+  })
+
+  it('awaits an aborted upload before disposing the service', async () => {
+    const b = await bench()
+    const session = b.runtime.sessions.binding('s1')!.session
+    let uploadSignal: AbortSignal | undefined
+    let finishUpload: (() => void) | undefined
+    ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(
+      (_data: Uint8Array, _name?: string, signal?: AbortSignal) => {
+        uploadSignal = signal
+        return new Promise((resolve) => {
+          finishUpload = () => { resolve({ ok: false, error: { message: 'aborted' } }) }
+        })
+      },
+    )
+    b.root.createDrafts(session, [
+      new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }),
+    ])
+    await vi.waitFor(() => { expect(uploadSignal).toBeDefined() })
+
+    let disposed = false
+    const disposal = b.fiber.dispose().then(() => { disposed = true })
+    await vi.waitFor(() => { expect(uploadSignal?.aborted).toBe(true) })
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+    finishUpload?.()
+    await disposal
+    expect(disposed).toBe(true)
+    await b.runtime.dispose()
+  })
+
   it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => {
     const b = await bench()
     await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
@@ -147,6 +442,7 @@ describe('ConversationController', () => {
     await bare.plugin(ConversationController, {
       input: new InputHub(bare, makeTranslate(zh, {})),
       blocks: new ComposerBlockRegistry(),
+      maxConcurrentFileUploads: 2,
     }).await()
     const orphan = bare.get('conversation') as ConversationController
     await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
@@ -178,7 +474,7 @@ describe('sendSession submission echo', () => {
   it('registers the echo before serialization and prompts with its identity', async () => {
     const b = await echoBench()
     try {
-      const [attachment] = b.root.createDraftImages([
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
         new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }),
       ])
       const session = b.runtime.sessions.binding('s1')!.session
@@ -187,7 +483,7 @@ describe('sendSession submission echo', () => {
       expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
         mode: 'queue',
         text: '带图',
-        images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })],
+        attachments: [expect.objectContaining({ type: 'image', previewUrl: 'blob:echo-1', name: 'a.png' })],
       }))
       expect(b.prompt).not.toHaveBeenCalled()
       await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
@@ -201,10 +497,10 @@ describe('sendSession submission echo', () => {
         'req-echo',
       )
       // The draft stays registered until the echo's observed retirement.
-      expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
+      expect(b.root.resolveDraftAttachments([attachment!.id])).toHaveLength(1)
       b.retire.onRetire?.({ reason: 'observed', attachments: [] })
       await expect(sending).resolves.toEqual({ kind: 'success' })
-      expect(b.root.draftImages([attachment!.id])).toEqual([])
+      expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([])
       expect(b.revoked).toHaveBeenCalledWith('blob:echo-1')
     } finally {
       b.restore()
@@ -212,6 +508,57 @@ describe('sendSession submission echo', () => {
     await b.runtime.dispose()
   })
 
+  it('preserves mixed image/file selection order through echo, prompt, and observed retirement', async () => {
+    const b = await echoBench()
+    try {
+      const session = b.runtime.sessions.binding('s1')!.session
+      ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({
+        ok: true as const,
+        value: {
+          receiptId: 'mixed-file-receipt' as never,
+          file: { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 },
+        },
+      }))
+      const drafts = b.root.createDrafts(session, [
+        new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
+        new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }),
+        new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }),
+      ])
+      await vi.waitFor(() => {
+        expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready')
+      })
+      const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer')
+      expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({
+        mode: 'steer',
+        attachments: [
+          expect.objectContaining({ type: 'image', name: 'first.png' }),
+          { type: 'file', attachment: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } },
+          expect.objectContaining({ type: 'image', name: 'last.png' }),
+        ],
+      }))
+      await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
+      expect(b.prompt.mock.calls[0]?.[0]).toEqual([
+        { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'first.png' },
+        { type: 'file', receiptId: 'mixed-file-receipt' },
+        { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'last.png' },
+        { type: 'text', text: 'ordered' },
+      ])
+      b.retire.onRetire?.({
+        reason: 'observed',
+        attachments: [
+          { attachmentId: 'image-first', mediaType: 'image/png' },
+          { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 },
+          { attachmentId: 'image-last', mediaType: 'image/png' },
+        ],
+      })
+      await expect(sending).resolves.toEqual({ kind: 'success' })
+      expect(b.root.resolveDraftAttachments(drafts.map(draft => draft.id))).toEqual([])
+    } finally {
+      b.restore()
+    }
+    await b.runtime.dispose()
+  })
+
   it('passes each delivery mode before image serialization', async () => {
     const b = await echoBench()
     try {
@@ -241,17 +588,19 @@ describe('sendSession submission echo', () => {
       const seedImageUrl = vi.fn(() => true)
       b.runtime.ctx.provide('uiConversation')
       b.runtime.ctx.set('uiConversation', { seedImageUrl })
-      const [attachment] = b.root.createDraftImages([
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
         new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }),
       ])
       const session = b.runtime.sessions.binding('s1')!.session
       const sending = b.root.sendSession(session, '', [attachment!.id], 'queue')
       await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() })
-      const ref = { attachmentId: 'att-1' }
+      const ref = {
+        attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1,
+      }
       b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
       await expect(sending).resolves.toEqual({ kind: 'success' })
       expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1')
-      expect(b.root.draftImages([attachment!.id])).toEqual([])
+      expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([])
       expect(b.revoked).not.toHaveBeenCalled()
       // Failed retirement keeps nothing to do; a second retire of released ids is a no-op.
       b.retire.onRetire?.({ reason: 'observed', attachments: [ref] })
@@ -261,20 +610,31 @@ describe('sendSession submission echo', () => {
     await b.runtime.dispose()
   })
 
-  it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => {
+  it('keeps mixed image and file drafts when the echo retires as failed', async () => {
     const b = await echoBench()
     try {
+      const session = b.runtime.sessions.binding('s1')!.session
+      ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({
+        ok: true as const,
+        value: {
+          receiptId: 'kept-file-receipt' as never,
+          file: { attachmentId: 'kept-file' as never, name: 'kept.txt', bytes: 1 },
+        },
+      }))
       b.prompt.mockResolvedValueOnce({
         ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }),
       } as never)
-      const [attachment] = b.root.createDraftImages([
+      const attachments = b.root.createDrafts(session, [
         new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }),
+        new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }),
       ])
-      const session = b.runtime.sessions.binding('s1')!.session
-      await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue'))
+      await vi.waitFor(() => {
+        expect(b.root.fileUploads.getSnapshot()[attachments[1]!.id]?.status).toBe('ready')
+      })
+      await expect(b.root.sendSession(session, '失败', attachments.map(attachment => attachment.id), 'queue'))
         .resolves.toEqual({ kind: 'error' })
       b.retire.onRetire?.({ reason: 'failed' })
-      expect(b.root.draftImages([attachment!.id])).toHaveLength(1)
+      expect(b.root.resolveDraftAttachments(attachments.map(attachment => attachment.id))).toHaveLength(2)
       expect(b.revoked).not.toHaveBeenCalled()
     } finally {
       b.restore()
@@ -294,7 +654,7 @@ describe('sendSession submission echo', () => {
     }
     vi.stubGlobal('FileReader', FailingReader)
     try {
-      const [attachment] = b.root.createDraftImages([
+      const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
         new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }),
       ])
       const session = b.runtime.sessions.binding('s1')!.session
@@ -374,15 +734,17 @@ describe('draft image dimension probe', () => {
     }
     vi.stubGlobal('Image', InstantImage)
     try {
-      const [probed] = b.root.createDraftImages([
+      const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
         new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }),
       ])
       expect(probed).toMatchObject({ width: 640, height: 480 })
       vi.stubGlobal('Image', undefined)
-      const [unprobed] = b.root.createDraftImages([
+      const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session, [
         new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }),
       ])
-      expect(unprobed?.width).toBeUndefined()
+      expect(unprobed?.kind).toBe('image')
+      if (unprobed?.kind !== 'image') throw new Error('image draft missing')
+      expect(unprobed.width).toBeUndefined()
     } finally {
       vi.unstubAllGlobals()
       created.mockRestore()

+ 6 - 4
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -40,7 +40,7 @@ Range.prototype.getBoundingClientRect = () => ({
 
 function fakeWiring() {
   const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
-  const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } })
+  const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } })
   return { wiring: shell, sink, shell }
 }
 
@@ -249,9 +249,11 @@ function mount(
           useInput={useInput}
           inputActions={inputActions}
           keyboard={wiring}
-          addImages={() => null}
-          removeImage={() => {}}
-          draftImages={() => []}
+          addFiles={() => null}
+          useFileUploads={bindSnapshotSelector(createSnapshotStore({}))}
+          retryFileUpload={undefined}
+          removeAttachment={() => {}}
+          resolveDraftAttachments={() => []}
           resolveSubmitMode={() => 'queue'}
           toggleCommandMenu={vi.fn()}
           useNotices={bindSnapshotSelector(wiring.notices)}

+ 2 - 2
packages/client/ui-conversation/tests/submit-machine.client.spec.ts

@@ -170,9 +170,9 @@ describe('submit-machine: adjudication outcomes', () => {
 describe('submit-machine: claimed lifecycle', () => {
   it('the claim event enters claimed and snapshots hint and images bits', () => {
     const m = new SubmitMachine()
-    m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), images: true } })
+    m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), attachments: true } })
     expect(m.state.phase).toBe('claimed')
-    expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', images: true })
+    expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', attachments: true })
   })
 
   it('claimed overwrites in place — no stack', () => {

+ 5 - 5
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -1074,7 +1074,7 @@ describe('adjudicate', () => {
         return Promise.resolve('handled')
       }),
     ])
-    const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { images: 0 })
+    const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { attachments: 0 })
     expect(result).toEqual({ claim })
     expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast'])
   })
@@ -1085,7 +1085,7 @@ describe('adjudicate', () => {
       enterSource('@', 'subagent', atHook),
       enterSource('/', 'command', () => Promise.resolve(undefined)),
     ])
-    await expect(controller.adjudicate('/xyz', new AbortController().signal, { images: 0 })).resolves.toBeUndefined()
+    await expect(controller.adjudicate('/xyz', new AbortController().signal, { attachments: 0 })).resolves.toBeUndefined()
     expect(atHook).not.toHaveBeenCalled()
   })
 
@@ -1101,7 +1101,7 @@ describe('adjudicate', () => {
         return Promise.resolve('handled')
       }),
     ])
-    const envelope = { images: 2 }
+    const envelope = { attachments: 2 }
     await controller.adjudicate('/goal', new AbortController().signal, envelope)
     expect(envelopes).toEqual([envelope, envelope])
     expect(envelopes[0]).toBe(envelope)
@@ -1112,7 +1112,7 @@ describe('adjudicate', () => {
       enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))),
       enterSource('/', 'late', () => Promise.resolve('handled')),
     ])
-    await expect(controller.adjudicate('/goal x', new AbortController().signal, { images: 0 }))
+    await expect(controller.adjudicate('/goal x', new AbortController().signal, { attachments: 0 }))
       .rejects.toThrow('warmup failed')
   })
 
@@ -1121,7 +1121,7 @@ describe('adjudicate', () => {
     const { controller } = controllerBench([enterSource('/', 'command', hook)])
     const abort = new AbortController()
     abort.abort(new Error('attempt released'))
-    await expect(controller.adjudicate('/goal', abort.signal, { images: 0 })).rejects.toThrow('attempt released')
+    await expect(controller.adjudicate('/goal', abort.signal, { attachments: 0 })).rejects.toThrow('attempt released')
     expect(hook).not.toHaveBeenCalled()
   })
 })

+ 14 - 0
packages/client/ui-primitives/tests/file-size.client.spec.ts

@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { fileSizeText } from '../src/file-size.ts'
+
+describe('fileSizeText', () => {
+  it('formats bytes and both sides of the KB, MB, and GB rounding thresholds', () => {
+    expect(fileSizeText(312)).toBe('312B')
+    expect(fileSizeText(4.25 * 1024)).toBe('4.3KB')
+    expect(fileSizeText(12.5 * 1024)).toBe('13KB')
+    expect(fileSizeText(1.5 * 1024 * 1024)).toBe('1.5MB')
+    expect(fileSizeText(12.5 * 1024 * 1024)).toBe('13MB')
+    expect(fileSizeText(2447 * 1024 * 1024)).toBe('2.4GB')
+    expect(fileSizeText(12.5 * 1024 * 1024 * 1024)).toBe('13GB')
+  })
+})

+ 3 - 3
packages/client/ui-tool/tests/diff-card.client.spec.tsx

@@ -389,9 +389,9 @@ describe('DetailsPanel diff Output section', () => {
         useInput={(() => { throw new Error('unused') })}
         inputActions={{
           setDraft: () => {},
-          addImages: () => true,
-          removeImage: () => {},
-          pruneImages: () => {},
+          addAttachments: () => true,
+          removeAttachment: () => {},
+          pruneAttachments: () => {},
           submit: () => {},
         }}
         useProjection={(() => undefined)}

+ 3 - 3
packages/client/ui-tool/tests/read-card.client.spec.tsx

@@ -311,9 +311,9 @@ describe('DetailsPanel Output section (read)', () => {
         useInput={(() => { throw new Error('unused') })}
         inputActions={{
           setDraft: () => {},
-          addImages: () => true,
-          removeImage: () => {},
-          pruneImages: () => {},
+          addAttachments: () => true,
+          removeAttachment: () => {},
+          pruneAttachments: () => {},
           submit: () => {},
         }}
         useProjection={(() => undefined)}

+ 3 - 3
packages/client/ui-tool/tests/search-card.client.spec.tsx

@@ -395,9 +395,9 @@ describe('DetailsPanel Output section (search)', () => {
         useInput={(() => { throw new Error('unused') })}
         inputActions={{
           setDraft: () => {},
-          addImages: () => true,
-          removeImage: () => {},
-          pruneImages: () => {},
+          addAttachments: () => true,
+          removeAttachment: () => {},
+          pruneAttachments: () => {},
           submit: () => {},
         }}
         useProjection={(() => undefined)}

+ 8 - 2
packages/client/ui-tool/tests/terminal-card.client.spec.tsx

@@ -524,7 +524,10 @@ describe('DetailsPanel Output section', () => {
         useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
         useTrajectory={useEmptyTrajectory}
         useInput={(() => { throw new Error('unused') })}
-        inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
+        inputActions={{
+          setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {},
+          pruneAttachments: () => {}, submit: () => {},
+        }}
         useProjection={(() => undefined)}
         useStore={bindSnapshotSelector(chat)}
         actions={chat.actions}
@@ -712,7 +715,10 @@ describe('DetailsPanel Output section', () => {
         useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
         useTrajectory={useEmptyTrajectory}
         useInput={(() => { throw new Error('unused') })}
-        inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }}
+        inputActions={{
+          setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {},
+          pruneAttachments: () => {}, submit: () => {},
+        }}
         useProjection={(() => undefined)}
         useStore={bindSnapshotSelector(chat)}
         actions={chat.actions}

+ 3 - 3
packages/client/ui-tool/tests/web-card.client.spec.tsx

@@ -243,9 +243,9 @@ describe('DetailsPanel web Output section', () => {
         useInput={(() => { throw new Error('unused') })}
         inputActions={{
           setDraft: () => {},
-          addImages: () => true,
-          removeImage: () => {},
-          pruneImages: () => {},
+          addAttachments: () => true,
+          removeAttachment: () => {},
+          pruneAttachments: () => {},
           submit: () => {},
         }}
         useProjection={(() => undefined)}

+ 43 - 0
packages/client/ui-trajectory/tests/layout.client.spec.tsx

@@ -666,3 +666,46 @@ describe('durable image attachments', () => {
     expect(block?.content).toContain('https://example.com/a.png')
   })
 })
+
+describe('durable file attachments', () => {
+  const attachment = {
+    attachmentId: `sha256:${'d'.repeat(64)}`,
+    name: 'notes.pdf',
+    bytes: 2447 * 1024 * 1024,
+  }
+
+  it('labels file-only and mixed-text user records without rendering file cards', () => {
+    const nodes = [
+      {
+        kind: 'user', seq: 1, time: 1_000, source: null,
+        content: [{ type: 'file', attachment }, { type: 'file', attachment }],
+      },
+      {
+        kind: 'user', seq: 2, time: 2_000, source: null,
+        content: [{ type: 'text', text: 'review these' }, { type: 'file', attachment }],
+      },
+    ] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    const users = turns.flatMap(turn => turn.groups).flatMap(group => group.cells)
+      .filter(cell => cell.kind === 'user')
+    expect(users[0]?.text).toBe('Files ×2')
+    expect(users[0]?.previewMarkdown).toBeUndefined()
+    expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' })
+  })
+
+  it('keeps image and file counts in a file-only attachment summary', () => {
+    const image = {
+      attachmentId: `sha256:${'e'.repeat(64)}`,
+      mediaType: 'image/png',
+      bytes: 68,
+      width: 640,
+      height: 320,
+    }
+    const nodes = [{
+      kind: 'user', seq: 1, time: 1_000, source: null,
+      content: [{ type: 'image', attachment: image }, { type: 'file', attachment }],
+    }] as unknown as LegacyConversationSlice['nodes']
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+    expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1')
+  })
+})

+ 8 - 8
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -204,13 +204,13 @@ function standaloneProps(
 ): StandaloneBaseProps {
   const trajectory = historySnapshot(nodes)
   const input = createSnapshotStore<InputState>({
-    draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
+    draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
   })
   const inputActions: InputActions = {
     setDraft: () => {},
-    addImages: () => false,
-    removeImage: () => {},
-    pruneImages: () => {},
+    addAttachments: () => false,
+    removeAttachment: () => {},
+    pruneAttachments: () => {},
     submit: () => {},
   }
   return {
@@ -326,13 +326,13 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
     createSnapshotStore<readonly ViewTab[]>(tabsOf(slots)),
   )
   const useInput = bindSnapshotSelector(createSnapshotStore<InputState>({
-    draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
+    draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
   }))
   const inputActions: InputActions = {
     setDraft: vi.fn(),
-    addImages: vi.fn(() => false),
-    removeImage: vi.fn(),
-    pruneImages: vi.fn(),
+    addAttachments: vi.fn(() => false),
+    removeAttachment: vi.fn(),
+    pruneAttachments: vi.fn(),
     submit: vi.fn(),
   }
   const standardProps = {

+ 4 - 4
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -88,7 +88,7 @@ const trajectoryState: TrajectoryState = {
 }
 const inputState: InputState = {
   draft: '',
-  imageIds: [],
+  attachmentIds: [],
   draftRev: 0,
   phase: 'plain',
   occurrences: [],
@@ -113,9 +113,9 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
   useInput: selector => selector(inputState),
   inputActions: {
     setDraft: () => { throw new Error('unused') },
-    addImages: () => { throw new Error('unused') },
-    removeImage: () => { throw new Error('unused') },
-    pruneImages: () => { throw new Error('unused') },
+    addAttachments: () => { throw new Error('unused') },
+    removeAttachment: () => { throw new Error('unused') },
+    pruneAttachments: () => { throw new Error('unused') },
     submit: () => { throw new Error('unused') },
   },
   useStore: selector => selector(questionDraftStore.getSnapshot()),

+ 4 - 4
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -87,7 +87,7 @@ const trajectoryState: TrajectoryState = {
 }
 const inputState: InputState = {
   draft: '',
-  imageIds: [],
+  attachmentIds: [],
   draftRev: 0,
   phase: 'plain',
   occurrences: [],
@@ -112,9 +112,9 @@ const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> =
   useInput: selector => selector(inputState),
   inputActions: {
     setDraft: () => { throw new Error('unused') },
-    addImages: () => { throw new Error('unused') },
-    removeImage: () => { throw new Error('unused') },
-    pruneImages: () => { throw new Error('unused') },
+    addAttachments: () => { throw new Error('unused') },
+    removeAttachment: () => { throw new Error('unused') },
+    pruneAttachments: () => { throw new Error('unused') },
     submit: () => { throw new Error('unused') },
   },
   // The seat's key domain is question ∪ common.

+ 3 - 3
packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx

@@ -318,9 +318,9 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
     useInput: () => { throw new Error('unused') },
     inputActions: {
       setDraft: () => {},
-      addImages: () => false,
-      removeImage: () => {},
-      pruneImages: () => {},
+      addAttachments: () => false,
+      removeAttachment: () => {},
+      pruneAttachments: () => {},
       submit: () => {},
     },
     useWorkspaces: selector => selector(panelWorkspace),

+ 45 - 11
packages/experimental/inspector/tests/port-selection.host.spec.ts

@@ -1,9 +1,52 @@
 /** Host Worker port-selection behavior. */
 
 import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
 import { afterEach, describe, expect, it } from 'vitest'
 import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
 
+async function closeServer(server: Server): Promise<void> {
+  if (!server.listening) return
+  await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
+}
+
+async function listen(server: Server, port: number): Promise<AddressInfo> {
+  return await new Promise<AddressInfo>((resolve, reject) => {
+    const onError = (error: Error): void => { reject(error) }
+    server.once('error', onError)
+    server.listen(port, '127.0.0.1', () => {
+      server.off('error', onError)
+      const address = server.address()
+      if (address === null || typeof address === 'string') {
+        reject(new Error('test server did not bind a TCP port'))
+        return
+      }
+      resolve(address)
+    })
+  })
+}
+
+async function bindWithAvailableSuccessor(): Promise<Server> {
+  for (let attempt = 0; attempt < 32; attempt += 1) {
+    const candidate = createServer()
+    const address = await listen(candidate, 0)
+    if (address.port === 65_535) {
+      await closeServer(candidate)
+      continue
+    }
+    const probe = createServer()
+    try {
+      await listen(probe, address.port + 1)
+      return candidate
+    } catch {
+      await closeServer(candidate)
+    } finally {
+      await closeServer(probe)
+    }
+  }
+  throw new Error('test could not reserve an occupied port with a bindable successor')
+}
+
 describe('Inspector endpoint port selection', () => {
   let blocker: Server | undefined
   let inspector: InspectorHandle | undefined
@@ -11,21 +54,12 @@ describe('Inspector endpoint port selection', () => {
   afterEach(async () => {
     await inspector?.close()
     inspector = undefined
-    if (blocker?.listening === true) {
-      await new Promise<void>((resolve) => { blocker!.close(() => { resolve() }) })
-    }
+    if (blocker !== undefined) await closeServer(blocker)
     blocker = undefined
   })
 
   it('advances from an occupied starting port and publishes the selected port', async () => {
-    blocker = createServer()
-    await new Promise<void>((resolve, reject) => {
-      blocker!.once('error', reject)
-      blocker!.listen(0, '127.0.0.1', () => {
-        blocker!.off('error', reject)
-        resolve()
-      })
-    })
+    blocker = await bindWithAvailableSuccessor()
     const occupiedAddress = blocker.address()
     if (occupiedAddress === null || typeof occupiedAddress === 'string') {
       throw new Error('test server did not bind a TCP port')

+ 16 - 0
packages/experimental/webworker-runtime/tests/transport/frames.spec.ts

@@ -20,3 +20,19 @@ describe('tunnel init frame', () => {
       .toThrow(/array of string overlay urls/)
   })
 })
+
+describe('tunnel request bodies', () => {
+  it('accepts ArrayBuffer and Blob bodies and rejects other structured-clone values', () => {
+    const bytes = Uint8Array.of(1, 2).buffer
+    const blob = new Blob(['large'])
+    expect(parseInboundFrame({
+      t: 'req', id: 1, method: 'POST', url: '/bytes', headers: {}, body: bytes,
+    })).toMatchObject({ body: bytes })
+    expect(parseInboundFrame({
+      t: 'req', id: 2, method: 'POST', url: '/blob', headers: {}, body: blob,
+    })).toMatchObject({ body: blob })
+    expect(() => parseInboundFrame({
+      t: 'req', id: 3, method: 'POST', url: '/bad', headers: {}, body: 'large',
+    })).toThrow('body must be an ArrayBuffer or Blob')
+  })
+})

+ 9 - 0
packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts

@@ -100,6 +100,15 @@ function stubWorker(): {
   check('204 resolves with a null body', (await response).body, null)
 }
 
+// Blob bodies remain Blob-backed across the page-to-Host Worker handoff.
+{
+  const { worker, sent } = stubWorker()
+  const tunnel = new WorkerTunnel(worker)
+  const body = new Blob(['large'])
+  void tunnel.fetch('/upload', { method: 'POST', body })
+  check('a Blob request body stays opaque', (sent[0] as unknown as { body: unknown }).body, body)
+}
+
 // A streamed reply reassembles in order and closes.
 {
   const { worker, deliver } = stubWorker()

+ 38 - 0
packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts

@@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
 import type { TunnelOutboundFrame } from '../../src/transport/frames.ts'
 import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts'
 
+function bytes(...values: number[]): Uint8Array<ArrayBuffer> {
+  const data = new Uint8Array(new ArrayBuffer(values.length))
+  data.set(values)
+  return data
+}
+
 function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } {
   const frames: TunnelOutboundFrame[] = []
   const server = new TunnelServer({
@@ -60,6 +66,38 @@ describe('worker tunnel unary authentication', () => {
   })
 })
 
+describe('worker tunnel Blob requests', () => {
+  it('streams an opaque Blob in bounded chunks inside the Host Worker route', async () => {
+    const frames: TunnelOutboundFrame[] = []
+    const seen: Uint8Array[] = []
+    const body = new Blob(['unused'])
+    body.arrayBuffer = () => Promise.reject(new Error('route must not aggregate the Blob'))
+    body.stream = () => new ReadableStream<Uint8Array<ArrayBuffer>>({
+      start(controller) {
+        controller.enqueue(bytes(108, 97, 114))
+        controller.enqueue(bytes(103, 101))
+        controller.close()
+      },
+    })
+    const server = new TunnelServer({
+      port: { postMessage: (frame) => { frames.push(frame) } },
+      requestListener: () => Promise.resolve(async (request, response) => {
+        for await (const chunk of request as AsyncIterable<Uint8Array>) seen.push(chunk)
+        const res = response as { writeHead(status: number): void; end(body: string): void }
+        res.writeHead(200)
+        res.end('stored')
+      }),
+    })
+    server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })()))
+    server.handleMessage({
+      t: 'req', id: 9, method: 'POST', url: 'http://localhost/upload', headers: {}, body,
+    })
+    await vi.waitFor(() => { expect(frames).toHaveLength(1) })
+    expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['lar', 'ge'])
+    expect(frames[0]).toMatchObject({ t: 'res', id: 9, status: 200 })
+  })
+})
+
 describe('worker tunnel logical streams', () => {
   it('drains a pre-boot open through the worker-local Gateway seam', async () => {
     const { server, frames } = harness()

+ 86 - 7
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -502,6 +502,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'an absolute host path, or undefined when this backend is not host-file-backed.',
         throws: ['an AttachmentError when the durable reference is invalid.'],
       },
+      {
+        signature: 'saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef>',
+        description: 'Durably commit one file byte-for-byte before its owning session event is appended. Files carry no admission limits: any byte content and length is accepted, and the stored object is the exact submitted bytes. Backends without verbatim file storage keep this default rejection.',
+        parameters: [{ name: 'input', description: 'exact bytes and optional display name.' }],
+        returns: 'the durable content-addressed file reference.',
+      },
+      {
+        signature: 'saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef>',
+        description: 'Durably commit one file byte-for-byte from bounded chunks. Providers must apply backpressure and must not collect the complete file in memory. Backends without streamed verbatim storage keep this default rejection.',
+        parameters: [{ name: 'input', description: 'ordered exact bytes, optional cancellation, and display name.' }],
+        returns: 'the durable content-addressed file reference.',
+      },
+      {
+        signature: 'async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable<Uint8Array>',
+        description: 'Read and verify one verbatim stored file as bounded chunks. Providers must not collect the complete file in memory. Backends without verbatim file reads keep this default rejection.',
+        parameters: [{ name: 'ref', description: 'durable reference from the session log.' }, { name: 'signal', description: 'optional cancellation for backend reads and verification work.' }],
+        returns: 'exact file bytes in order; integrity failures reject the iteration.',
+      },
+      {
+        signature: 'fileHostPath(ref: FileAttachmentRef): string | undefined',
+        description: 'Locate the verbatim stored file object in the harness host filesystem.',
+        parameters: [{ name: 'ref', description: 'durable file reference.' }],
+        returns: 'an absolute host path, or undefined when this backend is not host-file-backed.',
+        throws: ['an AttachmentError when the durable reference is invalid.'],
+      },
       {
         signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise<RequestImageAttachment>',
         description: 'Generate or read one deterministic model-request version from the stored normalized image.',
@@ -625,6 +650,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'definition', description: 'discovery metadata and direct UI handler.' }],
         returns: 'the exact effect disposer that unregisters this definition.',
       },
+      {
+        signature: 'registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void',
+        description: 'Register the sole authority that resolves staged file receipts for command submissions.',
+        parameters: [{ name: 'resolver', description: 'Session-aware receipt resolver.' }],
+        returns: 'disposer that removes this exact resolver.',
+      },
       {
         signature: '@Remote list(agent: Agent): readonly CommandDescriptor[]',
         description: 'List the effective immutable command descriptors for one agent.',
@@ -638,9 +669,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the scoped shadow or global definition.',
       },
       {
-        signature: '@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>',
-        description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nImage 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.',
-        parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'images', description: 'base64-encoded composer images accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }],
+        signature: '@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>',
+        description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nAttachment 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.',
+        parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'submittedAttachments', description: 'encoded images and staged file receipts accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }],
         returns: 'the settled execution (result + lifecycle pairing id), or `undefined` when syntax or name does not resolve.',
       },
     ],
@@ -1154,6 +1185,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'provider', description: 'provider route named by a request header.' }, { name: 'model', description: 'exact model id named by the same header.' }],
         returns: 'the owning adapter\'s image pricing for the route, when declared.',
       },
+      {
+        signature: 'fileRequestText(ref: FileAttachmentRef): string',
+        description: 'Resolve the exact text one durable file occurrence contributes to every provider request in the current execution environment.',
+        parameters: [{ name: 'ref', description: 'durable verbatim file reference from model history.' }],
+        returns: 'the same deterministic handle text used at adapter dispatch.',
+      },
       {
         signature: 'async listModels(provider: string): Promise<LlmModelInfo[]>',
         description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.',
@@ -1413,6 +1450,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'request', description: 'Session and attachment identities used for authorization.' }],
         returns: 'the durable attachment reference and base64-encoded bytes.',
       },
+      {
+        signature: '@Remote(\'uploadFile\') uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise<SessionUploadFileValue>',
+        description: 'Persist one encoded file upload verbatim and stage it for a later prompt on the same Session.',
+        parameters: [{ name: 'request', description: 'Session identity, base64 payload, and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }],
+        returns: 'an opaque per-upload receipt and the durable file reference.',
+      },
       {
         signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue',
         description: 'Mutate one still-pending queue occurrence on a live Agent.',
@@ -3670,22 +3713,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'CommandExecution',
     declaration: 'export interface CommandExecution {\n    readonly commandId: CommandId;\n    readonly result: CommandResult;\n}',
   },
+  {
+    name: 'CommandFileReceiptResolver',
+    declaration: 'export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined;',
+  },
   {
     name: 'CommandId',
     declaration: 'export type CommandId = Branded<\'CommandId\'>;',
   },
   {
     name: 'CommandInputDescriptor',
-    declaration: 'export interface CommandInputDescriptor {\n    readonly hint: string;\n    readonly images?: boolean;\n}',
+    declaration: 'export interface CommandInputDescriptor {\n    readonly hint: string;\n    readonly attachments?: boolean;\n}',
   },
   {
     name: 'CommandInvocation',
-    declaration: 'export interface CommandInvocation {\n    readonly commandId: CommandId;\n    readonly agent: Agent;\n    readonly rawInput: string;\n    readonly attachments: readonly ImageBlock[];\n    readonly signal: AbortSignal;\n}',
+    declaration: 'export interface CommandInvocation {\n    readonly commandId: CommandId;\n    readonly agent: Agent;\n    readonly rawInput: string;\n    readonly attachments: readonly (ImageBlock | FileBlock)[];\n    readonly signal: AbortSignal;\n}',
   },
   {
     name: 'CommandResult',
     declaration: 'export type CommandResult = {\n    readonly kind: \'success\';\n    readonly text?: string;\n    readonly sourceEventSeq?: number;\n} | {\n    readonly kind: \'error\';\n    readonly text: string;\n};',
   },
+  {
+    name: 'CommandSubmitAttachment',
+    declaration: 'export type CommandSubmitAttachment = ({\n    readonly type: \'image\';\n} & EncodedImageAttachment) | {\n    readonly type: \'file\';\n    readonly receiptId: string;\n};',
+  },
   {
     name: 'CompactionAgentContext',
     declaration: 'export interface CompactionAgentContext {\n    session: Session;\n    options: {\n        provider?: string;\n        model?: string;\n    };\n}',
@@ -3716,7 +3767,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ContentBlockMap',
-    declaration: 'export interface ContentBlockMap {\n    \'text\': TextBlock;\n    \'reasoning\': ReasoningBlock;\n    \'image\': ImageBlock;\n    \'tool-call\': ToolCallBlock;\n    \'tool-result\': ToolResultBlock;\n}',
+    declaration: 'export interface ContentBlockMap {\n    \'text\': TextBlock;\n    \'reasoning\': ReasoningBlock;\n    \'image\': ImageBlock;\n    \'file\': FileBlock;\n    \'tool-call\': ToolCallBlock;\n    \'tool-result\': ToolResultBlock;\n}',
   },
   {
     name: 'ContentBlockType',
@@ -3998,6 +4049,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'FiberState',
     declaration: 'export type FiberState = FiberStateEnum;',
   },
+  {
+    name: 'FileAttachmentRef',
+    declaration: 'export interface FileAttachmentRef {\n    attachmentId: AttachmentId;\n    name: string;\n    bytes: number;\n}',
+  },
+  {
+    name: 'FileBlock',
+    declaration: 'export interface FileBlock {\n    type: \'file\';\n    attachment: FileAttachmentRef;\n}',
+  },
   {
     name: 'FileDiff',
     declaration: 'export interface FileDiff {\n    path: string;\n    oldText: string | null;\n    newText: string;\n}',
@@ -4010,6 +4069,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'FileReferenceCandidate',
     declaration: 'export interface FileReferenceCandidate {\n    path: string;\n    kind: \'file\' | \'directory\';\n}',
   },
+  {
+    name: 'FileUploadReceiptId',
+    declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;',
+  },
   {
     name: 'FinishReason',
     declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];',
@@ -4328,7 +4391,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'LlmRuntime',
-    declaration: 'export class LlmRuntime extends TypertRemoteService {\n    constructor(ctx: Context);\n    registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n    @Remote\n    listProviders(): LlmProviderInfo[];\n    registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n    @Remote\n    listConfigurableProviders(): LlmConfigurableProvider[];\n    registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise<readonly LlmDiscoveredModel[]>): () => void;\n    async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise<LlmDiscoveredModel[]>;\n    @Remote(\'discoverModels\')\n    async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise<LlmDiscoveredModel[]>;\n    providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n    imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n    async listModels(provider: string): Promise<LlmModelInfo[]>;\n    async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n    async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>;\n    async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>;\n    stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
+    declaration: 'export class LlmRuntime extends TypertRemoteService {\n    constructor(ctx: Context);\n    registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n    @Remote\n    listProviders(): LlmProviderInfo[];\n    registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n    @Remote\n    listConfigurableProviders(): LlmConfigurableProvider[];\n    registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise<readonly LlmDiscoveredModel[]>): () => void;\n    async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise<LlmDiscoveredModel[]>;\n    @Remote(\'discoverModels\')\n    async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise<LlmDiscoveredModel[]>;\n    providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n    imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n    fileRequestText(ref: FileAttachmentRef): string;\n    async listModels(provider: string): Promise<LlmModelInfo[]>;\n    async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n    async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>;\n    async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>;\n    stream(options: GenerateOptions) /* …truncated — full shape in source */',
   },
   {
     name: 'LspHover',
@@ -4734,6 +4797,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SandboxPolicyRequest',
     declaration: 'export interface SandboxPolicyRequest {\n    session?: Session;\n    mode?: SandboxMode;\n}',
   },
+  {
+    name: 'SaveFileAttachment',
+    declaration: 'export interface SaveFileAttachment {\n    data: Uint8Array;\n    name?: string;\n}',
+  },
+  {
+    name: 'SaveFileStreamAttachment',
+    declaration: 'export interface SaveFileStreamAttachment {\n    data: AsyncIterable<Uint8Array>;\n    signal?: AbortSignal;\n    name?: string;\n}',
+  },
   {
     name: 'SaveImageAttachment',
     declaration: 'export interface SaveImageAttachment {\n    data: Uint8Array;\n    mediaType: ImageMediaType;\n    name?: string;\n}',
@@ -5198,6 +5269,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionUpdateQueueValue',
     declaration: 'export interface SessionUpdateQueueValue {\n    readonly accepted: true;\n}',
   },
+  {
+    name: 'SessionUploadFileRequest',
+    declaration: 'export interface SessionUploadFileRequest {\n    readonly sessionId: SessionId;\n    readonly data: string;\n    readonly name?: string;\n}',
+  },
+  {
+    name: 'SessionUploadFileValue',
+    declaration: 'export interface SessionUploadFileValue {\n    readonly receiptId: FileUploadReceiptId;\n    readonly file: FileAttachmentRef;\n}',
+  },
   {
     name: 'SessionWireEvent',
     declaration: 'export interface SessionWireEvent {\n    readonly type: string;\n    readonly seq: number;\n    readonly time: number;\n    readonly data: JsonValue;\n    readonly ignorable?: true;\n    readonly sourceEventSeqs?: number[];\n    readonly surfaceOp?: SurfaceOp;\n}',

+ 28 - 17
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -99,7 +99,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => {
     expect(test.ctx.commands.list(test.agent)).toContainEqual({
       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 },
     })
     expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
 
@@ -236,7 +236,7 @@ describe('/goal human command', () => {
   })
 })
 
-describe('/goal image attachments', () => {
+describe('/goal attachments', () => {
   const PNG = 'AAAA'
 
   /** Wire the fake store the executor admits through (once per harness). */
@@ -261,25 +261,35 @@ describe('/goal image attachments', () => {
         for (const input of inputs) refs.push(await saveImage(input))
         return refs
       },
+      saveFile(input: { data: Uint8Array; name?: string }) {
+        saved += 1
+        return Promise.resolve({
+          attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment',
+        })
+      },
     })
+    test.ctx.commands.registerFileReceiptResolver((_agent, receiptId) => receiptId === 'receipt-notes'
+      ? { attachmentId: 'file-notes' as never, bytes: 5, name: 'notes.txt' }
+      : undefined)
   }
 
-  /** Run /goal with `count` composer images through the executor boundary. */
-  async function runWithImages(test: Harness, suffix: string, count: number) {
-    const images = Array.from({ length: count }, (_, index) => ({
-      mediaType: 'image/png' as const, data: PNG, name: `ref-${index + 1}.png`,
-    }))
-    const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, images, new AbortController().signal)
+  /** Run /goal with a mixed composer batch through the executor boundary. */
+  async function runWithAttachments(test: Harness, suffix: string, includeFile = true) {
+    const attachments = [
+      { type: 'image' as const, mediaType: 'image/png' as const, data: PNG, name: 'ref.png' },
+      ...(includeFile ? [{ type: 'file' as const, receiptId: 'receipt-notes' }] : []),
+    ]
+    const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, attachments, new AbortController().signal)
     if (execution === undefined) throw new Error('goal command was not registered')
     return execution.result
   }
 
-  it('submits one user followup carrying the admitted images ahead of the round prompt', async () => {
+  it('submits one user followup carrying mixed attachments ahead of the round prompt', async () => {
     const test = await harness()
     provideStore(test)
     const followup = vi.fn()
     ;(test.agent as unknown as { followup: typeof followup }).followup = followup
-    const result = await runWithImages(test, ' rebuild the cathedral', 2)
+    const result = await runWithAttachments(test, ' rebuild the cathedral')
     expect(result.kind).toBe('success')
     expect(followup).toHaveBeenCalledTimes(1)
     const message = followup.mock.calls[0]?.[0] as {
@@ -287,9 +297,10 @@ describe('/goal image attachments', () => {
       source: { kind: string }
     }
     expect(message.source).toEqual({ kind: 'user' })
-    expect(message.content.map(block => block.type)).toEqual(['image', 'image', 'text'])
-    expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference images for the goal objective.' })
-    expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref-1.png')
+    expect(message.content.map(block => block.type)).toEqual(['image', 'file', 'text'])
+    expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference attachments for the goal objective.' })
+    expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref.png')
+    expect((message.content[1] as { attachment: { name: string } }).attachment.name).toBe('notes.txt')
   })
 
   it('accompanies an edit and a post-complete recreate the same way', async () => {
@@ -298,7 +309,7 @@ describe('/goal image attachments', () => {
     const followup = vi.fn()
     ;(test.agent as unknown as { followup: typeof followup }).followup = followup
     test.ctx.goals.create(test.agent, { objective: 'initial objective' })
-    const result = await runWithImages(test, ' edit refined objective', 1)
+    const result = await runWithAttachments(test, ' edit refined objective')
     expect(result.kind).toBe('success')
     expect(followup).toHaveBeenCalledTimes(1)
   })
@@ -310,10 +321,10 @@ describe('/goal image attachments', () => {
     ;(test.agent as unknown as { followup: typeof followup }).followup = followup
     test.ctx.goals.create(test.agent, { objective: 'active objective' })
     for (const suffix of [' pause', '', ' clear']) {
-      const result = await runWithImages(test, suffix, 1)
+      const result = await runWithAttachments(test, suffix, false)
       expect(result).toEqual({
         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>.',
       })
     }
     expect(followup).not.toHaveBeenCalled()
@@ -326,7 +337,7 @@ describe('/goal image attachments', () => {
     const followup = vi.fn()
     ;(test.agent as unknown as { followup: typeof followup }).followup = followup
     test.ctx.goals.create(test.agent, { objective: 'existing objective' })
-    const result = await runWithImages(test, ' replacement objective', 1)
+    const result = await runWithAttachments(test, ' replacement objective')
     expect(result.kind).toBe('error')
     expect(followup).not.toHaveBeenCalled()
   })

+ 65 - 21
packages/interaction/commands/tests/commands.spec.ts

@@ -465,7 +465,7 @@ describe('CommandRuntime', () => {
   })
 })
 
-describe('image attachments', () => {
+describe('command attachments', () => {
   const PNG = 'AAAA'
 
   function storeOf() {
@@ -483,6 +483,12 @@ describe('image attachments', () => {
           ...input.name === undefined ? {} : { name: input.name },
         })
       }),
+      saveFile: vi.fn((input: { data: Uint8Array; name?: string }) => {
+        saved += 1
+        return Promise.resolve({
+          attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment',
+        })
+      }),
       validateImageBatch(inputs: readonly unknown[]) {
         const validate = AttachmentStore.prototype as unknown as {
           validateImageBatch(this: unknown, batch: readonly unknown[]): void
@@ -500,8 +506,8 @@ describe('image attachments', () => {
   function accepting(handler: CommandDefinition['handler']): CommandDefinition {
     return {
       name: 'vision',
-      description: 'accepts images',
-      input: { hint: '<objective>', images: true },
+      description: 'accepts attachments',
+      input: { hint: '<objective>', attachments: true },
       handler,
     }
   }
@@ -510,32 +516,32 @@ describe('image attachments', () => {
     const ctx = await mount()
     expect(() => ctx.commands.register({
       ...command('flag-type'),
-      input: { hint: 'x', images: 'yes' },
-    } as unknown as CommandDefinition)).toThrow('command "flag-type" input images flag must be a boolean')
+      input: { hint: 'x', attachments: 'yes' },
+    } as unknown as CommandDefinition)).toThrow('command "flag-type" input attachments flag must be a boolean')
   })
 
-  it('lists images acceptance on the descriptor and omits a false flag', async () => {
+  it('lists attachment acceptance on the descriptor and omits a false flag', async () => {
     const ctx = await mount()
     const { agent } = await mintAgentScope(ctx, 'a')
     ctx.commands.register(accepting(() => ({ kind: 'success' })))
-    ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', images: false } })
+    ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', attachments: false } })
     const byName = new Map(ctx.commands.list(agent).map(descriptor => [descriptor.name, descriptor]))
-    expect(byName.get('vision')?.input).toEqual({ hint: '<objective>', images: true })
+    expect(byName.get('vision')?.input).toEqual({ hint: '<objective>', attachments: true })
     expect(byName.get('plain-input')?.input).toEqual({ hint: 'x' })
   })
 
-  it('settles images sent to a non-declaring command as a logged error before the handler', async () => {
+  it('settles attachments sent to a non-declaring command as a logged error before the handler', async () => {
     const ctx = await mount()
     const { agent } = await mintAgentScope(ctx, 'a')
     const handler = vi.fn(() => ({ kind: 'success' as const }))
     ctx.commands.register({ ...command('deploy'), handler })
     const execution = await ctx.commands.execute(
-      agent, '/deploy now', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal)
-    expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept image attachments' })
+      agent, '/deploy now', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal)
+    expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept attachments' })
     expect(handler).not.toHaveBeenCalled()
     expect(lifecycleOf(agent)).toMatchObject([
       { type: 'command/run', data: { name: 'deploy' } },
-      { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept image attachments' } },
+      { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept attachments' } },
     ])
   })
 
@@ -544,41 +550,79 @@ describe('image attachments', () => {
     const { agent } = await mintAgentScope(ctx, 'a')
     ctx.commands.register(accepting(() => ({ kind: 'success' })))
     const execution = await ctx.commands.execute(
-      agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal)
+      agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal)
     expect(execution?.result).toEqual({
       kind: 'error',
-      text: '/vision: image attachments are unavailable because no attachment store is composed',
+      text: '/vision: attachments are unavailable because no attachment store is composed',
     })
   })
 
-  it('admits and hands the handler frozen ordered image blocks; plain invocations stay empty', async () => {
+  it('admits a mixed batch in selection order and keeps plain invocations empty', async () => {
     const ctx = await mount()
     ctx.provide('attachments', storeOf())
     const { agent } = await mintAgentScope(ctx, 'a')
+    ctx.commands.registerFileReceiptResolver((_receivingAgent, receiptId) => receiptId === 'receipt-notes'
+      ? { attachmentId: 'file-notes' as never, name: 'notes.txt', bytes: 5 }
+      : undefined)
     const seen = vi.fn((invocation: { attachments: readonly unknown[] }) => {
       expect(Object.isFrozen(invocation.attachments)).toBe(true)
       return { kind: 'success' as const }
     })
     ctx.commands.register(accepting(seen))
     await ctx.commands.execute(agent, '/vision x', [
-      { mediaType: 'image/png', data: PNG, name: 'a.png' },
-      { mediaType: 'image/png', data: PNG, name: 'b.png' },
+      { type: 'image', mediaType: 'image/png', data: PNG, name: 'a.png' },
+      { type: 'file', receiptId: 'receipt-notes' },
+      { type: 'image', mediaType: 'image/png', data: PNG, name: 'b.png' },
     ], new AbortController().signal)
     const invocation = seen.mock.calls[0]?.[0] as { attachments: ReadonlyArray<{ type: string; attachment: { name?: string } }> }
     expect(invocation.attachments.map(block => [block.type, block.attachment.name])).toEqual([
-      ['image', 'a.png'], ['image', 'b.png'],
+      ['image', 'a.png'], ['file', 'notes.txt'], ['image', 'b.png'],
     ])
     await ctx.commands.execute(agent, '/vision y', [], new AbortController().signal)
     expect((seen.mock.calls[1]?.[0] as { attachments: readonly unknown[] }).attachments).toEqual([])
   })
 
+  it('requires a same-session file receipt resolver and keeps its registration single-owner', async () => {
+    const ctx = await mount()
+    const store = storeOf()
+    ctx.provide('attachments', store)
+    const { agent } = await mintAgentScope(ctx, 'a')
+    const handler = vi.fn(() => ({ kind: 'success' as const }))
+    ctx.commands.register(accepting(handler))
+    const missing = await ctx.commands.execute(
+      agent, '/vision x', [{ type: 'file', receiptId: 'missing' }], new AbortController().signal)
+    expect(missing?.result).toEqual({
+      kind: 'error', text: 'File upload receipt is unknown for this session.',
+    })
+    expect(handler).not.toHaveBeenCalled()
+    const mixed = await ctx.commands.execute(agent, '/vision x', [
+      { type: 'image', mediaType: 'image/png', data: PNG },
+      { type: 'file', receiptId: 'missing' },
+    ], new AbortController().signal)
+    expect(mixed?.result).toEqual({
+      kind: 'error', text: 'File upload receipt is unknown for this session.',
+    })
+    expect(store.saveImage).not.toHaveBeenCalled()
+
+    const first = vi.fn(() => undefined)
+    const disposeFirst = ctx.commands.registerFileReceiptResolver(first)
+    expect(() => ctx.commands.registerFileReceiptResolver(() => undefined))
+      .toThrow('commands: a file receipt resolver is already registered')
+    disposeFirst()
+    const disposeSecond = ctx.commands.registerFileReceiptResolver(() => undefined)
+    disposeFirst()
+    expect(() => ctx.commands.registerFileReceiptResolver(() => undefined))
+      .toThrow('commands: a file receipt resolver is already registered')
+    disposeSecond()
+  })
+
   it('settles an admission limit failure as a logged error result', async () => {
     const ctx = await mount()
     ctx.provide('attachments', storeOf())
     const { agent } = await mintAgentScope(ctx, 'a')
     const handler = vi.fn(() => ({ kind: 'success' as const }))
     ctx.commands.register(accepting(handler))
-    const three = [1, 2, 3].map(() => ({ mediaType: 'image/png' as const, data: PNG }))
+    const three = [1, 2, 3].map(() => ({ type: 'image' as const, mediaType: 'image/png' as const, data: PNG }))
     const execution = await ctx.commands.execute(agent, '/vision x', three, new AbortController().signal)
     expect(execution?.result).toEqual({ kind: 'error', text: 'Image batch exceeds the configured image-count limit.' })
     expect(handler).not.toHaveBeenCalled()
@@ -600,7 +644,7 @@ describe('image attachments', () => {
     const handler = vi.fn(() => ({ kind: 'success' as const }))
     ctx.commands.register(accepting(handler))
     await expect(ctx.commands.execute(
-      agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], controller.signal,
+      agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], controller.signal,
     )).rejects.toThrow('operator cancelled during admission')
     expect(handler).not.toHaveBeenCalled()
     expect(lifecycleOf(agent).at(-1)).toMatchObject({
@@ -617,7 +661,7 @@ describe('image attachments', () => {
     const { agent } = await mintAgentScope(ctx, 'a')
     ctx.commands.register(accepting(() => ({ kind: 'success' })))
     await expect(ctx.commands.execute(
-      agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal,
+      agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal,
     )).rejects.toThrow('disk gone')
     expect(lifecycleOf(agent).at(-1)).toMatchObject({
       type: 'command/done',

+ 24 - 8
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -620,7 +620,7 @@ describe('/plan', () => {
     const plainSteer = vi.fn()
     ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
     expect(ctx.commands.list(plainAgent)).toEqual([
-      { 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 } },
     ])
 
     const signal = new AbortController().signal
@@ -704,7 +704,7 @@ describe('/plan', () => {
     expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false)
   })
 
-  it('steers image attachments with or without text and refuses them on /plan off', async () => {
+  it('steers mixed attachments with or without text and refuses them on /plan off', async () => {
     const ctx = await setup()
     await ctx.plugin(CommandRuntime)
     await new Promise(resolve => setImmediate(resolve))
@@ -727,21 +727,34 @@ describe('/plan', () => {
         for (const input of inputs) refs.push(await saveImage(input))
         return refs
       },
+      saveFile(input: { data: Uint8Array; name?: string }) {
+        saved += 1
+        return Promise.resolve({
+          attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment',
+        })
+      },
     })
+    ctx.commands.registerFileReceiptResolver((agent, receiptId) => receiptId === 'receipt-notes'
+      ? { attachmentId: `file-${agent.id}` as never, bytes: 5, name: 'notes.txt' }
+      : undefined)
     const signal = new AbortController().signal
-    const images = [{ mediaType: 'image/png' as const, data: 'AAAA' }]
+    const attachments = [
+      { type: 'image' as const, mediaType: 'image/png' as const, data: 'AAAA', name: 'diagram.png' },
+      { type: 'file' as const, receiptId: 'receipt-notes' },
+    ]
 
     const agent = await agentWithSession(ctx, 'imaged-plan-command')
     openTurn(agent.session)
     const steer = vi.fn()
     ;(agent as unknown as { steer: typeof steer }).steer = steer
-    const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', images, signal)
+    const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', attachments, signal)
     expect(withMessage?.result.kind).toBe('success')
     expect(steer).toHaveBeenCalledExactlyOnceWith({
       id: expect.any(String) as unknown,
       role: 'user',
       content: [
         { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-1' }) as unknown },
+        { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-plan-command', name: 'notes.txt' }) as unknown },
         { type: 'text', text: 'sketch the layout' },
       ],
       source: { kind: 'user' },
@@ -751,12 +764,15 @@ describe('/plan', () => {
     openTurn(bareAgent.session)
     const bareSteer = vi.fn()
     ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer
-    expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result)
+    expect((await ctx.commands.execute(bareAgent, '/plan', attachments, signal))?.result)
       .toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.' })
     expect(bareSteer).toHaveBeenCalledExactlyOnceWith({
       id: expect.any(String) as unknown,
       role: 'user',
-      content: [{ type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }],
+      content: [
+        { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown },
+        { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-bare-plan-command', name: 'notes.txt' }) as unknown },
+      ],
       source: { kind: 'user' },
     })
     expect(ctx.planMode.get(bareAgent)).toEqual({ active: false, pending: true })
@@ -764,8 +780,8 @@ describe('/plan', () => {
     const activeAgent = await agentWithSession(ctx, 'imaged-off-plan-command', { active: true })
     const offSteer = vi.fn()
     ;(activeAgent as unknown as { steer: typeof offSteer }).steer = offSteer
-    expect((await ctx.commands.execute(activeAgent, '/plan off', images, signal))?.result)
-      .toEqual({ kind: 'error', text: 'Image attachments cannot accompany /plan off.' })
+    expect((await ctx.commands.execute(activeAgent, '/plan off', attachments, signal))?.result)
+      .toEqual({ kind: 'error', text: 'Attachments cannot accompany /plan off.' })
     expect(offSteer).not.toHaveBeenCalled()
     expect(ctx.planMode.get(activeAgent)).toEqual({ active: true })
   })

+ 1 - 1
packages/subagent/subagent-codex/tests/real-product.spec.ts

@@ -53,7 +53,7 @@ afterEach(async () => {
   for (const root of roots.splice(0)) {
     await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
   }
-})
+}, 30_000)
 
 interface RealHarness {
   readonly ctx: Context

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

@@ -428,6 +428,7 @@ describe('fixture session face', () => {
     const bare = runtime.sessions.behavior('s1')
     expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
     expect(() => bare.readAttachment('att-1' as Parameters<typeof bare.readAttachment>[0])).toThrow(/readAttachment is not stubbed/)
+    expect(() => bare.uploadFile()).toThrow(/uploadFile is not stubbed/)
     expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
     expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
     expect(() => bare.command()).toThrow(/command is not stubbed/)

+ 4 - 4
packages/test-support/session-snapshot/tests/harness.spec.ts

@@ -895,9 +895,9 @@ describe('runScenario', () => {
   it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => {
     const missing = await scenario({})
     await expect(runScenario(
-      { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] },
+      { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 200 }] },
       { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
-    )).rejects.toThrow(/did not persist turn\/end within 20ms/)
+    )).rejects.toThrow(/did not persist turn\/end within 200ms/)
 
     const open = await scenario({
       prompt: 'hang-until-cancel',
@@ -915,11 +915,11 @@ describe('runScenario', () => {
         steps: [
           ...boot,
           { op: 'promptAndCancel', text: 'hang' },
-          { op: 'waitForTurnEnd', timeoutMs: 20 },
+          { op: 'waitForTurnEnd', timeoutMs: 200 },
         ],
       },
       { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile },
-    )).rejects.toThrow(/did not persist turn\/end within 20ms/)
+    )).rejects.toThrow(/did not persist turn\/end within 200ms/)
   })
 
   it('waitForGoalPhase requires the requested durable goal phase', { timeout: 20_000 }, async () => {

+ 3 - 0
pnpm-lock.yaml

@@ -783,6 +783,9 @@ importers:
       '@deepseek-ai/dsh-client-store':
         specifier: workspace:^
         version: link:../../client/store
+      '@deepseek-ai/dsh-commands':
+        specifier: workspace:^
+        version: link:../../interaction/commands
       '@deepseek-ai/dsh-file-reference':
         specifier: workspace:^
         version: link:../../context/file-reference

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

@@ -337,6 +337,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SessionSelectModelValue: 'session.md',
   SessionSummary: 'session.md',
   SessionUpdateQueueRequest: 'session.md',
+  SessionUploadFileRequest: 'session.md',
+  SessionUploadFileValue: 'session.md',
   SessionUpdateQueueValue: 'session.md',
   SessionStartSource: 'core.md',
   SessionLogSnapshot: 'session-query.md',
@@ -347,7 +349,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   ApprovalRequestEvent: 'approval.md',
   ApprovalService: 'approval.md',
   AskUserQuestionRequestEvent: 'user-questions.md',
+  EncodedFileAttachment: 'attachment.md',
   EncodedImageAttachment: 'attachment.md',
+  FileAttachmentRef: 'attachment.md',
+  SaveFileAttachment: 'attachment.md',
+  SaveFileStreamAttachment: 'attachment.md',
   ImageAttachmentAccess: 'llm-streaming.md',
   ImageAttachmentRef: 'attachment.md',
   ImageRequestPolicy: 'attachment.md',
@@ -392,8 +398,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   CreateGoalResult: 'goal.md',
   CommandDefinition: 'commands.md',
   CommandDescriptor: 'commands.md',
+  CommandFileReceiptResolver: 'commands.md',
   CommandId: 'commands.md',
   CommandResult: 'commands.md',
+  CommandSubmitAttachment: 'commands.md',
   CommandSurface: 'commands.md',
   LspProvider: 'lsp.md',
   LspQueryRequest: 'lsp.md',

+ 3 - 0
scripts/verify-client-ui-i18n.ts

@@ -35,8 +35,11 @@ const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Pl
 const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|message|placeholder|summary|text|title|tooltip)(?:s|_.*)?$/i
 const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|message|placeholder|summary|text|title|tooltip|tabs)$/i
 const IMMUTABLE_LANGUAGE_TOKENS = new Set([
+  'B',
   'Function',
+  'GB',
   'K',
+  'KB',
   'M',
   'MB',
   'Symbol',

+ 2 - 0
snapshots/web/bash-abort-row/ui.expected.md

@@ -29,6 +29,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/cordis-tool-round/ui.expected.md

@@ -117,6 +117,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/feedback-command/ack-expanded.expected.md

@@ -47,6 +47,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/feedback-command/ack.expected.md

@@ -39,6 +39,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/feedback-release/ack-expanded.expected.md

@@ -47,6 +47,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/feedback-release/ack.expected.md

@@ -39,6 +39,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 43 - 0
snapshots/web/file-upload-round/replay.override.json

@@ -0,0 +1,43 @@
+{
+  "patches": [
+    {
+      "at": 0,
+      "entry": {
+        "kind": "chunks",
+        "chunks": [
+          {
+            "type": "block-start",
+            "index": 0,
+            "blockType": "tool-call"
+          },
+          {
+            "type": "block-end",
+            "index": 0,
+            "block": {
+              "type": "tool-call",
+              "id": "call_00_ET_x83IHPfS8ouwILXe8u729956",
+              "name": "read",
+              "arguments": "{\"file_path\": \"{{fromRequest:saved at \\\"([^\\\"]+)\\\"}}\"}"
+            }
+          },
+          {
+            "type": "usage",
+            "usage": {
+              "inputTokens": 8243,
+              "outputTokens": 147,
+              "totalTokens": 8390,
+              "cacheReadTokens": 0,
+              "reasoningTokens": 0
+            }
+          },
+          {
+            "type": "finish",
+            "reason": {
+              "kind": "tool-calls"
+            }
+          }
+        ]
+      }
+    }
+  ]
+}

+ 31 - 0
snapshots/web/file-upload-round/session.jsonl

@@ -0,0 +1,31 @@
+{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787887026413,"cwd":"{{cwd}}","agentPreset":"standard"}
+{"type":"permission/preset","data":{"preset":"workspace-write"}}
+{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
+{"type":"approval/policy","data":{"policy":"ask"}}
+{"type":"model/selection","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high"}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"user/message","data":{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
+{"type":"session/title","data":{"title":"Read the attached file with","messageSeqs":[8],"source":{"kind":"fallback"}}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"}
+{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ET_x83IHPfS8ouwILXe8u729956"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_x83IHPfS8ouwILXe8u729956","content":[{"type":"text","text":"<path>{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt</path>\n<type>file</type>\n<content>\n1: UPLOAD_ROUND_OK\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt","offset":1,"lines":[{"number":1,"text":"UPLOAD_ROUND_OK"}],"totalLines":1}},"sourceEventSeqs":[18],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
+{"type":"step/start","data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"text-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,15,17,15,17],"texts":["U","PL","O","AD","_RO","UND","_OK"]}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UPLOAD_ROUND_OK"}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UPLOAD_ROUND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}},"sourceEventSeqs":[22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":2}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 9 - 0
snapshots/web/file-upload-round/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: file-upload-round
+profile: web
+composition: web-default
+recording: live
+header:
+  class: web-default
+replay:
+  override: true

+ 3 - 0
snapshots/web/file-upload-round/trajectory.expected.md

@@ -0,0 +1,3 @@
+- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.":
+  - cell "Turn 1 USER": USER
+  - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop."

+ 52 - 0
snapshots/web/file-upload-round/ui.expected.md

@@ -0,0 +1,52 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Read the attached file with" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: poem.txt TXT 16B
+- button "reference-1.png, click to view original":
+  - img "reference-1.png"
+- text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}}
+- button "Copy":
+  - img
+- button "1 tool call":
+  - text: 1 tool call
+  - img
+- paragraph: UPLOAD_ROUND_OK
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Usage 17.3K tok":
+  - img
+  - text: Usage 17.3K tok
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high":
+  - text: DeepSeek-V4-Flash-Vision-Exp high
+  - img
+- button "1% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 17.2K tok · Output 155 tok

+ 2 - 0
snapshots/web/fresh-round-trip/ui-expanded.expected.md

@@ -51,6 +51,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/fresh-round-trip/ui.expected.md

@@ -35,6 +35,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md

@@ -229,6 +229,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/goal-multi-turn-actions/ui.expected.md

@@ -117,6 +117,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/lifecycle-chrome/hero.expected.md

@@ -33,6 +33,8 @@
   - paragraph
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/lifecycle-chrome/plan-active.expected.md

@@ -32,6 +32,8 @@
 - textbox "Describe what you want to build... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Plan mode on, press to turn off": Plan
 - button "Select model, current DeepSeek-V4-Flash":

+ 2 - 0
snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md

@@ -43,6 +43,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

+ 2 - 0
snapshots/web/lifecycle-chrome/reloaded.expected.md

@@ -35,6 +35,8 @@
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img
+- button "Add attachment":
+  - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff