From 580e05b7943a9bf768b3fa17b42cff05ba0137c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:38:37 +0800 Subject: [PATCH] fix(gui): harden multimodal image attachments --- .../2026-07-05-reconstructable-requests.md | 8 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 61 +++-- ...-image-input-and-durable-attachments.zh.md | 61 +++-- apps/cli/src/web.ts | 16 +- apps/web/tests/smoke-fixture.e2e.ts | 2 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/src/image.ts | 23 +- .../attachment/attachment-local/src/index.ts | 2 +- .../attachment/attachment-local/src/store.ts | 36 ++- .../attachment-local/tests/image.spec.ts | 93 ++++++++ .../attachment-local/tests/index.spec.ts | 39 ++++ .../attachment-local/tests/store.spec.ts | 53 ++++- packages/client/connection/README.md | 2 +- packages/client/connection/src/client/api.ts | 4 + .../connection/src/client/connection.ts | 11 +- .../client/connection/src/client/index.ts | 2 +- .../connection/tests/connection.spec.ts | 32 +++ .../client/connection/tests/fixture.spec.ts | 25 +++ packages/client/runtime/README.md | 2 +- packages/client/runtime/src/client/index.ts | 1 + .../runtime/src/client/sessions/service.ts | 19 +- .../client/runtime/tests/client-apply.spec.ts | 2 + packages/client/ui-conversation/README.md | 2 + .../ui-conversation/src/client/apply.ts | 15 +- .../src/client/chat/AssistantMarkdown.tsx | 4 +- .../src/client/contract/slots.ts | 11 +- .../ui-conversation/src/client/service.ts | 82 ++++++- .../src/client/skeleton/ConversationRoot.tsx | 9 +- .../src/client/skeleton/EmptyState.tsx | 31 ++- .../src/client/skeleton/InputBar.tsx | 36 ++- .../tests/apply-inject.spec.tsx | 10 +- .../ui-conversation/tests/input-bar.spec.tsx | 30 ++- .../tests/message-image.spec.tsx | 20 ++ .../tests/service-orchestration.spec.ts | 137 ++++++++++- .../tests/skeleton-branches.spec.tsx | 25 ++- .../ui-conversation/tests/skeleton.spec.tsx | 62 ++++- .../client/ui-trajectory/tests/views.spec.tsx | 1 + packages/compact/compact-basic/README.md | 4 +- .../compact/compact-basic/src/summarizer.ts | 17 +- .../compact-basic/tests/compact-basic.spec.ts | 55 ++++- packages/host/runtime/src/api-proxy.ts | 7 +- .../host/runtime/tests/host-runtime.spec.ts | 212 +++++++++++++++++- packages/host/webserver/README.md | 2 +- packages/host/webserver/src/index.ts | 43 +++- .../host/webserver/tests/webserver.spec.ts | 78 ++++++- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 15 +- packages/llm/llm-pi-ai/src/index.ts | 3 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 96 ++++++++ packages/llm/llm-pi-ai/tests/context.spec.ts | 207 +++++++++++++++++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 74 +++++- .../llm/token-meter/tests/token-meter.spec.ts | 13 +- packages/ui/acp/tests/codec.spec.ts | 12 + packages/ui/tui/README.md | 2 +- packages/ui/tui/src/index.ts | 16 +- packages/ui/tui/tests/tui.spec.ts | 46 ++++ scripts/test-invariants.ts | 27 +++ 61 files changed, 1700 insertions(+), 214 deletions(-) rename .agents/notes/{proposed => implemented}/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml (82%) rename .agents/notes/{proposed => implemented}/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md (69%) rename .agents/notes/{proposed => implemented}/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md (70%) create mode 100644 packages/attachment/attachment-local/tests/image.spec.ts create mode 100644 packages/attachment/attachment-local/tests/index.spec.ts create mode 100644 packages/llm/llm-pi-ai/tests/context.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 99a7633c5b..9670fcf0f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -1,4 +1,4 @@ -# Agent Note: Every LLM request is reconstructable from the session log +# Agent Note: Every LLM request is reconstructable from durable session state Status: implemented @@ -12,7 +12,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con ### The principle -**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership. +**Model-visible ⟺ durably recorded.** Text and structured references that reach a model request are recorded in the session log; referenced binary bytes live in immutable content-addressed attachment storage and are integrity-checked against the logged metadata. The checkable consequence: **every conversation `GenerateOptions` the loop sends is a pure function of the session log**, while provider wire bytes are a function of that envelope plus the immutable attachment objects at a pinned code version. Text-only adapter serialization remains pure in-memory conversion; image-capable adapters perform verified `readImage()` I/O during serialization. Direct one-shots such as compaction log their envelope scalars (`compact/summary.{provider, model, maxTokens}`), derive input deterministically from the logged region, and resolve any referenced images through the same durable store; they remain outside the loop invariant because only the loop marks request ownership. Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3. @@ -26,7 +26,7 @@ Each step rebuilds prompt assembly. On the instance's first step, `agent/session **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. -**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. The loop records the exact frozen request through `markAgentLoopRequest()` in `dsh-llm`; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. +**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Attachment backends separately verify referenced bytes against their content digest and logged media metadata before an adapter may serialize them. The loop records the exact frozen request through `markAgentLoopRequest()` in `dsh-llm`; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted @@ -44,7 +44,7 @@ Like MiniCode, the conversation advances append-only and resets only when model- ## Consequences -- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. +- A request envelope that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event. Exact replay of an image request additionally requires the immutable object named by its logged attachment reference. - Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()` and tool/prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern). - What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. - The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam. diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml similarity index 82% rename from .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 43f81f5752..5d438dfc9d 100644 --- a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 14ad4e5ceb28fd7df8b639db403be8accd8c2028 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5752247b080ee1be0b0bfbbf7a9fb7486ff6c41b +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 156bec4a4f9bb05490847cbe775368bc472a33e9 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: fb0f8d3031bb2d0e6af97136892cd07c22d575b3 diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md similarity index 69% rename from .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md rename to .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 14ad4e5ceb..156bec4a4f 100644 --- a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -1,12 +1,12 @@ # Agent Note: Web multimodal image input and durable attachments -Status: proposed +Status: implemented English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md) ## Problem -The Web composer accepts only text: `InputBar` receives a string draft, `ConversationService.send()` creates text content, and the host forwards that content to the agent. Users cannot paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. +Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationService.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. @@ -14,19 +14,19 @@ The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web- Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them. -## Proposal +## Decision -Add pasted or dropped raster images to the Web composer as the first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. +Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. -Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu are separate follow-ups. +Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. ### Product behavior - Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target. - The rail is shared by the empty-state and resident composers, is hidden when empty, and scrolls horizontally instead of widening the composer. - Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click. -- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; the paste handler prevents the default only when it accepts an image file. File drops on the composer always prevent browser navigation, accept supported images, and report unsupported files locally. -- A failed send restores the complete text and image draft. Removal, successful send, and session-scope disposal revoke obsolete object URLs. +- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally. +- A failed send restores the complete text and image draft. Removal, successful send, empty-state disposal, rendered-session disposal, and application disposal revoke the object URLs they own. - Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. - Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. - Version one does not override the browser context menu and provides no explicit image-copy action. @@ -54,6 +54,7 @@ interface ChatStoreState { } interface ComposerAttachment { + kind: 'image' id: string file: File previewUrl: string @@ -64,7 +65,7 @@ This split uses the slots framework's store seat and bound actions as the single The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. A temporary file is written, synchronized, and atomically published before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier, and every read verifies the digest, media type, byte length, width, and height. -The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. +The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. ### Durable content and prompt wire @@ -90,7 +91,7 @@ interface ImageBlock { } ``` -`ImageBlock` joins the merge-extensible core `ContentBlockMap` and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. +`ImageBlock` joins the merge-extensible core `ContentBlockMap` and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. The LLM vocabulary therefore has a type-only dependency on the attachment seam; provider runtime dependencies remain adapter-specific. The browser cannot mint a durable reference, so `session.prompt` accepts a narrow intake union rather than canonical `ContentBlock[]`: @@ -109,29 +110,31 @@ type PromptInputPart = Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes. -`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client caches the resulting object URL by session and attachment identifier for its service lifetime and revokes it on disposal. +`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and invalidates late loads so an unmounted session cannot repopulate the cache. ### Model capabilities and provider behavior Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. If the selected model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. Immediate intake-time rejection in the UI may be added after model selection is exposed consistently across every Web entry path. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the default model and image limits into `SessionsService`; both composers use the limits before allocating object URLs or base64, while only the no-session composer uses the default model for early explicit text-only feedback. Decoded-pixel validation and every resident session's actual route remain authoritative on the host. -The Pi-AI adapter is the first visual-input route: it resolves each durable reference through `ctx.attachments` and emits native image content only for models that declare image input. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. Token estimation accounts for image dimensions without counting base64 or attachment locators as text. Provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block. +Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compact-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. + ### History rendering and original preview -History folding preserves `ImageBlock` in both user and assistant messages. User images align to the trailing edge above their text; assistant images align to the leading narration flow. `MessageImage` derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses `object-fit: contain`, and turns a missing or corrupt object into a retryable error control. +History folding preserves `ImageBlock` in both user and assistant messages. User images align to the trailing edge above their text; assistant images remain in their original content-block position in the leading narration flow. `MessageImage` derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses `object-fit: contain`, and turns a missing or corrupt object into a retryable error control. Composer thumbnails and each `MessageImage` own ephemeral original-preview state and invoke the same pure `ImageLightbox`. The modal uses the already resolved original object URL, constrains only display size, focuses its close control, and restores the previous focus target when closed. ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The Web carrier independently caps buffered API request bodies, with `dsh web` deriving its default from the aggregate image limit plus base64/envelope expansion and allowing an explicit override. Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. @@ -144,19 +147,18 @@ Malformed base64, unsupported or mismatched media, truncated headers, excess byt | `packages/llm/llm` and `packages/llm/token-meter` | Role-neutral `ImageBlock`, modality metadata, and image cost estimation. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | | `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/compact/compact-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/host/runtime` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits, and model preflight. | +| `packages/host/webserver` | Bound buffered API request bodies before the fetch carrier. | | `packages/client/connection` and `packages/client/runtime` | Wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | | `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. | | `packages/ui/acp` | Explicit fallback rendering for image blocks. | The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`. -### Delivery +### Implementation -1. Land the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, and durable host ordering. -2. Land the Web upload/read protocol, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, and assembled keyless Web coverage. -3. Add immediate intake-time capability feedback when active model selection is consistently available to the composer. -4. Propose file picking, generic files/PDF, audio/video, durable draft staging, output-provider certification, and reference-aware garbage collection independently. +The implemented slice includes the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, host-capability projection, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -186,20 +188,16 @@ Composer presentation can use a generic attachment rail, but provider semantics UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback. -## Acceptance criteria +## Testing -- Pasting or dropping one or more supported images shows ordered removable thumbnails above both composer variants without changing textarea text; drag-over highlights the target, unsupported drops cannot navigate away, and image-only send works. -- Unsent browser images exist only as `File` and object URLs, survive session switches in memory, do not enter `localStorage`, and are revoked after removal, accepted send, or service disposal. -- Every accepted user image is committed below resolved `DSH_HOME` before its `user/message` event. The event contains only `ImageBlock` references and never base64 or temporary paths. -- Structured assistant images can be represented only by a durable `ImageBlock`; a future output adapter must persist bytes before emitting the assistant event, while Markdown image URLs remain text. -- Cold history renders user and assistant image references through the same bounded control. Double-click opens the original; Escape, backdrop, and close control dismiss it without a custom context menu. -- Session attachment reads fail unless the same session log references the identifier. Missing or corrupt objects fail explicitly and never return unverified bytes. -- Pi-AI emits native input images for a compatible route. DeepSeek and every non-implementing consumer return an explicit unsupported-content failure rather than dropping the block. -- An explicitly text-only active model rejects image send before attachment persistence or session event append; unknown metadata still reaches adapter enforcement and a failed send restores the draft. -- Keyless unit, host integration, client integration, and assembled Chromium coverage exercise persistence ordering, absence of base64 in logs, authorization, paste and drop, image-only send, historical user and assistant images, original preview, and object-URL cleanup. -- The current production adapter set declares text-only output; output-provider certification, file picking, non-image files, video, persistent drafts, and garbage collection remain outside version one. +- Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, and bounded HTTP request bodies. +- Client unit and assembled Chromium tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, historical user and assistant images, original preview, ordering, and draft/session/application object-URL cleanup. +- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, nested tool-result images, preserved summary input, and explicit image-output rejection. +- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. +- The current production adapter set declares text-only output; output-provider certification remains outside version one. -## Risks +## Consequences - Durable storage grows without garbage collection. Version one chooses replay safety over premature deletion. - A missing or corrupt object makes exact model reconstruction fail. Failing loud preserves integrity but may prevent that session from continuing until repaired. @@ -208,3 +206,4 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog - Original preview decodes more pixels than the inline control displays. Pixel limits, one clicked preview, and object-URL disposal bound but do not eliminate transient browser memory. - Capability metadata may be missing or stale. Host preflight improves feedback, while adapter enforcement remains authoritative. - A future output provider may require authenticated retrieval before an assistant image can complete, adding latency and a new failure point. Persist-before-event ordering favors replay integrity. +- File picking, generic files/PDF, audio/video, durable draft staging, image copying, custom context menus, output-provider certification, and reference-aware garbage collection remain independent designs. diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md similarity index 70% rename from .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md rename to .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 5752247b08..fb0f8d3031 100644 --- a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -1,12 +1,12 @@ # Agent Note: Web 多模态图片输入与持久附件 -Status: proposed +Status: implemented [English](2026-07-22-web-multimodal-image-input-and-durable-attachments.md) | 中文 ## 问题 -Web 输入区目前仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 +在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 @@ -14,19 +14,19 @@ Web 输入区目前仅接受文本:`InputBar` 接收字符串草稿,`Convers 同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 -## 提案 +## 决策 -把粘贴或拖放的光栅图片作为持久附件能力的首个消费方,接入 Web 输入区。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 +粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 -第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单分别作为后续工作。 +第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 ### 产品行为 - 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。 - 空状态输入区与常驻输入区共用附件栏;附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 - 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 -- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴处理器只有接受图片文件后才阻止默认行为。无论文件是否受支持,在输入区放置文件时都会阻止浏览器导航;系统会接受受支持的图片,并在本地提示哪些文件不受支持。 -- 发送失败时恢复完整的文本与图片草稿。移除、发送成功和会话作用域释放都会撤销过期的对象 URL。 +- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。 +- 发送失败时恢复完整的文本与图片草稿。移除、发送成功、空状态释放、已渲染会话释放和应用释放都会撤销各自持有的对象 URL。 - 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 - 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 - 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 @@ -54,6 +54,7 @@ interface ChatStoreState { } interface ComposerAttachment { + kind: 'image' id: string file: File previewUrl: string @@ -64,7 +65,7 @@ interface ComposerAttachment { 本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 -第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。 +第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 ### 持久内容与提示词协议 @@ -90,7 +91,7 @@ interface ImageBlock { } ``` -`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此,会话事件与不可变对象存储足以共同重建模型可见的确切图片。 +`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此,会话事件与不可变对象存储足以共同重建模型可见的确切图片。LLM 词汇由此仅在类型层面依赖附件服务边界;提供方运行时依赖仍由各适配器持有。 浏览器无法生成持久引用,因此 `session.prompt` 接受范围狭窄的接收联合类型,而不是规范 `ContentBlock[]`: @@ -109,29 +110,31 @@ type PromptInputPart = Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 -`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。客户端在服务生命周期内,以会话和附件标识符为键缓存生成的对象 URL,并在释放时撤销它。 +`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 ### 模型能力与提供方行为 模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。如果所选模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。在所有 Web 入口路径都能一致公开模型选择后,可以在 UI 中增加粘贴时立即拒绝的反馈。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把默认模型和图片限制投影到 `SessionsService`;两种输入区都会在分配对象 URL 或 base64 前使用这些限制,只有无会话输入区会使用默认模型,针对明确仅支持文本的情况提前反馈。解码像素校验与每个常驻会话的实际路由均由宿主作出权威判定。 -Pi-AI 适配器是首条视觉输入路径:它通过 `ctx.attachments` 解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为文本计数。提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 +压缩(compaction)会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 + ### 历史渲染与原图预览 -历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片在叙述流中靠前端对齐。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。 +历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片则保留在靠前端叙述流中的原内容块位置。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。 输入区缩略图和每个 `MessageImage` 各自持有临时原图预览状态,并调用同一个纯 `ImageLightbox`。模态框使用已经解析的原始对象 URL,只限制显示尺寸;它会聚焦关闭控件,并在关闭时把焦点恢复到先前的目标。 ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。Web 载体会独立限制 API 请求体的缓冲大小;`dsh web` 根据图片总量限制加上 base64 和请求封装的膨胀量推导默认值,并允许显式覆盖。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 @@ -144,19 +147,18 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 | `packages/llm/llm` 和 `packages/llm/token-meter` | 角色无关的 `ImageBlock`、模态元数据和图片成本估算。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | | `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/compact/compact-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/host/runtime` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查。 | +| `packages/host/webserver` | 在 fetch 载体之前限制 API 请求体的缓冲大小。 | | `packages/client/connection` 和 `packages/client/runtime` | 协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | | `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 | | `packages/ui/acp` | 图片块的明确兜底渲染。 | 附件包(package)构成一个能力服务边界的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。 -### 交付 +### 实现 -1. 交付附件服务边界、角色无关的图片块、图片感知的 token 估算、Pi-AI 输入转换、DeepSeek 拒绝和宿主侧的持久化顺序。 -2. 交付 Web 上传与读取协议、内存草稿图片、支持粘贴与拖放的附件栏、用户与助手历史图片渲染、双击预览,以及组装后无需密钥的 Web 覆盖。 -3. 在输入区能够一致获取当前模型选择后,增加接收图片时立即提供的能力反馈。 -4. 分别为文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、输出提供方认证和按引用感知的垃圾回收提出方案。 +已实现的范围包括附件服务边界、角色无关的图片块、图片感知的 token 估算、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、宿主能力投影、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -186,20 +188,16 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项,UI 检查则是可选的提前反馈。 -## 验收标准 +## 测试 -- 在两种输入区中粘贴或拖放一张或多张受支持的图片时,文本框上方会按顺序显示可移除缩略图,且不更改文本框内容;拖入时会高亮目标,放置不支持的文件也不会触发页面跳转,仅图片的发送可正常工作。 -- 未发送的浏览器图片仅以 `File` 与对象 URL 的形式存在,可以在内存中跨会话切换保留,不进入 `localStorage`,并在移除、发送被接受或服务释放后撤销。 -- 每张已接受的用户图片都会提交到解析所得 `DSH_HOME` 下,之后才会追加相应的 `user/message` 事件。事件只包含 `ImageBlock` 引用,绝不包含 base64 或临时路径。 -- 结构化助手图片只能由持久 `ImageBlock` 表示;未来的输出适配器必须在发出助手事件前持久化字节,而 Markdown 图片 URL 仍是文本。 -- 冷启动历史记录通过同一个有界控件渲染用户与助手图片引用。双击打开原图;Escape、激活背景区域和激活关闭控件可以关闭预览,且不提供自定义上下文菜单。 -- 除非同一个会话日志引用了该标识符,否则会话附件读取会失败。对象缺失或损坏会明确失败,绝不返回未经校验的字节。 -- Pi-AI 会为兼容路径生成提供方原生输入图片。DeepSeek 与所有未实现该能力的消费方返回明确的不支持内容错误,而不是丢弃该块。 -- 明确仅支持文本的当前模型会在持久化附件或追加会话事件前拒绝图片发送;未知元数据仍会到达适配器强制检查,发送失败则恢复草稿。 -- 无需密钥的单元测试、宿主集成测试、客户端集成测试和组装应用的 Chromium 覆盖会验证持久化顺序、日志中不含 base64、授权、粘贴与拖放、仅图片发送、历史用户与助手图片、原图预览和对象 URL 清理。 -- 当前生产适配器集合声明仅支持文本输出;输出提供方认证、文件选择、非图片文件、视频、持久草稿和垃圾回收不在第一版范围内。 +- 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制,以及大小受限的 HTTP 请求体。 +- 客户端单元测试和组装后的 Chromium 测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、历史用户与助手图片、原图预览、顺序,以及草稿、会话和应用层级的对象 URL 清理。 +- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 +- 当前生产适配器集合声明仅支持文本输出;输出提供方认证不在第一版范围内。 -## 风险 +## 后果 - 持久存储会在没有垃圾回收时持续增长。第一版选择回放安全,而不是过早删除。 - 对象缺失或损坏会让模型请求无法精确重建。明确失败可以保持完整性,但在修复前可能阻止该会话继续运行。 @@ -208,3 +206,4 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 - 原图预览解码的像素多于行内控件显示的像素。像素限制、一次只打开一个预览和对象 URL 释放可以约束但无法消除浏览器瞬时内存占用。 - 能力元数据可能缺失或陈旧。宿主前置检查可以改善反馈,适配器强制检查仍是权威结果。 - 未来输出提供方可能需要经过身份认证的下载,助手图片才能完成,这会增加延迟与新的故障点。先持久化再追加事件的顺序优先保障回放完整性。 +- 文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、图片复制、自定义上下文菜单、输出提供方认证和按引用感知的垃圾回收仍是相互独立的设计。 diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 02e98e78b5..99150d4c1d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -12,6 +12,7 @@ import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-ho const LOOPBACK_HOST = '127.0.0.1' const ALL_INTERFACES_HOST = '0.0.0.0' +const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024 export async function runWeb(argv: string[]): Promise { const { values } = parseArgs({ @@ -19,6 +20,7 @@ export async function runWeb(argv: string[]): Promise { options: { host: { type: 'string', default: LOOPBACK_HOST }, port: { type: 'string', default: '3080' }, + 'max-request-body-bytes': { type: 'string' }, }, allowPositionals: false, }) @@ -34,9 +36,21 @@ export async function runWeb(argv: string[]): Promise { process.stderr.write(`dsh web: invalid --port ${values.port}\n`) process.exit(1) } + const configuredMaxRequestBodyBytes = values['max-request-body-bytes'] === undefined + ? undefined + : Number(values['max-request-body-bytes']) + if (configuredMaxRequestBodyBytes !== undefined + && (!Number.isInteger(configuredMaxRequestBodyBytes) || configuredMaxRequestBodyBytes < 1)) { + process.stderr.write(`dsh web: invalid --max-request-body-bytes ${values['max-request-body-bytes']}\n`) + process.exit(1) + } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const attachments = host.ctx.get('attachments') + if (attachments === undefined) throw new Error('dsh web: attachment service unavailable') + const maxRequestBodyBytes = configuredMaxRequestBodyBytes + ?? Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3) + REQUEST_ENVELOPE_HEADROOM_BYTES // Web UI plugin chain: in-memory Loader tree over the eight UI packages, // then the registry that feeds __DSH_BOOT__ and /plugins//client.js. @@ -78,7 +92,7 @@ export async function runWeb(argv: string[]): Promise { let server: Awaited> try { server = await startWebServer( - { host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins }, + { host: hostAddress, port, distIndex, apiHandler: host.handler, maxRequestBodyBytes, webPlugins }, (err: Error) => { process.stderr.write(`dsh web: ${String(err)}\n`) void shutdown(1) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0e4b21d184..b0e56c9ea2 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -50,6 +50,7 @@ describe('web boot chain (keyless, real carrier)', () => { port, distIndex: DIST_INDEX, apiHandler, + maxRequestBodyBytes: 32 * 1024 * 1024, webPlugins: { snapshot: () => ROWS, clientPath: id => (id === ROWS[0]!.id ? LIVE_BUNDLE : undefined), @@ -108,6 +109,7 @@ describe('web boot chain success pass (keyless, production plugin chain, ?fixtur port, distIndex: DIST_INDEX, apiHandler, + maxRequestBodyBytes: 32 * 1024 * 1024, webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) }, }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e3c52e2536..7d9a156757 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 6ff2aa1ad4ca2ef051322f9d95631fe626d26e84 -architecture.zh.md: b4b26efec16d85f1fb26589c5c9bffbb35e39564 +architecture.md: c0cfb1bd78aa55596e04bcfd750b67e15c84094e +architecture.zh.md: 23156cfd84d0b1e6df86102874fc3f4aaa021fbc diff --git a/docs/architecture.md b/docs/architecture.md index 6ff2aa1ad4..c0cfb1bd78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -143,7 +143,7 @@ Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, promp The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events remain for replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from the same stream. -**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ durably recorded**: the log reconstructs request envelopes from `step/start`, the header's session prefix, and folded `request/header` events; logged attachment references resolve through integrity-checked immutable objects. `dsh-agent-loop/invariant` asserts envelope reconstruction through `ctx.invariants`; attachment backends assert byte integrity ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b4b26efec1..23156cfd84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -143,7 +143,7 @@ forever: 会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件留在日志中,以保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自同一个事件流。 -**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 +**模型可见 ⟺ 已持久记录**:日志根据 `step/start`、请求头的会话前缀和折叠后的 `request/header` 事件重建请求封装;日志中记录的附件引用通过经过完整性校验的不可变对象解析。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言请求封装重建;附件后端断言字节完整性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 21883bc9ff..3e867695d6 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-attachment-local -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, and an atomic exclusive hard-link publish; reads re-check the digest, media signature, dimensions, and logged metadata. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Writes use a private staging directory, owner-only files, a synced temporary file, and an atomic exclusive hard-link publish; reads re-check the digest, media signature, dimensions, and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index 59ddb4ebda..4e7ae5aabb 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -11,31 +11,31 @@ export interface DetectedImage { } function ascii(data: Uint8Array, start: number, value: string): boolean { + /* v8 ignore next -- Every call site establishes the fixed header span before comparing it. */ if (data.length < start + value.length) return false for (let i = 0; i < value.length; i++) if (data[start + i] !== value.charCodeAt(i)) return false return true } function u16be(data: Uint8Array, offset: number): number { - return ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0) + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset) } function u16le(data: Uint8Array, offset: number): number { - return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset, true) } function u24le(data: Uint8Array, offset: number): number { - return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) | ((data[offset + 2] ?? 0) << 16) + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + return view.getUint8(offset) | (view.getUint8(offset + 1) << 8) | (view.getUint8(offset + 2) << 16) } function u32be(data: Uint8Array, offset: number): number { - return (((data[offset] ?? 0) * 0x1000000) + ((data[offset + 1] ?? 0) << 16) - + ((data[offset + 2] ?? 0) << 8) + (data[offset + 3] ?? 0)) >>> 0 + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset) } function u32le(data: Uint8Array, offset: number): number { - return ((data[offset] ?? 0) + ((data[offset + 1] ?? 0) << 8) - + ((data[offset + 2] ?? 0) << 16) + ((data[offset + 3] ?? 0) * 0x1000000)) >>> 0 + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, true) } function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage { @@ -86,10 +86,11 @@ export function detectImage(data: Uint8Array): DetectedImage { if (declaredLength > data.length) throw new AttachmentError('WebP data is truncated.', 'INVALID_IMAGE') if (ascii(data, 12, 'VP8X')) return dimensions(u24le(data, 24) + 1, u24le(data, 27) + 1, 'image/webp') if (ascii(data, 12, 'VP8L') && data[20] === 0x2f) { - const b0 = data[21] ?? 0 - const b1 = data[22] ?? 0 - const b2 = data[23] ?? 0 - const b3 = data[24] ?? 0 + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) + const b0 = view.getUint8(21) + const b1 = view.getUint8(22) + const b2 = view.getUint8(23) + const b3 = view.getUint8(24) return dimensions(1 + b0 + ((b1 & 0x3f) << 8), 1 + (b1 >> 6) + (b2 << 2) + ((b3 & 0x0f) << 10), 'image/webp') } if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 1c8e923867..acb06f16c9 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -67,7 +67,7 @@ export class LocalAttachmentStore extends AttachmentStore { } async readImage(ref: ImageAttachmentRef): Promise { - return readImageFile(this.root, ref, this.imageLimits) + return readImageFile(this.root, ref) } } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 24fa3645be..22e2ca2309 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -38,15 +38,20 @@ function ensureReference(ref: ImageAttachmentRef): string { return match[1] } -function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits): Omit { +function inspectMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType']): Omit { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') - if (data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') const detected = detectImage(data) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') - if (detected.width * detected.height > limits.maxImagePixels) throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') return { ...detected, bytes: data.byteLength } } +function validateAdmission(metadata: Omit, limits: ImageAttachmentLimits): void { + if (metadata.bytes > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + if (metadata.width * metadata.height > limits.maxImagePixels) { + throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') + } +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -55,7 +60,8 @@ function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRe * @returns durable content-addressed reference. */ export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { - const metadata = validateMetadata(input.data, input.mediaType, limits) + const metadata = inspectMetadata(input.data, input.mediaType) + validateAdmission(metadata, limits) const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') @@ -75,16 +81,25 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li try { await link(temporary, target) } catch (error) { + /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } await unlink(temporary) } catch (error) { - if (handle !== undefined) await handle.close().catch(() => { /* close failure is superseded by the storage failure */ }) - await unlink(temporary).catch((cleanupError: unknown) => { - if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError - }) + /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ + if (handle !== undefined) await handle.close().catch( + /* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */ + () => {}, + ) + await unlink(temporary).catch( + /* v8 ignore next -- The callback requires a second independent staging-unlink failure. */ + (cleanupError: unknown) => { + /* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */ + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError + }, + ) if (error instanceof AttachmentError) throw error throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } @@ -100,10 +115,9 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li * Read and verify one content-addressed image. * @param root - absolute `DSH_HOME/attachments/v1` root. * @param ref - reference recorded in the session log. - * @param limits - resolved storage policy. * @returns verified bytes and reference. */ -export async function readImageFile(root: string, ref: ImageAttachmentRef, limits: ImageAttachmentLimits): Promise { +export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise { const sha256 = ensureReference(ref) let data: Uint8Array try { @@ -113,7 +127,7 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef, limit throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) } if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') - const metadata = validateMetadata(data, ref.mediaType, limits) + const metadata = inspectMetadata(data, ref.mediaType) if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) { throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') } diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts new file mode 100644 index 0000000000..04cbe984fd --- /dev/null +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { detectImage } from '../src/image.ts' + +function bytes(text: string): number[] { + return [...Buffer.from(text, 'ascii')] +} + +function webp(chunk: string, mutate: (data: Uint8Array) => void): Uint8Array { + const data = new Uint8Array(30) + data.set(bytes('RIFF'), 0) + data.set([22, 0, 0, 0], 4) + data.set(bytes('WEBP'), 8) + data.set(bytes(chunk), 12) + mutate(data) + return data +} + +describe('raster header detection', () => { + it('detects PNG dimensions', () => { + const data = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + expect(detectImage(data)).toEqual({ mediaType: 'image/png', width: 1, height: 1 }) + }) + + it('detects both GIF revisions and rejects zero dimensions', () => { + expect(detectImage(Uint8Array.from([...bytes('GIF87a'), 3, 0, 2, 0]))) + .toEqual({ mediaType: 'image/gif', width: 3, height: 2 }) + expect(detectImage(Uint8Array.from([...bytes('GIF89a'), 4, 0, 5, 0]))) + .toEqual({ mediaType: 'image/gif', width: 4, height: 5 }) + expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 0, 0, 1, 0]))) + .toThrow(/positive/) + expect(() => detectImage(Uint8Array.from([...bytes('GIF89a'), 1, 0, 0, 0]))) + .toThrow(/positive/) + }) + + it('walks JPEG marker forms and reports malformed dimensions', () => { + const sof = [0xff, 0xc0, 0, 7, 8, 0, 2, 0, 3] + expect(detectImage(Uint8Array.from([0xff, 0xd8, ...sof]))) + .toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 }) + expect(detectImage(Uint8Array.from([ + 0xff, 0xd8, + 0xe0, 0, 2, + 0x01, + 0xff, ...sof, + ]))).toEqual({ mediaType: 'image/jpeg', width: 3, height: 2 }) + + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xd9, 0, 0, 0]))) + .toThrow(/missing/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xff, 0xff, 0xff, 0xff]))) + .toThrow(/missing/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 1, 0]))) + .toThrow(/truncated/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xe0, 0, 9, 0]))) + .toThrow(/truncated/) + expect(() => detectImage(Uint8Array.from([0xff, 0xd8, 0xc0, 0, 6, 0, 0, 0, 0]))) + .toThrow(/dimensions are truncated/) + }) + + it('detects each WebP header and rejects truncated or unknown chunks', () => { + expect(detectImage(webp('VP8X', (data) => { + data.set([2, 0, 0], 24) + data.set([3, 0, 0], 27) + }))).toEqual({ mediaType: 'image/webp', width: 3, height: 4 }) + + expect(detectImage(webp('VP8L', (data) => { + data[20] = 0x2f + data.set([2, 0, 1, 0], 21) + }))).toEqual({ mediaType: 'image/webp', width: 3, height: 5 }) + + expect(detectImage(webp('VP8 ', (data) => { + data.set([0x9d, 0x01, 0x2a], 23) + data.set([6, 0, 7, 0], 26) + }))).toEqual({ mediaType: 'image/webp', width: 6, height: 7 }) + + const truncated = webp('VP8X', () => {}) + truncated[4] = 23 + expect(() => detectImage(truncated)).toThrow(/truncated/) + expect(() => detectImage(webp('NOPE', () => {}))).toThrow(/dimensions are missing/) + expect(() => detectImage(webp('VP8L', () => {}))).toThrow(/dimensions are missing/) + expect(() => detectImage(webp('VP8 ', () => {}))).toThrow(/dimensions are missing/) + }) + + it('rejects unrecognized bytes and near-miss signatures', () => { + expect(() => detectImage(new Uint8Array(0))).toThrow(/Unsupported/) + expect(() => detectImage(Uint8Array.from([...bytes('GIFxxa'), 1, 0, 1, 0]))) + .toThrow(/Unsupported/) + const nearWebp = webp('VP8X', () => {}) + nearWebp[8] = 0 + expect(() => detectImage(nearWebp)).toThrow(/Unsupported/) + }) +}) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts new file mode 100644 index 0000000000..b21b0d544d --- /dev/null +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -0,0 +1,39 @@ +import { Context } from 'cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import LocalAttachmentStore, { + DEFAULT_MAX_IMAGE_BYTES, + DEFAULT_MAX_IMAGE_PIXELS, + DEFAULT_MAX_IMAGES_PER_MESSAGE, + DEFAULT_MAX_MESSAGE_IMAGE_BYTES, +} from '../src/index.ts' + +describe('local attachment service', () => { + it('resolves every omitted admission limit explicitly', () => { + const service = new LocalAttachmentStore(new Context(), {}) + expect(service.imageLimits).toEqual({ + maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES, + maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], + }) + }) + + it('saves and reads through the service boundary', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const data = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + const ref = await service.saveImage({ data, mediaType: 'image/png' }) + await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 9fff7d651e..d7ee6de31b 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -54,11 +54,21 @@ describe('local attachment store', () => { expect(new Uint8Array(await readFile(object))).toEqual(PNG) expect((await stat(object)).mode & 0o777).toBe(0o600) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) - await expect(readImageFile(storageRoot, first, LIMITS)).resolves.toEqual({ ref: first, data: PNG }) + await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) + }) + + it('keeps admitted history readable after deployment limits become stricter', async () => { + const storageRoot = await root() + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => { const storageRoot = await root() + await expect(saveImageFile(storageRoot, { + data: new Uint8Array(0), mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) await expect(saveImageFile(storageRoot, { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) @@ -74,6 +84,10 @@ describe('local attachment store', () => { await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + const unnamed = await saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', name: '\u0000', + }, LIMITS) + expect(unnamed).not.toHaveProperty('name') }) it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { @@ -83,14 +97,45 @@ describe('local attachment store', () => { const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await chmod(object, 0o600) await writeFile(object, Uint8Array.of(1, 2, 3)) - await expect(readImageFile(storageRoot, ref, LIMITS)) + await expect(readImageFile(storageRoot, ref)) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) - await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }, LIMITS)) + await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never })) .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) const missingRoot = await root() await mkdir(missingRoot, { recursive: true }) - await expect(readImageFile(missingRoot, ref, LIMITS)) + await expect(readImageFile(missingRoot, ref)) .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + + const unreadableRoot = await root() + const target = join(unreadableRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(target, { recursive: true }) + await expect(readImageFile(unreadableRoot, ref)) + .rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' }) + }) + + it('rejects conflicting existing objects and reference metadata mismatches', async () => { + const storageRoot = await root() + const sha256 = createHash('sha256').update(PNG).digest('hex') + const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true }) + await writeFile(target, Uint8Array.of(1, 2, 3)) + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + + await writeFile(target, PNG) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('maps unexpected publication failures to a stable storage error', async () => { + const storageRoot = await root() + const sha256 = createHash('sha256').update(PNG).digest('hex') + const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(target, { recursive: true }) + + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) }) }) diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index dc9fbb85b8..3b56e01fa4 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-connection -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## Model Experience diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index cf4c9c2ac3..78bf140642 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,6 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ResponseValue, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { @@ -20,6 +21,9 @@ export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' +/** Successful value returned by the connection-generation host handshake. */ +export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'> + import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' /** diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 6eb6491e2f..21b30e25f9 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,4 @@ -import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' +import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists * these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */ @@ -44,6 +44,8 @@ export type ConnectionState = 'connected' | 'reconnecting' export interface ConnectionSinks { onMuxEnvelope?: (envelope: RpcRequest) => void onHostEnvelope?: (envelope: RpcRequest) => void + /** Latest successful host capability snapshot for this connection generation. */ + onDescription?: (description: HostDescription) => void /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ onConnected?: () => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect @@ -131,13 +133,18 @@ export class ConnectionController { // subscribed baseline. The timeout guards against a carrier that never fires onOpen // (see ConnectionConfig.streamOpenTimeoutMs). const timeout = new AbortController() - await Promise.all([ + const [description] = await Promise.all([ this.api.host.describe({}), Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), ]) timeout.abort() + const descriptionResult = description.result + if (!descriptionResult.ok) { + throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) + } if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 + this.callSink(() => { this.sinks.onDescription?.(descriptionResult.value) }) this.emitState('connected') this.callSink(this.sinks.onConnected) } catch { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index fd6c5c3345..b2c5c449fc 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -19,7 +19,7 @@ export type { ToolCallView, ToolResultView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, - IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, + HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts' diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 4de4a31f25..9bb4965838 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -23,9 +23,11 @@ describe('connection lifecycle', () => { it('announces connected after describe + both streams open, then pumps frames to sinks', async () => { const api = new FakeApiClient() const muxSeen: string[] = [] + const descriptions: string[] = [] let connected = 0 const controller = new ConnectionController(api, { onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), + onDescription: description => descriptions.push(description.version), onConnected: () => { connected++ }, }, FAST) controller.start() @@ -34,6 +36,7 @@ describe('connection lifecycle', () => { api.pushMux(subscribedFrame()) await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) }) expect(api.callsOf('host.describe')).toHaveLength(1) + expect(descriptions).toEqual(['0-fake']) } finally { controller.stop() } @@ -83,6 +86,35 @@ describe('connection lifecycle', () => { } }) + it('treats a host.describe business error as generation failure', async () => { + const api = new FakeApiClient() + let describeCalls = 0 + api.onDescribe = () => { + describeCalls += 1 + if (describeCalls === 1) { + return Promise.resolve({ + rpcId: 'bad-describe' as never, + result: { + ok: false as const, + error: { code: 'internal' as const, message: 'not ready', details: {} }, + }, + }) + } + return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) + } + let connected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(describeCalls).toBe(2) }) + await vi.waitFor(() => { expect(connected).toBe(1) }) + } finally { + controller.stop() + warnSpy.mockRestore() + } + }) + it('converges stream/error frames into reconnect instead of dispatching them', async () => { const api = new FakeApiClient() const muxSeen: string[] = [] diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 6b81f4ef95..e115a381a1 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -207,6 +207,31 @@ describe('createFixtureApi', () => { }) }) + it('accounts for every base64 padding form and reports a missing fixture attachment', async () => { + const api = createFixtureApi() + const created = await api.sessions.create(req({})) + if (!created.result.ok) throw new Error('create failed') + const sessionId = created.result.value.sessionId + const prompted = await api.sessions.prompt(req({ + sessionId, + mode: 'queue' as const, + content: ['YQ==', 'YWI=', 'YWJj'].map(data => ({ + type: 'image' as const, + mediaType: 'image/png' as const, + data, + })), + })) + expect(prompted.result.ok).toBe(true) + const missing = await api.sessions.attachment(req({ + sessionId, + attachmentId: 'fixture:missing' as never, + })) + expect(missing.result).toMatchObject({ + ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } }, + }) + await api.sessions.cancel(req({ sessionId })) + }) + it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => { vi.useFakeTimers() try { diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 66eb5b22ac..b5ae63f7e7 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry, and the latest successful host capability description), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. ## Model Experience diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 77b51a68b5..833d6dd6c9 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -148,6 +148,7 @@ export function apply(ctx: Context): void { const loop = connection.start({ onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) }, onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) }, + onDescription: (description) => { sessions.handleDescription(description) }, onConnected: () => { sessions.manager.handleConnected() }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec63b8f70d..7dbc6e8e82 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -14,7 +14,7 @@ * read-only view). */ import type { Context, Fiber } from 'cordis' -import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { HostDescription, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -101,6 +101,7 @@ export class SessionsService { private watched: SessionId | undefined /** Removed-while-watched sessions whose teardown waits for the watch to move away. */ private readonly deferredRemovals = new Set() + private description: HostDescription | undefined /** * @param ctx - client root context (scope fibers mount under it). @@ -118,6 +119,22 @@ export class SessionsService { rootCtx.reflect.provide('sessions', this, undefined) } + /** + * Store the latest successful connection-generation host description. + * @param description - capability and deployment snapshot from `host.describe`. + */ + handleDescription(description: HostDescription): void { + this.description = description + } + + /** + * Read the latest host capability snapshot. + * @returns the last successful description, or undefined before connection. + */ + hostDescription(): HostDescription | undefined { + return this.description + } + /** * Select a session as current. Unknown ids fail loud instead of navigating * nowhere (the sole selection write path). diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 0baa2e8237..7a3e72ba6d 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -53,6 +53,8 @@ describe('runtime client apply', () => { expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) + bench.sinks?.onDescription?.({ version: '0', cwd: '/f', attachedSessions: 0 }) + expect(sessions?.hostDescription()).toEqual({ version: '0', cwd: '/f', attachedSessions: 0 }) bench.sinks?.onConnected?.() }) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bca5ee9a37..83dd2e6b88 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,6 +4,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain). +Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input. + `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8087c42ebb..3159ac6802 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -94,15 +94,21 @@ export function apply(ctx: Context): void { subscribe: fn => conversation.subscribeViews(fn), version: () => conversation.viewsVersion(), }, - addImages: (files) => { - const images = conversation.createDraftImages(files) - actions.addImages(images.map(image => image.id)) + addImages: (files, current) => { + try { + const images = conversation.createDraftImages(files, current) + actions.addImages(images.map(image => image.id)) + return null + } catch (error: unknown) { + return error instanceof Error ? error.message : String(error) + } }, removeImage: (id) => { conversation.releaseDraftImage(id) actions.removeImage(id) }, draftImages: ids => conversation.draftImages(ids), + releaseSessionImages: (id) => { conversation.releaseSessionImages(id) }, send: (text, images: readonly ComposerAttachment[], mode) => { const trimmed = text.trim() if (trimmed === '' && images.length === 0) return @@ -140,6 +146,9 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.empty', inject: (): EmptyStateInjected => ({ + createDraftImages: (files, current) => conversation.createDraftImages(files, current, true), + releaseDraftImage: (id) => { conversation.releaseDraftImage(id) }, + releaseDraftImages: (attachments) => { conversation.releaseDraftImages(attachments) }, startSession: opts => conversation.startSession(opts), }), }, EmptyState) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index cc2875add8..feb8efcdd0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -40,15 +40,13 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, loadImage = unavailableImage }: AssistantMarkdownProps) { const last = blocks.length - 1 - const images = blocks.filter((block): block is Extract => block.kind === 'image') return (
- {blocks.map((block, i) => { switch (block.kind) { case 'text': return case 'reasoning': return - case 'image': return null + case 'image': return // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null default: return diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 80cb6f0b3f..a88a3b1d4d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -14,6 +14,7 @@ import type { SelectionTarget, ViewEntry } from './views.ts' /** Browser-owned image that has not crossed the durable host boundary. */ export interface ComposerAttachment { + kind: 'image' id: string file: File previewUrl: string @@ -37,11 +38,13 @@ export interface ConversationInjected { version(): number } /** Create browser previews and append their ids through the declared store action. */ - addImages(files: readonly File[]): void + addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null /** Release one browser preview and remove its id through the declared store action. */ removeImage(id: string): void /** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */ draftImages(ids: readonly string[]): readonly ComposerAttachment[] + /** Release historical image URLs when this rendered session scope unmounts. */ + releaseSessionImages(sessionId: SessionId): void /** Send choreography: trims, clears the draft optimistically, restores it on failure. */ send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ @@ -72,6 +75,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & /** Injected share of the no-session empty-state slot. */ export interface EmptyStateInjected { + /** Create service-owned image previews after host-capability preflight. */ + createDraftImages(files: readonly File[], current: readonly ComposerAttachment[]): readonly ComposerAttachment[] + /** Release one service-owned image preview. */ + releaseDraftImage(id: string): void + /** Release all service-owned image previews held by the empty state. */ + releaseDraftImages(attachments: readonly ComposerAttachment[]): void /** The create → navigate → first-send chain, in one service call. */ startSession(opts: { cwd?: string diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9fc167320c..7b11e48723 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -29,6 +29,7 @@ import type { ComposerAttachment } from './contract/slots.ts' /** Opaque wrapper keeps browser `File` internals outside persisted store state. */ class BrowserDraftAttachment implements ComposerAttachment { + readonly kind = 'image' as const readonly id: string readonly previewUrl: string readonly #file: File @@ -53,10 +54,17 @@ interface ViewsState { listeners: Set<() => void> } +interface ImageUrlEntry { + readonly sessionId: SessionId + readonly generation: number + readonly pending: Promise +} + /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service { private readonly draftAttachments = new Map() - private readonly imageUrls = new Map>() + private readonly imageUrls = new Map() + private readonly imageGenerations = new Map() private readonly createdImageUrls = new Set() private readonly viewsState: ViewsState = { entries: new Map(), cache: null, tick: 0, listeners: new Set(), @@ -73,6 +81,7 @@ export class ConversationService extends Service { this.createdImageUrls.clear() this.draftAttachments.clear() this.imageUrls.clear() + this.imageGenerations.clear() }, 'conversation attachment URL cache') } @@ -86,6 +95,7 @@ export class ConversationService extends Service { */ async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { const session = this.scopedSession('send') + this.validateImages(images, []) const uploaded = await Promise.all(images.map(async file => ({ type: 'image' as const, mediaType: imageMediaType(file.type), @@ -100,9 +110,16 @@ export class ConversationService extends Service { /** * Create runtime-only draft attachments and their object URLs. * @param files - browser-owned image files. + * @param current - images already present in the same composer. + * @param checkDefaultModel - whether to apply `host.describe`'s default-model capability, used only before a session exists. * @returns ordered attachment descriptors whose ids may enter the chat store. */ - createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { + createDraftImages( + files: readonly File[], + current: readonly ComposerAttachment[] = [], + checkDefaultModel = false, + ): readonly ComposerAttachment[] { + this.validateImages(files, current, checkDefaultModel) return files.map((file) => { const attachment = new BrowserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) @@ -154,7 +171,8 @@ export class ConversationService extends Service { resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { const key = `${sessionId}:${attachment.attachmentId}` const cached = this.imageUrls.get(key) - if (cached !== undefined) return cached + if (cached !== undefined) return cached.pending + const generation = this.imageGenerations.get(sessionId) ?? 0 const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) @@ -163,17 +181,39 @@ export class ConversationService extends Service { } const bytes = Uint8Array.from(result.value.data) const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { + revokePreview(url) + throw new Error('historical image scope was released before loading completed') + } this.createdImageUrls.add(url) return url }) .catch((error: unknown) => { - this.imageUrls.delete(key) + if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key) throw error }) - this.imageUrls.set(key, pending) + this.imageUrls.set(key, { sessionId, generation, pending }) return pending } + /** + * Release every historical image URL owned by one rendered session. + * @param sessionId - session whose rendered image scope is ending. + */ + releaseSessionImages(sessionId: SessionId): void { + this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1) + for (const [key, entry] of this.imageUrls) { + if (entry.sessionId !== sessionId) continue + this.imageUrls.delete(key) + void entry.pending.then((url) => { + if (!this.createdImageUrls.delete(url)) return + revokePreview(url) + }, () => { + // A failed or generation-invalidated load owns no cached object URL. + }) + } + } + /** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */ async cancel(): Promise { const session = this.scopedSession('cancel') @@ -284,6 +324,38 @@ export class ConversationService extends Service { if (sessions === undefined) throw new Error('conversation: sessions service unavailable') return sessions } + + /** Apply host-advertised fast-path checks before any object URL or base64 allocation. */ + private validateImages( + files: readonly File[], + current: readonly ComposerAttachment[], + checkDefaultModel = false, + ): void { + const description = this.requireSessions().hostDescription() + const modalities = description?.activeModel?.inputModalities + if (checkDefaultModel && modalities !== undefined && !modalities.includes('image')) { + throw new Error('当前模型不支持图片输入') + } + const limits = description?.imageLimits + const all = [...current.map(attachment => attachment.file), ...files] + if (limits !== undefined && all.length > limits.maxImagesPerMessage) { + throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`) + } + let totalBytes = 0 + for (const file of all) { + const mediaType = imageMediaType(file.type) + if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) { + throw new Error(`当前部署不支持 ${mediaType} 图片`) + } + if (limits !== undefined && file.size > limits.maxImageBytes) { + throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) + } + totalBytes += file.size + } + if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { + throw new Error('图片总大小超过单条消息限制') + } + } } function bumpViews(state: ViewsState): void { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index d018d93457..b94d17758d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -36,7 +36,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationRoot({ sessionId, useSession, useSessions, useStore, actions, - views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open, + views, addImages, removeImage, draftImages, releaseSessionImages, + send, stop, openDetails, loadOlder, open, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const list = views.list() @@ -63,6 +64,10 @@ export function ConversationRoot({ } }, [actions, attachments, imageIds]) + useEffect(() => () => { + releaseSessionImages(sessionId) + }, [releaseSessionImages, sessionId]) + const error: InputBarError | null = promptError === null ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } @@ -145,7 +150,7 @@ export function ConversationRoot({ error={error} variant="composer" onDraftChange={actions.setDraft} - onAddImages={addImages} + onAddImages={files => addImages(files, attachments)} onRemoveAttachment={removeImage} onSend={(mode) => { send(draft, attachments, mode) }} onStop={stop} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index e837154d2a..c801369e95 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -30,7 +30,13 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } -export function EmptyState({ useSessions, startSession }: EmptyStateProps) { +export function EmptyState({ + useSessions, + createDraftImages, + releaseDraftImage, + releaseDraftImages, + startSession, +}: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is @@ -67,21 +73,22 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { } useEffect(() => () => { - for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl) - }, []) + releaseDraftImages(attachmentsRef.current) + }, [releaseDraftImages]) - const addImages = (files: readonly File[]): void => { - setAttachments(current => [...current, ...files.map(file => ({ - id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file), - }))]) + const addImages = (files: readonly File[]): string | null => { + try { + const added = createDraftImages(files, attachments) + setAttachments(current => [...current, ...added]) + return null + } catch (reason: unknown) { + return reason instanceof Error ? reason.message : String(reason) + } } const removeImage = (id: string): void => { - setAttachments((current) => { - const removed = current.find(item => item.id === id) - if (removed !== undefined) URL.revokeObjectURL(removed.previewUrl) - return current.filter(item => item.id !== id) - }) + releaseDraftImage(id) + setAttachments(current => current.filter(item => item.id !== id)) } const picker = ( diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index eba7c52451..6b87acfb87 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -12,12 +12,6 @@ import type { ComposerAttachment } from '../contract/slots.ts' import { ImageLightbox } from './ImageLightbox.tsx' import css from './InputBar.module.css' -const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']) - -function supportedImages(files: Iterable): File[] { - return [...files].filter(file => IMAGE_MEDIA_TYPES.has(file.type)) -} - /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ export interface InputBarError { op: 'send' | 'stop' @@ -36,7 +30,7 @@ export interface InputBarProps { /** Optional leading accessory row content (the empty state mounts its cwd picker here). */ accessory?: ReactNode onDraftChange: (text: string) => void - onAddImages?: (files: readonly File[]) => void + onAddImages?: (files: readonly File[]) => string | null onRemoveAttachment?: (id: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void @@ -44,7 +38,7 @@ export interface InputBarProps { export function InputBar({ draft, attachments = [], running, disabled, error, variant, placeholder, accessory, - onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop, + onDraftChange, onAddImages = () => null, onRemoveAttachment = () => {}, onSend, onStop, }: InputBarProps) { const empty = draft.trim() === '' && attachments.length === 0 const [preview, setPreview] = useState(null) @@ -90,13 +84,12 @@ export function InputBar({ const onPaste = (event: ClipboardEvent): void => { const files = [...event.clipboardData.items] - .filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type)) + .filter(item => item.kind === 'file') .map(item => item.getAsFile()) .filter((file): file is File => file !== null) if (files.length === 0) return - event.preventDefault() - setDropError(null) - onAddImages(files) + if (event.clipboardData.getData('text/plain') === '') event.preventDefault() + setDropError(onAddImages(files)) } const onDragEnter = (event: DragEvent): void => { @@ -127,13 +120,8 @@ export function InputBar({ setDragActive(false) if (locked) return const dropped = [...event.dataTransfer.files] - const images = supportedImages(dropped) - if (images.length === 0) { - setDropError('暂仅支持 PNG、JPEG、WebP 和 GIF 图片') - return - } - setDropError(images.length === dropped.length ? null : '已忽略不受支持的非图片文件') - onAddImages(images) + if (dropped.length === 0) return + setDropError(onAddImages(dropped)) } const closePreview = useCallback(() => { setPreview(null) }, []) @@ -187,7 +175,10 @@ export function InputBar({ type="button" className={css.remove} aria-label={`移除图片 ${attachment.file.name || ''}`} - onClick={() => { onRemoveAttachment(attachment.id) }} + onClick={() => { + setDropError(null) + onRemoveAttachment(attachment.id) + }} >×
))} @@ -204,7 +195,10 @@ export function InputBar({ disabled={locked} placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')} rows={2} - onChange={(e) => onDraftChange(e.target.value)} + onChange={(e) => { + setDropError(null) + onDraftChange(e.target.value) + }} onKeyDown={onKeyDown} onPaste={onPaste} onCompositionStart={onCompositionStart} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index b766f12afc..182e1436a5 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -74,6 +74,7 @@ async function bench() { manager: { get: () => sessionFake }, scope: (id: SessionId) => mint(id), cell: () => undefined, + hostDescription: () => undefined, create: vi.fn(() => Promise.resolve(ROOT)), open: vi.fn(), } @@ -200,12 +201,17 @@ describe('details and empty inject surfaces', () => { expect(details).toBe(conv) }) - it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => { + it('empty injects draft-image lifecycle and the startSession chain without a store', async () => { const b = await bench() const entry = b.entryOf('conversation.empty') expect(entry.store).toBeUndefined() const injected = (entry.inject as unknown as () => EmptyStateInjected)() - expect(Object.keys(injected)).toEqual(['startSession']) + expect(Object.keys(injected)).toEqual([ + 'createDraftImages', + 'releaseDraftImage', + 'releaseDraftImages', + 'startSession', + ]) await injected.startSession({ text: 'go', mode: 'queue' }) expect(b.sessionsFake.create).toHaveBeenCalled() expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index eb03543781..557baf6c03 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -132,8 +132,9 @@ describe('error strip and variants', () => { describe('image draft rail', () => { it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => { - const onAddImages = vi.fn() - const { textarea } = setup({ draft: '', onAddImages }) + const onAddImages = vi.fn((files: readonly File[]) => + files.some(file => file.type === 'video/mp4') ? '不支持的图片格式:video/mp4' : null) + const { view, textarea } = setup({ draft: '', onAddImages }) const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) const prevented = fireEvent.paste(textarea, { clipboardData: { @@ -141,19 +142,25 @@ describe('image draft rail', () => { { kind: 'string', type: 'text/plain', getAsFile: () => null }, { kind: 'file', type: 'image/png', getAsFile: () => image }, ], + getData: () => '同时粘贴的文字', }, }) - expect(prevented).toBe(false) + expect(prevented).toBe(true) expect(onAddImages).toHaveBeenCalledWith([image]) + const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) fireEvent.paste(textarea, { - clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] }, + clipboardData: { + items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => video }], + getData: () => '', + }, }) - expect(onAddImages).toHaveBeenCalledTimes(1) + expect(onAddImages).toHaveBeenCalledTimes(2) + expect(view.getByText(/不支持的图片格式/)).toBeTruthy() }) it('accepts supported image drops, highlights the target, and prevents browser navigation', () => { - const onAddImages = vi.fn() + const onAddImages = vi.fn(() => null) const { view } = setup({ draft: '', onAddImages }) const card = view.container.querySelector('[class*="card"]')! const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' }) @@ -172,15 +179,16 @@ describe('image draft rail', () => { }) it('ignores unsupported dropped files and refuses drops while locked', () => { - const onAddImages = vi.fn() + const onAddImages = vi.fn((files: readonly File[]) => + files.some(file => file.type === 'text/plain') ? '不支持的图片格式:text/plain' : null) const { view } = setup({ draft: '', onAddImages }) const card = view.container.querySelector('[class*="card"]')! const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' }) fireEvent.drop(card, { dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' }, }) - expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy() - expect(onAddImages).not.toHaveBeenCalled() + expect(view.getByText(/不支持的图片格式/)).toBeTruthy() + expect(onAddImages).toHaveBeenCalledWith([documentFile]) const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' }) const locked = setup({ draft: '', disabled: true, onAddImages }) @@ -191,12 +199,12 @@ describe('image draft rail', () => { fireEvent.dragOver(lockedCard, { dataTransfer }) expect(dataTransfer.dropEffect).toBe('none') fireEvent.drop(lockedCard, { dataTransfer }) - expect(onAddImages).not.toHaveBeenCalled() + expect(onAddImages).toHaveBeenCalledTimes(1) }) it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - const attachment = { id: 'draft-1', file, previewUrl: 'blob:draft-1' } + const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' } const onRemoveAttachment = vi.fn() const { view, textarea, props } = setup({ draft: '', attachments: [attachment], onRemoveAttachment, diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx index d78fca2069..b6746ff2ed 100644 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { MessageImage } from '../src/client/chat/MessageImage.tsx' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' afterEach(cleanup) @@ -41,4 +42,23 @@ describe('MessageImage', () => { await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) expect(load).toHaveBeenCalledTimes(2) }) + + it('keeps assistant images at their original position between text blocks', async () => { + const view = render( + Promise.resolve('blob:middle')} + />, + ) + const image = await view.findByAltText('middle') + const before = view.getByText('before') + const after = view.getByText('after') + expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index b82276e555..1e5a46a441 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -7,7 +7,9 @@ * for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts). */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -32,9 +34,17 @@ const SCOPE_TAG: symbol = (() => { interface SessionDouble { prompt: ReturnType cancel: ReturnType + readAttachment: ReturnType } -async function bench(opts?: { sessions?: boolean }) { +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function bench(opts?: { + sessions?: boolean + description?: ReturnType +}) { const ctx = new Context() const sessionDoubles = new Map() const scopes = new Map() @@ -57,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) { s = { prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), + readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))), } sessionDoubles.set(id, s) } @@ -66,6 +77,7 @@ async function bench(opts?: { sessions?: boolean }) { create: createMock, open: openMock, scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)), + hostDescription: () => opts?.description, } as unknown as SessionsService if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake) const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) }) @@ -133,6 +145,127 @@ describe('send / cancel', () => { }) }) +describe('image admission and URL lifecycle', () => { + const description: NonNullable> = { + version: '0', + cwd: '/f', + attachedSessions: 0, + activeModel: { + provider: 'anthropic', + id: 'claude-opus-4-8', + name: 'Opus', + inputModalities: ['text', 'image'], + outputModalities: ['text'], + }, + imageLimits: { + maxImageBytes: 3, + maxImagesPerMessage: 2, + maxMessageImageBytes: 4, + maxImagePixels: 100, + mediaTypes: ['image/png'], + }, + } + + it('preflights host limits before allocating previews and releases draft URLs', async () => { + const createObjectURL = vi.fn(() => 'blob:draft') + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }) + const b = await bench({ description }) + const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' }) + const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' }) + + const attachments = b.svc.createDraftImages([first]) + expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' }) + expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/) + expect(createObjectURL).toHaveBeenCalledTimes(1) + + b.svc.releaseDraftImages(attachments) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft') + }) + + it('rejects unsupported model capability, media type, count, and per-image bytes', async () => { + const createObjectURL = vi.fn(() => 'blob:unexpected') + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() }) + const textOnly = await bench({ + description: { + ...description, + activeModel: { ...description.activeModel!, inputModalities: ['text'] }, + }, + }) + const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/) + + const b = await bench({ description }) + const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) + expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/) + const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' }) + expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/) + const existing = b.svc.createDraftImages([png, png]) + expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/) + expect(createObjectURL).toHaveBeenCalledTimes(2) + }) + + it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => { + const createObjectURL = vi.fn() + .mockReturnValueOnce('blob:history-1') + .mockReturnValueOnce('blob:history-2') + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }) + const b = await bench() + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } + b.sessionsFake.manager.get(sid('s1')) + const session = b.sessionDoubles.get(sid('s1'))! + session.readAttachment.mockResolvedValue({ + ok: true, + value: { attachment: ref, data: [1] }, + }) + + await expect(Promise.all([ + b.svc.resolveImage(sid('s1'), ref), + b.svc.resolveImage(sid('s1'), ref), + ])).resolves.toEqual(['blob:history-1', 'blob:history-1']) + expect(session.readAttachment).toHaveBeenCalledTimes(1) + + b.svc.releaseSessionImages(sid('s1')) + await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') }) + await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2') + expect(session.readAttachment).toHaveBeenCalledTimes(2) + }) + + it('revokes a historical URL whose load completes after its session scope was released', async () => { + const createObjectURL = vi.fn(() => 'blob:late') + const revokeObjectURL = vi.fn() + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }) + const b = await bench() + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } + const response = Promise.withResolvers<{ + ok: true + value: { attachment: ImageAttachmentRef; data: number[] } + }>() + b.sessionsFake.manager.get(sid('s1')) + b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise) + + const pending = b.svc.resolveImage(sid('s1'), ref) + b.svc.releaseSessionImages(sid('s1')) + response.resolve({ ok: true, value: { attachment: ref, data: [1] } }) + + await expect(pending).rejects.toThrow(/scope was released/) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:late') + }) +}) + describe('startSession chain', () => { it('creates, navigates through sessions.open, then sends through the new scope', async () => { const b = await bench() diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 1d4b76091a..fbaed548c2 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -71,9 +71,10 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} - addImages={vi.fn()} + addImages={vi.fn(() => null)} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} @@ -132,9 +133,10 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} - addImages={vi.fn()} + addImages={vi.fn(() => null)} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} @@ -240,7 +242,13 @@ describe('EmptyState branches', () => { it('keeps the draft and surfaces a local error strip when startSession rejects', async () => { const startSession = vi.fn(() => Promise.reject(new Error('create down'))) const view = render( - , + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={startSession} + />, ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'first task' } }) @@ -252,7 +260,13 @@ describe('EmptyState branches', () => { it('non-Error rejection reasons stringify into the error strip', async () => { const startSession = vi.fn(() => Promise.reject('plain-string')) const view = render( - , + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={startSession} + />, ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'go' } }) @@ -268,6 +282,9 @@ describe('EmptyState branches', () => { { id: 'a', title: 'a', cwd: '/proj' }, { id: 'b', title: 'b' }, // no cwd: filtered from the option set ])} + createDraftImages={() => []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} startSession={startSession} />, ) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index d7e490c7ee..a49265c225 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -68,7 +68,15 @@ describe('EmptyState', () => { ]) let reject!: (e: Error) => void const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) - render() + render( + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={startSession} + />, + ) const select = screen.getByRole('combobox', { name: '项目目录' }) expect([...(select as HTMLSelectElement).options].map(o => o.value)) @@ -87,12 +95,59 @@ describe('EmptyState', () => { it('new-directory option swaps the select for a free-form input', () => { const { useSessions } = fakeSessions([]) - render( Promise.resolve()} />) + render( + []} + releaseDraftImage={() => {}} + releaseDraftImages={() => {}} + startSession={() => Promise.resolve()} + />, + ) fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } }) const custom = screen.getByPlaceholderText(/目录路径/) fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') }) + + it('routes empty-state draft image creation and release through the injected lifecycle', () => { + const { useSessions } = fakeSessions([]) + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachment = { + kind: 'image' as const, + id: 'draft-1', + file, + previewUrl: 'blob:draft-1', + } + const createDraftImages = vi.fn() + .mockReturnValueOnce([attachment]) + .mockImplementationOnce(() => { throw new Error('图片过大') }) + const releaseDraftImage = vi.fn() + const releaseDraftImages = vi.fn() + const view = render( + Promise.resolve()} + />, + ) + const textarea = view.container.querySelector('textarea')! + const clipboardData = { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }], + getData: () => '', + } + fireEvent.paste(textarea, { clipboardData }) + expect(createDraftImages).toHaveBeenCalledWith([file], []) + fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) + expect(releaseDraftImage).toHaveBeenCalledWith('draft-1') + + fireEvent.paste(textarea, { clipboardData }) + expect(view.getByText('图片过大')).toBeTruthy() + view.unmount() + expect(releaseDraftImages).toHaveBeenCalledWith([]) + }) }) describe('ConversationRoot', () => { @@ -121,9 +176,10 @@ describe('ConversationRoot', () => { subscribe: () => () => {}, version: () => 1, }} - addImages={vi.fn()} + addImages={vi.fn(() => null)} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={send} stop={stop} openDetails={openDetails} diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 6b35741846..b69a6761aa 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -99,6 +99,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = addImages={vi.fn()} removeImage={vi.fn()} draftImages={() => []} + releaseSessionImages={vi.fn()} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 7279cc029d..4e1dc4cedf 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -13,7 +13,7 @@ This backend owns the compaction policy: - **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. -- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. +- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. - **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. @@ -100,7 +100,7 @@ Replacing rather than append-only. Each checkpoint invalidates reuse from the fi #### What the model sees -The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored. +The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages, including image references, that the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored. ##### Compaction instruction (final user message) diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index ce4f28f8b3..2c107f047c 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' -import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -145,7 +145,7 @@ export async function summarizeWithLlm( const error = finishError(assembler.finish) if (error !== undefined) throw error - const summary = textOnly(assembler.message().content) + const summary = summaryText(assembler.message().content) if (!summary.some(block => block.text.trim().length > 0)) { throw new Error('summarization produced no text summary content') } @@ -189,9 +189,18 @@ function finishError(finish: FinishReason): Error | undefined { } } -/** Keep only text blocks before synthesizing a user message. */ -function textOnly( +/** Reject visual output and keep only text before synthesizing a user message. */ +function summaryText( blocks: readonly ContentBlock[], ): Array> { + if (containsImage(blocks)) { + throw new LlmError('compaction summary cannot contain image output', 'UNSUPPORTED_CONTENT') + } return blocks.filter((block): block is Extract => block.type === 'text') } + +/** Detect images recursively so no structured result can hide a silent visual drop. */ +function containsImage(blocks: readonly ContentBlock[]): boolean { + return blocks.some(block => block.type === 'image' + || (block.type === 'tool-result' && containsImage(block.content))) +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 3ea658b64d..1b6ff4e7de 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' @@ -1103,7 +1104,22 @@ describe('default one-shot summarizer', () => { it('replays the conversation prefix and appends the instruction as the final message', async () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] - const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] } + const prefix: Message = { + role: 'user', + content: [ + { type: 'text', text: 'earlier turn' }, + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + ], + } await compact.runSummarize({ system: 'REPLAYED SYSTEM', tools, @@ -1256,6 +1272,43 @@ describe('default one-shot summarizer', () => { await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) + + it('rejects image summary output instead of silently dropping it', async () => { + const { compact } = await summarizerHarness([ + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + { type: 'text', text: 'partial summary' }, + ]) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) + + it('rejects image summary output nested in a tool result', async () => { + const { compact } = await summarizerHarness([{ + type: 'tool-result', + toolCallId: CallId('summary-tool'), + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }], + }]) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) }) describe('automatic listener and loader composition', () => { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 8681cb3b3e..3a1803e5de 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -387,11 +387,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { if (content.some(part => part.type === 'image')) { - const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model) + const routed = agent.session.requestHeader()?.config + const provider = routed?.provider ?? agent.options.provider ?? defaults.provider + const model = routed?.model ?? agent.options.model ?? defaults.model + const activeModel = (await ctx.llm.listModels(provider)).find(candidate => candidate.id === model) if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) { return err(request, { code: 'attachment-error', - message: `Model "${defaults.model}" does not support image input.`, + message: `Model "${model}" does not support image input.`, details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, }) } diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c48931e579..91a19b4397 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -18,13 +18,14 @@ class ScriptedAdapter extends LlmAdapter { constructor( private script: (StreamChunk[] | 'hang')[], private readonly inputModalities: readonly ModelModality[] = ['text', 'image'], + private readonly model = 'test-model', ) { super() } override listModels(provider: string): Promise { return Promise.resolve([{ - provider, id: 'test-model', name: 'test-model', + provider, id: this.model, name: this.model, inputModalities: this.inputModalities, outputModalities: ['text'], }]) } @@ -112,11 +113,38 @@ describe('bootHost / startHost', () => { const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body })) const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } } expect(parsed.result.value.provider).toBe('scripted') + const attachmentBody = JSON.stringify({ + type: 'client-request', + rpcId: 'r-attachment', + method: 'session.attachment', + payload: { sessionId: 'session-missing', attachmentId: 'sha256:missing' }, + }) + const attachmentResponse = await running.handler.fetch(new Request('http://x/api/session.attachment', { + method: 'POST', + body: attachmentBody, + })) + expect((await attachmentResponse.json() as { result: { ok: boolean } }).result.ok).toBe(false) const first = running.dispose() expect(running.dispose()).toBe(first) await first host = undefined }) + + it('mounts configured pi-ai providers while accepting an explicit empty list', async () => { + const empty = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-empty-')), + piAiProviders: [], + }) + expect(empty.ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) + await empty.dispose() + + const configured = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-')), + piAiProviders: [{ provider: 'openai' }], + }) + expect(configured.ctx.llm.listProviders()).toContainEqual({ id: 'openai', name: 'openai' }) + await configured.dispose() + }) }) describe('host.describe', () => { @@ -125,6 +153,18 @@ describe('host.describe', () => { const value = expectOk(await api.host.describe(request({}))) expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 }) }) + + it('omits activeModel when the configured model is absent from the provider catalog', async () => { + host = await startHost({ + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-describe-missing-model-')), + provider: 'scripted', + model: 'missing-model', + }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'], 'other-model')) + expect(expectOk(await host.api.host.describe(request({})))).not.toHaveProperty('activeModel') + }) }) describe('sessions.create / list', () => { @@ -239,6 +279,125 @@ describe('sessions.prompt / cancel', () => { expect(denied.result).toMatchObject({ ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, }) + + const { sessionId: nestedSession } = expectOk(await host.api.sessions.create(request({}))) + const nestedAgent = host.ctx.agents.get(nestedSession) as Agent + nestedAgent.session.append('context/message', { + content: [ + null, + [], + { + type: 'tool-result', + toolCallId: 'nested-text' as never, + content: [{ type: 'text', text: 'no image here' }], + }, + { + type: 'tool-result', + toolCallId: 'nested-image' as never, + content: [{ type: 'image', attachment: image.attachment }], + }, + ] as never, + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + expectOk(await host.api.sessions.attachment(request({ + sessionId: nestedSession, + attachmentId: image.attachment.attachmentId, + }))) + + const { sessionId: streamedSession } = expectOk(await host.api.sessions.create(request({}))) + const streamedAgent = host.ctx.agents.get(streamedSession) as Agent + streamedAgent.session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: image.attachment } }, + }) + expectOk(await host.api.sessions.attachment(request({ + sessionId: streamedSession, + attachmentId: image.attachment.attachmentId, + }))) + + const missingRef = { + ...image.attachment, + attachmentId: `sha256:${'b'.repeat(64)}` as never, + } + streamedAgent.session.append('context/message', { + content: [{ type: 'image', attachment: missingRef }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const missing = await host.api.sessions.attachment(request({ + sessionId: streamedSession, + attachmentId: missingRef.attachmentId, + })) + expect(missing.result).toMatchObject({ + ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } }, + }) + + const read = vi.spyOn(host.ctx.attachments, 'readImage').mockRejectedValueOnce(new Error('read failed')) + const internal = await host.api.sessions.attachment(request({ + sessionId: nestedSession, + attachmentId: image.attachment.attachmentId, + })) + expect(internal.result).toMatchObject({ ok: false, error: { code: 'internal' } }) + read.mockRestore() + + const ghost = await host.api.sessions.attachment(request({ + sessionId: 'session-ghost' as SessionId, + attachmentId: image.attachment.attachmentId, + })) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + }) + + it('rejects non-canonical, excessive-count, and excessive-byte image prompts', async () => { + const running = await boot() + const { sessionId } = expectOk(await running.api.sessions.create(request({}))) + for (const data of ['', 'AB==']) { + const invalid = await running.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data }], + })) + expect(invalid.result).toMatchObject({ + ok: false, error: { details: { reason: 'INVALID_IMAGE_BASE64' } }, + }) + } + + const attachmentService = running.ctx.attachments as unknown as { + imageLimits: typeof running.ctx.attachments.imageLimits + } + attachmentService.imageLimits = { + ...running.ctx.attachments.imageLimits, + maxImagesPerMessage: 1, + } + const tooMany = await running.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: Array.from({ length: 2 }, () => ({ + type: 'image' as const, + mediaType: 'image/png' as const, + data: PNG_BASE64, + })), + })) + expect(tooMany.result).toMatchObject({ + ok: false, error: { details: { reason: 'TOO_MANY_IMAGES' } }, + }) + + attachmentService.imageLimits = { + ...running.ctx.attachments.imageLimits, + maxImagesPerMessage: 10, + maxMessageImageBytes: 100, + } + const excessiveBytes = await running.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: Array.from({ length: 2 }, () => ({ + type: 'image' as const, + mediaType: 'image/png' as const, + data: PNG_BASE64, + })), + })) + expect(excessiveBytes.result).toMatchObject({ + ok: false, error: { details: { reason: 'IMAGES_TOO_LARGE' } }, + }) }) it('rejects images for an explicitly text-only model without creating a session event', async () => { @@ -260,6 +419,57 @@ describe('sessions.prompt / cancel', () => { expect(existsSync(join(dshHome, 'attachments'))).toBe(false) }) + it('preflights the session route instead of the host default model', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-routed-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-routed-home-')) + host = await startHost({ + boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'])) + host.ctx.llm.registerAdapter( + ['visual'], + new ScriptedAdapter([textResponse('seen')], ['text', 'image'], 'visual-model'), + ) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + agent.options.provider = 'visual' + agent.options.model = 'visual-model' + const idle = waitForIdle(host.ctx, agent) + + expectOk(await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }], + }))) + await idle + + expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true) + expect(existsSync(join(dshHome, 'attachments'))).toBe(true) + }) + + it('falls back to host routing when a session has no routed or agent model options', async () => { + host = await startHost({ + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-route-default-')), + provider: 'scripted', + model: 'test-model', + }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + agent.options.provider = undefined as never + agent.options.model = undefined as never + const response = await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }], + })) + expect(response.result).toMatchObject({ + ok: false, error: { details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + }) + }) + it('cancels an attached agent and rejects an unattached one', async () => { const running = await boot(['hang']) const { api, ctx } = running diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 9e59699bd6..ece3a52d48 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ Web-shape HTTP carrier: a `node:http` server routing `/api/*` to an injected fetch-shaped handler (node:http ↔ WHATWG bridge with SSE streamed out chunk by chunk) and everything else to static file serving with the step1-locked semantics — traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. -The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply both the bind `host` and `port`; port `0` requests an OS-assigned port and the running handle reports the assigned value. `dsh web` defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package has zero workspace dependencies on purpose: the handler arrives by structural typing (`{ fetch: typeof fetch }`), so `webserver ← runtime` is a runtime injection relationship, never a package dependency. Callers supply the bind `host`, `port`, and positive `maxRequestBodyBytes`; port `0` requests an OS-assigned port and the running handle reports the assigned value. The API bridge returns 413 before buffering a declared oversized body and keeps chunked-body buffering within the same cap. `dsh web` derives its default cap from the configured aggregate image limit plus base64/envelope expansion and accepts `--max-request-body-bytes` as an explicit override. It defaults to `127.0.0.1` and accepts `--host 0.0.0.0` for deliberate network access. Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. Client-disconnect detection hangs off the **response** `close` event, not the request: since Node 16, `IncomingMessage` `close` fires as soon as the request body is consumed (immediately for a bodyless GET), which would abort every SSE stream right after open. `RunningWebServer.close()` pairs `close()` with `closeAllConnections()` because SSE connections never end on their own. diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 074bfe395c..0b0cafb404 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -33,6 +33,8 @@ export interface WebServerOptions { distIndex: string /** Fetch-shaped API carrier; /api/*-prefixed requests are bridged to it. */ apiHandler: { fetch: typeof fetch } + /** Maximum buffered bytes accepted for one `/api/*` request body. */ + maxRequestBodyBytes: number /** * Web plugin table. When present, every index.html response carries a * `window.__DSH_BOOT__` manifest script and `/plugins//client.js` serves @@ -66,7 +68,10 @@ export interface RunningWebServer { * @returns the running server handle once listening. */ export function startWebServer(options: WebServerOptions, onError: (err: Error) => void): Promise { - const { host, port, distIndex, apiHandler, webPlugins } = options + const { host, port, distIndex, apiHandler, maxRequestBodyBytes, webPlugins } = options + if (!Number.isInteger(maxRequestBodyBytes) || maxRequestBodyBytes < 1) { + throw new RangeError('host webserver: maxRequestBodyBytes must be a positive integer') + } const distRoot = dirname(distIndex) const renderIndex = webPlugins === undefined ? undefined : async (): Promise => { const html = await readFile(distIndex, 'utf8') @@ -78,7 +83,7 @@ export function startWebServer(options: WebServerOptions, onError: (err: Error) requests; the field is only optional on the client-side IncomingMessage type */ const rawPath = new URL(req.url ?? '/', 'http://x').pathname if (rawPath.startsWith('/api/')) { - await bridge(req, res, apiHandler) + await bridge(req, res, apiHandler, maxRequestBodyBytes) return } if (req.method !== 'GET' && req.method !== 'HEAD') { @@ -163,8 +168,13 @@ async function servePluginBundle( } } -/** Bridge one node:http request to the WHATWG fetch handler (client close aborts; SSE bodies stream out chunk by chunk). */ -async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise { +/** Bridge one bounded node:http request to the WHATWG fetch handler. */ +async function bridge( + req: IncomingMessage, + res: ServerResponse, + apiHandler: { fetch: typeof fetch }, + maxRequestBodyBytes: number, +): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is @@ -174,8 +184,31 @@ async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { f res.on('close', () => { if (!res.writableEnded) abort.abort() }) + const declaredLength = req.headers['content-length'] + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { + res.writeHead(413) + res.end() + req.resume() + return + } const chunks: Buffer[] = [] - for await (const chunk of req) chunks.push(chunk as Buffer) + let received = 0 + let oversized = false + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxRequestBodyBytes) { + oversized = true + chunks.length = 0 + continue + } + if (!oversized) chunks.push(buffer) + } + if (oversized) { + res.writeHead(413) + res.end() + return + } /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server requests; the fields are only optional on the client-side IncomingMessage type */ const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 0b9d1a8978..49d9b7a233 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -1,10 +1,13 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { request as httpRequest } from 'node:http' import { createServer as createNetServer, Server as NetServer, type AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { startWebServer, type RunningWebServer } from '../src/index.ts' +const MAX_REQUEST_BODY_BYTES = 64 * 1024 + /** Reserve a loopback port for tests that need to address a second server. */ function freePort(): Promise { return new Promise((resolve, reject) => { @@ -104,17 +107,35 @@ afterEach(async () => { server = undefined }) -async function boot(onError: (err: Error) => void = () => undefined): Promise { +async function boot( + onError: (err: Error) => void = () => undefined, + maxRequestBodyBytes = MAX_REQUEST_BODY_BYTES, +): Promise { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, onError) + server = await startWebServer({ + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes, + }, onError) return `http://127.0.0.1:${String(server.port)}` } describe('startWebServer', () => { + it('rejects an invalid request-body cap before listening', () => { + const { distIndex } = makeDist() + expect(() => startWebServer({ + host: '127.0.0.1', + port: 0, + distIndex, + apiHandler: echoingApi, + maxRequestBodyBytes: 0, + }, () => undefined)).toThrow(/positive integer/) + }) + it('reports the listening port and closes idempotently', async () => { const { distIndex } = makeDist() - server = await startWebServer({ host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi }, () => undefined) + server = await startWebServer({ + host: '127.0.0.1', port: 0, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined) expect(server.port).toBeGreaterThan(0) const first = server.close() const second = server.close() @@ -136,7 +157,9 @@ describe('startWebServer', () => { }) const address = vi.spyOn(NetServer.prototype, 'address').mockReturnValue({ address: host, family: 'IPv4', port }) try { - const inertServer = await startWebServer({ host, port, distIndex, apiHandler: echoingApi }, () => undefined) + const inertServer = await startWebServer({ + host, port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined) expect(listen).toHaveBeenCalledWith(port, host, expect.any(Function)) await inertServer.close() } finally { @@ -148,8 +171,12 @@ describe('startWebServer', () => { it('rejects when the port is already taken', async () => { const { distIndex } = makeDist() const port = await freePort() - server = await startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined) - await expect(startWebServer({ host: '127.0.0.1', port, distIndex, apiHandler: echoingApi }, () => undefined)) + server = await startWebServer({ + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined) + await expect(startWebServer({ + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, + }, () => undefined)) .rejects.toMatchObject({ code: 'EADDRINUSE' }) }) }) @@ -207,7 +234,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti } const port = await freePort() server = await startWebServer( - { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + { + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins, + }, () => undefined, ) return `http://127.0.0.1:${String(server.port)}` } @@ -245,7 +275,10 @@ describe.skipIf(process.platform === 'win32')('web plugin surfaces (boot injecti } const port = await freePort() server = await startWebServer( - { host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, webPlugins }, () => undefined, + { + host: '127.0.0.1', port, distIndex, apiHandler: echoingApi, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES, webPlugins, + }, () => undefined, ) const res = await fetch(`http://127.0.0.1:${String(server.port)}/plugins/@deepseek-ai/dsh-client-connection/client.js`) expect(res.status).toBe(404) @@ -305,6 +338,35 @@ describe('/api bridge', () => { expect(await response.json()).toEqual({ method: 'POST', body: '{"n":1}', header: 'p1' }) }) + it('returns 413 before buffering a declared oversized body', async () => { + const base = await boot() + const response = await fetch(`${base}/api/echo`, { + method: 'POST', + body: 'x'.repeat(MAX_REQUEST_BODY_BYTES + 1), + }) + expect(response.status).toBe(413) + }) + + it('bounds chunked request buffering when no content length is declared', async () => { + const base = await boot(() => undefined, 8) + const target = new URL(`${base}/api/echo`) + const status = await new Promise((resolve, reject) => { + const request = httpRequest({ + hostname: target.hostname, + port: target.port, + path: target.pathname, + method: 'POST', + }, (response) => { + response.resume() + response.on('end', () => { resolve(response.statusCode) }) + }) + request.on('error', reject) + request.write('12345') + request.end('67890') + }) + expect(status).toBe(413) + }) + it('relays a bodyless response', async () => { const base = await boot() const response = await fetch(`${base}/api/empty`, { method: 'POST' }) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8a6736f112..4e17552cde 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -34,6 +34,8 @@ Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reason The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. +Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. A visual request still fails explicitly with `UNSUPPORTED_CONTENT` when the service or the selected model's image capability is absent. + ## Provider/model routing and replay The selected pi-ai catalog descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 64cdb1699f..dec252264e 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -25,8 +25,8 @@ import { toStreamChunks } from './stream.ts' export interface PiAiAdapterOptions { /** Validated provider profiles this adapter instance owns. */ profiles: readonly PiAiProviderProfile[] - /** Durable image resolver used only when a request contains image references. */ - attachments?: AttachmentStore + /** Resolve durable image storage at request time so plugin load order does not become capability state. */ + resolveAttachments?: () => AttachmentStore | undefined } /** @@ -72,12 +72,12 @@ function requestHeaders(headers: Readonly> | undefined): */ export class PiAiAdapter extends LlmAdapter { private readonly profiles: ReadonlyMap - private readonly attachments: AttachmentStore | undefined + private readonly resolveAttachments: () => AttachmentStore | undefined constructor(options: PiAiAdapterOptions) { super() this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) - this.attachments = options.attachments + this.resolveAttachments = options.resolveAttachments ?? (() => undefined) } override listModels(provider: string): Promise { @@ -136,12 +136,13 @@ export class PiAiAdapter extends LlmAdapter { if (containsImage && !model.input.includes('image')) { throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') } - if (containsImage && this.attachments === undefined) { + const attachments = containsImage ? this.resolveAttachments() : undefined + if (containsImage && attachments === undefined) { throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT') } - const context = this.attachments === undefined + const context = attachments === undefined ? toPiContext(options) - : await toPiContext(options, this.attachments) + : await toPiContext(options, attachments) const events = streamSimple(model, context, { ...profileOptions(profile), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2b0d842e47..27fcd40b57 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -36,10 +36,9 @@ export const inject = ['llm'] /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const profiles = resolveProfiles(config.providers) - const attachments = ctx.get('attachments') const adapter = new PiAiAdapter({ profiles, - ...(attachments === undefined ? {} : { attachments }), + resolveAttachments: () => ctx.get('attachments'), }) ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 63e30c0ed5..de12d81e5e 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -2,6 +2,13 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' @@ -88,6 +95,14 @@ const textEvents = [ '[DONE]', ] +const IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} + async function harness(baseURL: string, overrides: Record = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) @@ -189,6 +204,55 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) + it('resolves an attachment service mounted after the adapter when dispatching an image', async () => { + const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) + const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) + const ref: ImageAttachmentRef = { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } + const readImage = vi.fn((_ref: ImageAttachmentRef): Promise => + Promise.resolve({ ref, data: Uint8Array.of(1) })) + + class LateAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('not used')) + } + + readImage(value: ImageAttachmentRef): Promise { + return readImage(value) + } + } + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }], + }) + await ctx.plugin(LateAttachmentStore) + + const result = await assemble(ctx, { + provider: 'openai', + model: 'gpt-4.1', + messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }], + }) + + expect(result.finish.kind).toBe('error') + expect(readImage).toHaveBeenCalledWith(ref) + expect(server.paths).toEqual(['/v1/responses']) + }) + it('forces one wire request for an SDK-retryable provider failure', async () => { const server = await mockServer([ { @@ -387,6 +451,38 @@ describe('provider profile lifecycle', () => { expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) + it('rejects unsupported or unresolved image input before provider I/O', async () => { + const adapter = new PiAiAdapter({ + profiles: [{ provider: 'openai' }, { provider: 'deepseek' }], + }) + const drain = async (options: Parameters[0]): Promise => { + for await (const _chunk of adapter.stream(options)) { /* drain */ } + } + + await expect(drain({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [{ role: 'user', content: [{ type: 'image', attachment: IMAGE_REF }] }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(drain({ + provider: 'openai', + model: 'gpt-4.1', + messages: [{ role: 'user', content: [{ type: 'image', attachment: IMAGE_REF }] }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(drain({ + provider: 'openai', + model: 'gpt-4.1', + messages: [{ + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: 'call-image' as never, + content: [{ type: 'image', attachment: IMAGE_REF }], + }], + }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) + it('validates direct-constructor profiles at the embedding boundary', () => { expect(() => new PiAiAdapter({ profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }], diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts new file mode 100644 index 0000000000..f3e348d98a --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import { toPiContext } from '../src/context.ts' +import { toPiAssistant } from '../src/replay.ts' + +const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} + +const attachments = { + readImage: vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1) })), +} as unknown as AttachmentStore + +function request(messages: GenerateOptions['messages']): GenerateOptions { + return { + provider: 'openai', + model: 'gpt-4.1', + system: 'system prompt', + tools: [{ name: 'lookup', description: 'look up', parameters: { type: 'object' } }], + messages, + } +} + +describe('pi-ai request context conversion', () => { + it('omits absent and empty request-level optional fields', () => { + const base = { provider: 'openai', model: 'gpt-4.1', messages: [] } + expect(toPiContext(base)).toEqual({ messages: [] }) + expect(toPiContext({ ...base, tools: [] })).toEqual({ messages: [] }) + }) + + it('converts complete text-only history and rejects nested images without storage', () => { + const callId = CallId('call-1') + expect(toPiContext(request([ + { role: 'system', content: [{ type: 'text', text: 'history system' }] }, + { + role: 'assistant', + content: [{ type: 'tool-call', id: callId, name: 'lookup', arguments: '{}' }], + }, + { + role: 'user', + content: [ + { type: 'text', text: 'after tool' }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: '' }], + }, + ], + }, + ]))).toMatchObject({ + systemPrompt: 'system prompt', + tools: [{ name: 'lookup' }], + messages: [ + { role: 'user', content: 'history system' }, + { role: 'assistant' }, + { role: 'user', content: 'after tool' }, + { + role: 'toolResult', + toolCallId: 'call-1', + toolName: 'lookup', + content: [{ type: 'text', text: '(no output)' }], + isError: false, + }, + ], + }) + + expect(() => toPiContext(request([{ + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'image', attachment: ref }], + }], + }]))).toThrow(/durable attachment service/) + }) + + it('resolves user and tool-result images while preserving explicit fallbacks', async () => { + const callId = CallId('missing-call') + const knownCallId = CallId('known-call') + const context = await toPiContext(request([ + { role: 'user', content: [{ type: 'text', text: '' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'calling' }, + { type: 'tool-call', id: knownCallId, name: 'lookup', arguments: '{}' }, + ], + }, + { + role: 'user', + content: [ + { type: 'image', attachment: ref }, + { type: 'text', text: 'caption' }, + { type: 'reasoning', text: 'ignored' }, + ], + }, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: knownCallId, + content: [{ type: 'text', text: '' }], + }], + }, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + isError: true, + content: [ + { type: 'tool-result', toolCallId: callId, content: [] }, + { type: 'image', attachment: ref }, + ], + }], + }, + ]), attachments) + + expect(context.messages).toEqual([ + { role: 'user', content: '', timestamp: 0 }, + expect.objectContaining({ role: 'assistant' }), + { + role: 'user', + content: [ + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + { type: 'text', text: 'caption' }, + ], + timestamp: 0, + }, + { + role: 'toolResult', + toolCallId: 'known-call', + toolName: 'lookup', + content: [{ type: 'text', text: '(no output)' }], + isError: false, + timestamp: 0, + }, + { + role: 'toolResult', + toolCallId: 'missing-call', + toolName: 'unknown', + content: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + isError: true, + timestamp: 0, + }, + ]) + }) + + it('keeps empty text-only users while separating result-only messages', () => { + const callId = CallId('unknown-call') + expect(toPiContext(request([ + { role: 'user', content: [] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'answer' }, + { type: 'tool-call', id: CallId('other-call'), name: 'lookup', arguments: '{}' }, + ], + }, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'result' }], + }], + }, + ]))).toMatchObject({ + messages: [ + { role: 'user', content: '' }, + { role: 'assistant' }, + { role: 'toolResult', toolName: 'unknown' }, + ], + }) + }) + + it('handles in-history system and assistant messages explicitly on the image path', async () => { + await expect(toPiContext(request([{ + role: 'system', + content: [{ type: 'image', attachment: ref }], + }]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + + await expect(toPiContext(request([ + { role: 'system', content: [{ type: 'text', text: 'history system' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, + { role: 'user', content: [{ type: 'text', text: 'plain' }] }, + ]), attachments)).resolves.toMatchObject({ + messages: [ + { role: 'user', content: 'history system' }, + { role: 'assistant' }, + { role: 'user', content: 'plain' }, + ], + }) + + expect(() => toPiAssistant({ + role: 'assistant', + content: [{ type: 'image', attachment: ref }], + })).toThrow(/assistant image output/) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 06154a8a5e..6bee0aa0d0 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -1,5 +1,13 @@ +import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' @@ -17,6 +25,8 @@ interface ProviderCase { const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY +const anthropicApiKey = process.env.ANTHROPIC_API_KEY ?? process.env.DEEPSEEK_API_KEY +const anthropicBaseURL = process.env.DSH_PI_AI_ANTHROPIC_BASE_URL ?? process.env.DEEPSEEK_BASE_URL const providerCases: ProviderCase[] = [ { @@ -32,13 +42,14 @@ const providerCases: ProviderCase[] = [ provider: 'anthropic', api: 'anthropic-messages', model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8', - ...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {}, + ...anthropicApiKey === undefined ? {} : { apiKey: anthropicApiKey }, + ...anthropicBaseURL === undefined ? {} : { baseURL: anthropicBaseURL }, }, ] const contexts: Context[] = [] -async function harness(): Promise { +async function harness(image?: StoredImageAttachment): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) @@ -50,6 +61,30 @@ async function harness(): Promise { ...profile.headers === undefined ? {} : { headers: profile.headers }, })), }) + if (image !== undefined) { + const fixture = image + class E2eAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: fixture.data.byteLength, + maxImagesPerMessage: 1, + maxMessageImageBytes: fixture.data.byteLength, + maxImagePixels: fixture.ref.width * fixture.ref.height, + mediaTypes: [fixture.ref.mediaType], + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('e2e attachment fixture is read-only')) + } + + readImage(ref: ImageAttachmentRef): Promise { + if (ref.attachmentId !== fixture.ref.attachmentId) { + return Promise.reject(new Error('unknown e2e attachment fixture')) + } + return Promise.resolve(fixture) + } + } + await ctx.plugin(E2eAttachmentStore) + } return ctx } @@ -158,6 +193,41 @@ for (const profile of providerCases) { expect(textOf(second).toLowerCase()).toContain('ocean') expect(expectNativeReplay(second, profile).stopReason).toBe('stop') }) + + if (profile.provider === 'anthropic') { + it('sends a real image through the authenticated Anthropic visual path', async () => { + const data = new Uint8Array(await readFile( + new URL('../../../../assets/community-wecom-survey.png', import.meta.url), + )) + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: data.byteLength, + width: 256, + height: 256, + name: 'qr-code.png', + } + const ctx = await harness({ ref, data }) + const result = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: [{ + role: 'user', + content: [ + { + type: 'text', + text: 'What type of machine-readable symbol is shown in the attached image? Reply with exactly: QR code', + }, + { type: 'image', attachment: ref, alt: 'machine-readable symbol' }, + ], + }], + maxTokens: 256, + }) + + expectFinish(result, 'stop') + expect(textOf(result).toLowerCase()).toContain('qr code') + }) + } }, ) } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e3618474e2..063fd3559f 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' @@ -116,6 +117,16 @@ describe('TokenMeterService pricing', () => { const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1024, + height: 513, + }, + }, { type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' }, { type: 'tool-result', @@ -126,7 +137,7 @@ describe('TokenMeterService pricing', () => { { type: 'future-block', payload: 'abcd' } as unknown as ContentBlock, ] const estimated = service.estimateMessage({ role: 'assistant', content: blocks }) - expect(estimated).toBeGreaterThan(30) + expect(estimated).toBe(813) expect(service.estimateMessage(textMessage('abcd'))).toBe(9) }) diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index 9cd2ca33a1..30a3ec2f15 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { TurnEndReason } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session' @@ -35,6 +36,17 @@ describe('turnEndToStopReason', () => { describe('harnessBlockToAcpContent', () => { it('maps a text block to ACP text content', () => { expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' }) + const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) + expect(harnessBlockToAcpContent({ + type: 'image', + attachment: { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + })).toEqual({ type: 'text', text: `[image attachment ${attachmentId}]` }) }) it('returns undefined for non-text blocks (reasoning / plugin-added)', () => { diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 28d45f50d6..03aeea7d06 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -124,6 +124,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. -- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. +- **Images use explicit text markers** — the terminal cannot render inline raster images, so user, assistant, tool-result, and streaming image blocks render as `[image attachment ]` instead of disappearing. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. - **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 4a16aa87d0..9ffe081bf4 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -424,6 +424,9 @@ function contentText(content: readonly ContentBlock[]): string { case 'tool-result': parts.push(contentText(block.content)) break + case 'image': + parts.push(`[image attachment ${block.attachment.attachmentId}]`) + break default: { const rawType = (block as { type?: unknown }).type parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) @@ -648,7 +651,14 @@ class AssistantMessageComponent extends Container { constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { super() const reasoning = displayText(textBlocks(content, 'reasoning').trim()) - const text = displayText(textBlocks(content, 'text').trim()) + const text = displayText(content + .flatMap(block => block.type === 'text' + ? [block.text] + : block.type === 'image' + ? [`[image attachment ${block.attachment.attachmentId}]`] + : []) + .join('\n\n') + .trim()) if (reasoning && showReasoning) { this.addChild(new Spacer(1)) this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) @@ -668,6 +678,7 @@ class AssistantMessageComponent extends Container { interface StreamingBlock { type: string text: string + block?: ContentBlock } class StreamingAssistantComponent extends Container { @@ -691,6 +702,8 @@ class StreamingAssistantComponent extends Container { this.blocks.set(chunk.index, block) } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) + } else if (chunk.type === 'block-end' && chunk.block.type === 'image') { + this.blocks.set(chunk.index, { type: 'image', text: '', block: chunk.block }) } this.rebuild() } @@ -707,6 +720,7 @@ class StreamingAssistantComponent extends Container { .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] + if (block.type === 'image' && block.block?.type === 'image') return [block.block] return [] }) const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 22a50c9ccf..bd3c76c6fb 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -434,6 +434,29 @@ describe('pi-tui chat lifecycle and transcript', () => { step: 1, chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' }, }) + result.session.append('assistant/chunk', { + turn: 3, + step: 1, + chunk: { type: 'block-start', index: 3, blockType: 'image' }, + }) + result.session.append('assistant/chunk', { + turn: 3, + step: 1, + chunk: { + type: 'block-end', + index: 3, + block: { + type: 'image', + attachment: { + attachmentId: 'sha256:stream-image' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + }, + }) result.session.append('assistant/chunk', { turn: 3, step: 1, @@ -441,6 +464,7 @@ describe('pi-tui chat lifecycle and transcript', () => { }) await tick() expect(result.terminal.output).toContain('live thought') + expect(result.terminal.output).toContain('sha256:stream-image]') result.terminal.send('\x12') await tick() appendAssistant( @@ -729,6 +753,16 @@ describe('pi-tui chat lifecycle and transcript', () => { { type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' }, { type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' }, { type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] }, + { + type: 'image', + attachment: { + attachmentId: 'sha256:user-image' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, { type: 'future-block' } as never, {} as never, ], @@ -737,6 +771,16 @@ describe('pi-tui chat lifecycle and transcript', () => { appendAssistant(session, [ { type: 'reasoning', text: 'styled reasoning' }, { type: 'text', text: 'styled answer' }, + { + type: 'image', + attachment: { + attachmentId: 'sha256:assistant-image' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, ], { inputTokens: 2_000_000, outputTokens: 1_500_000 }) session.append('todo/write', { todos: [ { content: 'done', status: 'completed' }, @@ -756,6 +800,8 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('Heading') expect(result.terminal.output).toContain('nested_tool({})') expect(result.terminal.output).toContain('nested result') + expect(result.terminal.output).toContain('sha256:user-image]') + expect(result.terminal.output).toContain('[image attachment sha256:assistant-image]') expect(result.terminal.output).toContain('[future-block]') expect(result.terminal.output).toContain('[content]') expect(result.terminal.output).toContain('↑2.0m ↓1.5m') diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a3c16f1241..0f58cbd059 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -8,6 +8,13 @@ import { expect } from 'vitest' import { RegistryService } from 'cordis' import type { Context, Plugin } from 'cordis' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import InvariantService from '@deepseek-ai/dsh-invariants' declare global { @@ -78,6 +85,25 @@ export function usesManualInvariantTree(testPath: string): boolean { } const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const +const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invariant.ts' + +class TestAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('test invariant attachment store does not save images')) + } + + readImage(_ref: ImageAttachmentRef): Promise { + return Promise.reject(new Error('test invariant attachment store does not read images')) + } +} /** * Select the package companions that an ordinary test root must register. @@ -115,6 +141,7 @@ function startInvariantHost(root: Context): InvariantHost { mount(InvariantService, { enabled: true }) const testPath = expect.getState().testPath ?? '' const companionPaths = testInvariantCompanionPaths(testPath) + if (companionPaths.includes(ATTACHMENT_COMPANION)) mount(TestAttachmentStore) for (const path of companionPaths) { const companion = testInvariantCompanions[path] if (companion === undefined) {