From cb4c11b869f2c9e2bcba89ab6deec146b678be68 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 15:20:47 +0800 Subject: [PATCH 01/45] Add web multimodal image attachments --- ...ge-input-and-durable-attachments.i18n.yaml | 6 + ...dal-image-input-and-durable-attachments.md | 210 ++++++++++++++++++ ...-image-input-and-durable-attachments.zh.md | 210 ++++++++++++++++++ apps/web/tests/smoke-fixture.e2e.ts | 99 ++++++++- docs/capability-seams.md | 11 +- docs/config-catalog.md | 21 ++ docs/cordis-catalog/services.md | 26 ++- docs/core-data-structures/attachment.md | 70 ++++++ docs/core-data-structures/core.md | 8 +- docs/core-data-structures/llm-streaming.md | 1 + knip.json | 5 + packages/README.md | 1 + packages/attachment/README.md | 10 + .../attachment/attachment-local/README.md | 19 ++ .../attachment/attachment-local/package.json | 30 +++ .../attachment/attachment-local/src/image.ts | 101 +++++++++ .../attachment/attachment-local/src/index.ts | 74 ++++++ .../attachment-local/src/invariant.ts | 20 ++ .../attachment/attachment-local/src/store.ts | 121 ++++++++++ .../attachment-local/tests/store.spec.ts | 96 ++++++++ .../attachment/attachment-local/tsconfig.json | 12 + packages/attachment/attachment/README.md | 19 ++ packages/attachment/attachment/package.json | 27 +++ packages/attachment/attachment/src/index.ts | 51 +++++ .../attachment/attachment/src/invariant.ts | 20 ++ packages/attachment/attachment/src/types.ts | 75 +++++++ packages/attachment/attachment/tsconfig.json | 11 + packages/client/connection/package.json | 1 + packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 82 ++++++- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 3 + .../client/connection/tests/fixture.spec.ts | 29 ++- packages/client/connection/tsconfig.json | 3 + packages/client/runtime/package.json | 1 + .../src/client/sessions/conversation.ts | 6 + .../runtime/src/client/sessions/session.ts | 25 ++- .../client/runtime/tests/conversation.spec.ts | 12 +- packages/client/runtime/tests/fake-api.ts | 3 + packages/client/runtime/tests/session.spec.ts | 15 ++ packages/client/runtime/tsconfig.json | 3 + packages/client/ui-conversation/package.json | 1 + .../ui-conversation/src/client/apply.ts | 24 +- .../src/client/chat/AssistantMarkdown.tsx | 11 +- .../src/client/chat/ChatView.tsx | 26 ++- .../src/client/chat/MessageImage.module.css | 53 +++++ .../src/client/chat/MessageImage.tsx | 70 ++++++ .../src/client/chat/MessageItem.module.css | 11 +- .../src/client/chat/MessageItem.tsx | 38 +++- .../src/client/chat/register.ts | 6 +- .../src/client/contract/slots.ts | 22 +- .../src/client/contract/views.ts | 6 + .../ui-conversation/src/client/service.ts | 153 ++++++++++++- .../src/client/skeleton/ConversationRoot.tsx | 20 +- .../src/client/skeleton/EmptyState.tsx | 37 ++- .../client/skeleton/ImageLightbox.module.css | 34 +++ .../src/client/skeleton/ImageLightbox.tsx | 34 +++ .../src/client/skeleton/InputBar.module.css | 71 ++++++ .../src/client/skeleton/InputBar.tsx | 108 ++++++++- .../ui-conversation/src/client/stores.ts | 30 ++- .../tests/apply-inject.spec.tsx | 8 +- .../ui-conversation/tests/chat-store.spec.ts | 16 +- .../ui-conversation/tests/input-bar.spec.tsx | 86 +++++++ .../tests/message-image.spec.tsx | 44 ++++ .../tests/selection-survival.spec.ts | 2 +- .../tests/service-orchestration.spec.ts | 23 ++ .../tests/skeleton-branches.spec.tsx | 6 + .../ui-conversation/tests/skeleton.spec.tsx | 5 +- packages/client/ui-conversation/tsconfig.json | 3 + .../client/ui-trajectory/tests/views.spec.tsx | 3 + .../cordis/tool-cordis/src/api-catalog.ts | 50 ++++- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api/host.schema.ts | 18 ++ packages/host/apiproxy/src/api/host.ts | 6 + packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 43 +++- packages/host/apiproxy/src/api/sessions.ts | 15 +- packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../apiproxy/tests/client-handler.spec.ts | 4 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 6 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 19 +- packages/host/apiproxy/tsconfig.json | 3 + packages/host/runtime/package.json | 2 + packages/host/runtime/src/api-proxy.ts | 120 +++++++++- packages/host/runtime/src/boot.ts | 13 ++ .../host/runtime/tests/host-runtime.spec.ts | 93 +++++++- packages/host/runtime/tsconfig.json | 6 + packages/llm/llm-deepseek/src/adapter.ts | 2 + packages/llm/llm-deepseek/src/serialize.ts | 15 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 12 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 14 ++ packages/llm/llm-pi-ai/package.json | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 25 ++- packages/llm/llm-pi-ai/src/context.ts | 153 ++++++++++--- packages/llm/llm-pi-ai/src/index.ts | 6 +- packages/llm/llm-pi-ai/src/replay.ts | 2 + packages/llm/llm-pi-ai/tests/adapter.spec.ts | 1 + packages/llm/llm-pi-ai/tests/convert.spec.ts | 46 +++- packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/llm/llm/package.json | 2 + packages/llm/llm/src/index.ts | 2 + packages/llm/llm/src/types.ts | 24 ++ packages/llm/llm/tsconfig.json | 3 + packages/llm/token-meter/src/index.ts | 11 + packages/ui/acp/src/codec.ts | 2 + pnpm-lock.yaml | 58 +++++ python/sdk-runtime/package.json | 1 + scripts/gen-cordis-catalog.ts | 3 + scripts/gen-doc-graphs.ts | 10 + scripts/type-equiv.manifest.json | 25 +++ .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + 116 files changed, 3177 insertions(+), 151 deletions(-) create mode 100644 .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md create mode 100644 .agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md create mode 100644 docs/core-data-structures/attachment.md create mode 100644 packages/attachment/README.md create mode 100644 packages/attachment/attachment-local/README.md create mode 100644 packages/attachment/attachment-local/package.json create mode 100644 packages/attachment/attachment-local/src/image.ts create mode 100644 packages/attachment/attachment-local/src/index.ts create mode 100644 packages/attachment/attachment-local/src/invariant.ts create mode 100644 packages/attachment/attachment-local/src/store.ts create mode 100644 packages/attachment/attachment-local/tests/store.spec.ts create mode 100644 packages/attachment/attachment-local/tsconfig.json create mode 100644 packages/attachment/attachment/README.md create mode 100644 packages/attachment/attachment/package.json create mode 100644 packages/attachment/attachment/src/index.ts create mode 100644 packages/attachment/attachment/src/invariant.ts create mode 100644 packages/attachment/attachment/src/types.ts create mode 100644 packages/attachment/attachment/tsconfig.json create mode 100644 packages/client/ui-conversation/src/client/chat/MessageImage.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/MessageImage.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx create mode 100644 packages/client/ui-conversation/tests/message-image.spec.tsx diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml new file mode 100644 index 0000000000..43f81f5752 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md new file mode 100644 index 0000000000..14ad4e5ceb --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -0,0 +1,210 @@ +# Agent Note: Web multimodal image input and durable attachments + +Status: proposed + +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. + +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. + +The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. + +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 + +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. + +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. + +### 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. +- 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. + +### Storage lifecycle and ownership + +The persistence boundary is message acceptance, not paste: + +| State | Allowed representation | Durability and ordering | +| --- | --- | --- | +| Unsent user draft | Browser `File` plus object URL; a native client may use an OS temporary file such as `/var/...` | Temporary and client-owned. It may disappear on reload or process exit and never appears in a session event. | +| Accepted user image | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The host commits every image before `agent.send()` or `agent.steer()` can append the owning user event. | +| Structured model image output | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The provider adapter commits the bytes before it emits a completed image block or assistant message event. Temporary URLs, paths, and base64 are forbidden in the event. | + +The framework-owned chat store keeps the per-session draft text and ordered attachment identifiers. `ConversationService` owns the corresponding browser-only `File` and object-URL registry: + +```ts +export {} + +interface ChatStoreState { + selection: object | null + draft: string + imageIds: string[] + view: string | null +} + +interface ComposerAttachment { + id: string + file: File + previewUrl: string +} +``` + +This split uses the slots framework's store seat and bound actions as the single subscription path for UI state while keeping non-serializable browser objects out of persisted JSON. Draft text and ordered image identifiers continue to use `localStorage`; after a reload, `ConversationRoot` prunes identifiers whose runtime objects no longer exist. Unsent images therefore do not survive reload because browser `File` and object URLs are not durable. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. + +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. + +### Durable content and prompt wire + +The attachment seam exposes immutable image write and verified read operations. The canonical metadata is deliberately narrower than a generic file record: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type AttachmentId = Branded<'AttachmentId'> + +interface ImageAttachmentRef { + attachmentId: AttachmentId + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + bytes: number + width: number + height: number + name?: string +} + +interface ImageBlock { + type: 'image' + attachment: ImageAttachmentRef +} +``` + +`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 browser cannot mint a durable reference, so `session.prompt` accepts a narrow intake union rather than canonical `ContentBlock[]`: + +```ts +export {} + +type PromptInputPart = + | { type: 'text'; text: string } + | { + type: 'image' + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + data: string + name?: string + } +``` + +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. + +### 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 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. + +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. + +### 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. + +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. + +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. + +### Package and surface changes + +| Surface | Responsibility | +| --- | --- | +| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. | +| `packages/attachment/attachment-local` | Private content-addressed storage, image-header validation, integrity verification, and configuration. | +| `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/host/apiproxy` and `packages/host/runtime` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits, and model preflight. | +| `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 + +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. + +No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. + +## Alternatives considered + +### Keep every intake image in `/var` or another temporary directory + +Temporary storage is appropriate before send, including for a native client that receives clipboard files through the operating system. It is not appropriate after acceptance: cleanup is outside the harness's control, paths are host-specific, and resume or fork can outlive the file. The proposal permits temporary staging but copies accepted bytes into `DSH_HOME` before the event. + +### Persist immediately on paste or drop + +Immediate persistence makes drafts reload-resistant but creates durable objects before a session or message owns them, which requires quota, orphan lifetime, and cleanup policy. Version one keeps the unsent draft temporary and makes send acceptance the durability boundary. + +### Inline base64 in messages and session logs + +This duplicates binary data across RPC, events, history pages, forks, compaction, and browser storage, and invites token accounting to treat encoding text as model text. One immutable object plus small references keeps the durable representation bounded. + +### Use browser object URLs, local paths, or provider URLs as canonical content + +Object URLs expire with the document, local paths are not portable, and provider URLs may expire, track viewers, or expose credentials. They remain temporary transport or preview details only. + +### Use one generic `AttachmentBlock` for images, files, audio, and video + +Composer presentation can use a generic attachment rail, but provider semantics are modality-specific. Images are native multimodal input; PDFs may be provider files or extracted text; video may be native, sampled, or unsupported. A specific `ImageBlock` forces every consumer to handle or reject the modality explicitly. + +### Rely on UI capability checks or silently filter images + +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 + +- 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. + +## Risks + +- 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. +- JSON-RPC base64 adds upload memory and roughly one-third encoding overhead. Version-one limits bound it; larger media needs streaming or a binary transport. +- Unsent images do not survive reload. Durable drafts need quota and orphan cleanup rather than reusing message storage implicitly. +- 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. diff --git a/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md new file mode 100644 index 0000000000..5752247b08 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -0,0 +1,210 @@ +# Agent Note: Web 多模态图片输入与持久附件 + +Status: proposed + +[English](2026-07-22-web-multimodal-image-input-and-durable-attachments.md) | 中文 + +## 问题 + +Web 输入区目前仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 + +这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 + +[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 + +同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 + +## 提案 + +把粘贴或拖放的光栅图片作为持久附件能力的首个消费方,接入 Web 输入区。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 + +第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单分别作为后续工作。 + +### 产品行为 + +- 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。 +- 空状态输入区与常驻输入区共用附件栏;附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 +- 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 +- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴处理器只有接受图片文件后才阻止默认行为。无论文件是否受支持,在输入区放置文件时都会阻止浏览器导航;系统会接受受支持的图片,并在本地提示哪些文件不受支持。 +- 发送失败时恢复完整的文本与图片草稿。移除、发送成功和会话作用域释放都会撤销过期的对象 URL。 +- 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 +- 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 +- 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 + +### 存储生命周期与归属 + +持久化边界是消息被接受,而不是图片被粘贴: + +| 状态 | 允许的表示 | 持久性与顺序 | +| --- | --- | --- | +| 未发送的用户草稿 | 浏览器 `File` 加对象 URL;原生客户端可以使用 `/var/...` 等操作系统临时文件 | 临时且由客户端持有。它可能在重载或进程退出后消失,绝不出现在会话事件中。 | +| 已接受的用户图片 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 在 `agent.send()` 或 `agent.steer()` 能够追加所属用户事件前,宿主提交每张图片。 | +| 结构化模型图片输出 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 提供方适配器在发出已完成的图片块或助手消息事件前提交字节。事件中禁止出现临时 URL、路径和 base64。 | + +框架持有的 chat store 保存每个会话的草稿文本和有序附件标识符,`ConversationService` 则持有相应的浏览器专用 `File` 与对象 URL 注册表: + +```ts +export {} + +interface ChatStoreState { + selection: object | null + draft: string + imageIds: string[] + view: string | null +} + +interface ComposerAttachment { + id: string + file: File + previewUrl: string +} +``` + +这一拆分让 UI 状态通过 slots 框架的 store 席位和绑定 actions 使用唯一的订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。草稿文本和有序图片标识符继续使用 `localStorage`;重载后,`ConversationRoot` 会清理缺少对应运行时对象的标识符。未发送图片因此无法跨重载保留,因为浏览器 `File` 与对象 URL 不具备持久性。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 + +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 + +第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。 + +### 持久内容与提示词协议 + +附件服务边界公开不可变图片写入和经过校验的读取操作。规范元数据刻意比通用文件记录更窄: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type AttachmentId = Branded<'AttachmentId'> + +interface ImageAttachmentRef { + attachmentId: AttachmentId + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + bytes: number + width: number + height: number + name?: string +} + +interface ImageBlock { + type: 'image' + attachment: ImageAttachmentRef +} +``` + +`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此,会话事件与不可变对象存储足以共同重建模型可见的确切图片。 + +浏览器无法生成持久引用,因此 `session.prompt` 接受范围狭窄的接收联合类型,而不是规范 `ContentBlock[]`: + +```ts +export {} + +type PromptInputPart = + | { type: 'text'; text: string } + | { + type: 'image' + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + data: string + name?: string + } +``` + +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 + +`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。客户端在服务生命周期内,以会话和附件标识符为键缓存生成的对象 URL,并在释放时撤销它。 + +### 模型能力与提供方行为 + +模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 + +宿主是权威的前置检查边界。如果所选模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。在所有 Web 入口路径都能一致公开模型选择后,可以在 UI 中增加粘贴时立即拒绝的反馈。 + +Pi-AI 适配器是首条视觉输入路径:它通过 `ctx.attachments` 解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 + +核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 + +token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为文本计数。提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 + +### 历史渲染与原图预览 + +历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片在叙述流中靠前端对齐。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。 + +输入区缩略图和每个 `MessageImage` 各自持有临时原图预览状态,并调用同一个纯 `ImageLightbox`。模态框使用已经解析的原始对象 URL,只限制显示尺寸;它会聚焦关闭控件,并在关闭时把焦点恢复到先前的目标。 + +### 限制与信任边界 + +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。 + +格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 + +### 包与接口变更 + +| 接口 | 职责 | +| --- | --- | +| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 | +| `packages/attachment/attachment-local` | 私有内容寻址存储、图片头校验、完整性校验和配置。 | +| `packages/llm/llm` 和 `packages/llm/token-meter` | 角色无关的 `ImageBlock`、模态元数据和图片成本估算。 | +| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | +| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/host/apiproxy` 和 `packages/host/runtime` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查。 | +| `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、音频与视频、持久草稿暂存、输出提供方认证和按引用感知的垃圾回收提出方案。 + +预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 + +## 曾考虑的替代方案 + +### 将每张粘贴图片保留在 `/var` 或其他临时目录中 + +临时存储适合在发送前使用,也适合通过操作系统接收剪贴板文件的原生客户端。但它不适合在消息被接受后继续使用:清理不在 harness 的控制范围内,路径因宿主而异,恢复或 fork 后的会话也可能比文件存在得更久。提案允许临时暂存,但会在追加事件前将已接受的字节复制进 `DSH_HOME`。 + +### 粘贴或拖放后立即持久化 + +立即持久化可以让草稿在重载后继续存在,但会在会话或消息持有对象前就创建持久对象,因此必须定义配额、遗留对象生命周期和清理策略。第一版保持未发送草稿为临时状态,并把发送被接受作为持久性边界。 + +### 在消息与会话日志中内联 base64 + +这种方式会在 RPC、事件、历史分页、fork、压缩(compaction)和浏览器存储中复制二进制数据,还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。 + +### 使用浏览器对象 URL、本地路径或提供方 URL 作为规范内容 + +对象 URL 会随文档失效,本地路径不可移植,提供方 URL 则可能过期、跟踪查看者或暴露凭据。它们只能作为临时传输或预览细节存在。 + +### 用一个通用 `AttachmentBlock` 表示图片、文件、音频和视频 + +输入区展示可以使用通用附件栏,但提供方语义取决于具体模态。图片是原生多模态输入;PDF 可能是提供方文件或提取后的文本;视频可能由模型原生支持、抽帧处理或不受支持。特定的 `ImageBlock` 会迫使每个消费方明确处理或拒绝该模态。 + +### 依赖 UI 能力检查或静默过滤图片 + +UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项,UI 检查则是可选的提前反馈。 + +## 验收标准 + +- 在两种输入区中粘贴或拖放一张或多张受支持的图片时,文本框上方会按顺序显示可移除缩略图,且不更改文本框内容;拖入时会高亮目标,放置不支持的文件也不会触发页面跳转,仅图片的发送可正常工作。 +- 未发送的浏览器图片仅以 `File` 与对象 URL 的形式存在,可以在内存中跨会话切换保留,不进入 `localStorage`,并在移除、发送被接受或服务释放后撤销。 +- 每张已接受的用户图片都会提交到解析所得 `DSH_HOME` 下,之后才会追加相应的 `user/message` 事件。事件只包含 `ImageBlock` 引用,绝不包含 base64 或临时路径。 +- 结构化助手图片只能由持久 `ImageBlock` 表示;未来的输出适配器必须在发出助手事件前持久化字节,而 Markdown 图片 URL 仍是文本。 +- 冷启动历史记录通过同一个有界控件渲染用户与助手图片引用。双击打开原图;Escape、激活背景区域和激活关闭控件可以关闭预览,且不提供自定义上下文菜单。 +- 除非同一个会话日志引用了该标识符,否则会话附件读取会失败。对象缺失或损坏会明确失败,绝不返回未经校验的字节。 +- Pi-AI 会为兼容路径生成提供方原生输入图片。DeepSeek 与所有未实现该能力的消费方返回明确的不支持内容错误,而不是丢弃该块。 +- 明确仅支持文本的当前模型会在持久化附件或追加会话事件前拒绝图片发送;未知元数据仍会到达适配器强制检查,发送失败则恢复草稿。 +- 无需密钥的单元测试、宿主集成测试、客户端集成测试和组装应用的 Chromium 覆盖会验证持久化顺序、日志中不含 base64、授权、粘贴与拖放、仅图片发送、历史用户与助手图片、原图预览和对象 URL 清理。 +- 当前生产适配器集合声明仅支持文本输出;输出提供方认证、文件选择、非图片文件、视频、持久草稿和垃圾回收不在第一版范围内。 + +## 风险 + +- 持久存储会在没有垃圾回收时持续增长。第一版选择回放安全,而不是过早删除。 +- 对象缺失或损坏会让模型请求无法精确重建。明确失败可以保持完整性,但在修复前可能阻止该会话继续运行。 +- JSON-RPC base64 会增加上传内存,并带来约三分之一的编码开销。第一版的限制可以约束开销;更大的媒体需要流式传输或二进制传输协议。 +- 未发送图片无法跨重载保留。持久草稿需要配额和遗留对象清理,而不是隐式复用消息存储。 +- 原图预览解码的像素多于行内控件显示的像素。像素限制、一次只打开一个预览和对象 URL 释放可以约束但无法消除浏览器瞬时内存占用。 +- 能力元数据可能缺失或陈旧。宿主前置检查可以改善反馈,适配器强制检查仍是权威结果。 +- 未来输出提供方可能需要经过身份认证的下载,助手图片才能完成,这会增加延迟与新的故障点。先持久化再追加事件的顺序优先保障回放完整性。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index b75716c615..e3ee2badd6 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -3,8 +3,8 @@ // chromium. First describe: manifest injection + fail-loud half. Second // describe: the settled success pass — five REAL tsdown bundles (the // infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation -// round lands in smoke-real under the W5 real-host standard. +// and the three-column frame appears in one flip. The full eight-plugin pass +// also exercises durable history images without a model key. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -17,21 +17,24 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass (the production Web plugin chain). */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ const ROWS: WebPluginBootEntry[] = [ - { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] }, + { id: '@deepseek-ai/dsh-client-ui-theme', url: '/plugins/@deepseek-ai/dsh-client-ui-theme/client.js', inject: [] }, { id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] }, ] -const LAYOUT_BUNDLE = bundlePath('ui-layout') +const LIVE_BUNDLE = bundlePath('ui-theme') describe('web boot chain (keyless, real carrier)', () => { let server: Awaited> @@ -50,7 +53,7 @@ describe('web boot chain (keyless, real carrier)', () => { apiHandler, webPlugins: { snapshot: () => ROWS, - clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined), + clientPath: id => (id === ROWS[0]!.id ? LIVE_BUNDLE : undefined), }, }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() @@ -91,7 +94,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, production plugin chain, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -143,6 +146,88 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) + it('renders historical user and assistant images and opens the original on double-click', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-fixture-images')) + await page.getByRole('treeitem', { name: /fixture 3 sessions/ }).click() + await page.locator('[role="treeitem"][aria-selected]').first().click() + await page.waitForSelector('text=历史用户图片', { timeout: 15_000 }) + await page.waitForSelector('text=结构化模型图片', { timeout: 15_000 }) + const images = page.getByTitle('双击查看原图') + await expect.poll(() => images.count()).toBeGreaterThanOrEqual(2) + const first = images.first() + const box = await first.boundingBox() + expect(box?.width).toBeLessThanOrEqual(240) + expect(box?.height).toBeLessThanOrEqual(240) + await first.dblclick() + const preview = page.getByRole('dialog', { name: '原图预览' }) + await preview.waitFor({ state: 'visible' }) + await page.keyboard.press('Escape') + await preview.waitFor({ state: 'detached' }) + }) + + it('pastes and drops images into the composer, then sends them as durable history', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-fixture-image-paste')) + await page.getByRole('button', { name: '停止' }).click() + const textarea = page.locator('textarea') + await textarea.waitFor({ state: 'visible' }) + await expect.poll(() => textarea.isEnabled()).toBe(true) + await textarea.evaluate((element) => { + const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==') + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)) + const transfer = new DataTransfer() + transfer.items.add(new File([bytes], 'clipboard.png', { type: 'image/png' })) + element.dispatchEvent(new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: transfer, + })) + }) + + const rail = page.getByLabel('待发送图片') + await rail.waitFor({ state: 'visible' }) + const draftImage = rail.getByTitle('双击查看原图') + await draftImage.dblclick() + const preview = page.getByRole('dialog', { name: '原图预览' }) + await preview.waitFor({ state: 'visible' }) + await page.keyboard.press('Escape') + await preview.waitFor({ state: 'detached' }) + + await page.getByRole('button', { name: '发送' }).click() + await rail.waitFor({ state: 'detached' }) + await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(3) + + await page.getByRole('button', { name: '停止' }).click() + await expect.poll(() => textarea.isEnabled()).toBe(true) + await textarea.evaluate((element) => { + const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==') + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)) + const transfer = new DataTransfer() + transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' })) + element.closest('[class*="card"]')?.dispatchEvent(new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })) + }) + await page.getByRole('status').filter({ hasText: '松开以添加图片' }).waitFor({ state: 'visible' }) + await textarea.evaluate((element) => { + const binary = atob('iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==') + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)) + const transfer = new DataTransfer() + transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' })) + element.closest('[class*="card"]')?.dispatchEvent(new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + })) + }) + await rail.waitFor({ state: 'visible' }) + await rail.getByAltText('dropped.png').waitFor({ state: 'visible' }) + await page.getByRole('button', { name: '发送' }).click() + await rail.waitFor({ state: 'detached' }) + await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(4) + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 54951fd9b4..1abae21813 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -7,10 +7,14 @@ A service can be a core spine service, a swappable capability seam, or a bundle/ ```mermaid flowchart LR + pkg_attachment["attachment"] + svc_attachments["ctx.attachments
Durable binary attachment storage"] + pkg_attachment_local["attachment-local"] + pkg_host_runtime["host-runtime"] + pkg_llm_pi_ai["llm-pi-ai"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_deepseek["llm-deepseek"] - pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compact_basic["compact-basic"] @@ -125,6 +129,8 @@ flowchart LR pkg_agent --> svc_agents pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval + pkg_attachment --> svc_attachments + pkg_attachment_local --> svc_attachments pkg_bash --> svc_bash pkg_bash_local --> svc_bash pkg_bash_sandbox --> svc_bash @@ -189,6 +195,8 @@ flowchart LR svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools + svc_attachments --> pkg_host_runtime + svc_attachments --> pkg_llm_pi_ai svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash @@ -263,6 +271,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`host-runtime`](../packages/host/runtime), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4d2c803de0..ccd25885a8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -193,6 +193,26 @@ Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfi Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examples/agent-spine-demo/src/index.ts) +## `@deepseek-ai/dsh-attachment-local` + +```ts config-catalog +/** Local attachment backend configuration. */ +export interface Config { + /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Maximum encoded bytes accepted for one image. */ + maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number + /** Maximum intrinsic width multiplied by height accepted for one image. */ + maxImagePixels?: number +} +``` + +Source: [`packages/attachment/attachment-local/src/index.ts:26`](../packages/attachment/attachment-local/src/index.ts) + ## `@deepseek-ai/dsh-bash-local` ```ts config-catalog @@ -1881,6 +1901,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0fcb2fc28e..51b6aea5a1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -248,6 +248,30 @@ Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalReques Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts) +## `ctx.attachments` — `AttachmentStore` (abstract seam) + +Immutable binary attachment service. Implementations validate bytes before publishing a reference. + +```ts cordis-catalog +/** + * Validate and durably commit one image before its owning session event is appended. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns a durable content-addressed reference. + */ +abstract saveImage(input: SaveImageAttachment): Promise + +/** + * Read one image and verify that bytes still match the recorded reference. + * @param ref - durable reference from the session log. + * @returns the verified bytes and canonical reference. + */ +abstract readImage(ref: ImageAttachmentRef): Promise +``` + +Types: [ImageAttachmentRef](../core-data-structures/attachment.md) · [SaveImageAttachment](../core-data-structures/attachment.md) · [StoredImageAttachment](../core-data-structures/attachment.md) + +Source: [`packages/attachment/attachment/src/index.ts:28`](../../packages/attachment/attachment/src/index.ts) + ## `ctx.bash` — `BashExecutor` (abstract seam) Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). @@ -1493,7 +1517,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:87`](../../packages/llm/token-meter/src/index.ts) ## `ctx.toolResultPrune` — `ToolResultPruneService` diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md new file mode 100644 index 0000000000..03af4a557d --- /dev/null +++ b/docs/core-data-structures/attachment.md @@ -0,0 +1,70 @@ +# Durable Image Attachments + +The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](../cordis-catalog/services.md#ctxattachments); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. + +Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. + +Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) + +## Identity and verified metadata + +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. + +```ts type-equiv +/** Raster image formats accepted by the version-one attachment path. */ +type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' +``` + +```ts type-equiv +/** Durable, serializable metadata for one immutable image object. */ +interface ImageAttachmentRef { + /** Opaque storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Media type verified from the stored bytes. */ + mediaType: ImageMediaType + /** Exact encoded byte length. */ + bytes: number + /** Intrinsic encoded width in pixels. */ + width: number + /** Intrinsic encoded height in pixels. */ + height: number + /** Optional display name stripped of local path information. */ + name?: string +} +``` + +```ts type-equiv +/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +interface ImageAttachmentLimits { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ImageMediaType[] +} +``` + +The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. + +## Commit and verified-read payloads + +```ts type-equiv +/** Request to validate and durably commit one image. */ +interface SaveImageAttachment { + data: Uint8Array + /** Caller-declared media type, checked against magic bytes. */ + mediaType: ImageMediaType + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} +``` + +```ts type-equiv +/** Stored image bytes returned after reference and digest verification. */ +interface StoredImageAttachment { + ref: ImageAttachmentRef + data: Uint8Array +} +``` + +`saveImage()` validates bytes and atomically commits one object before returning its reference. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6b5596b782..a65eb2056e 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -28,6 +28,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | +| [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | | [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | @@ -106,12 +107,13 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md) plus optional alternative text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. A `Message` is a role plus blocks. Loop-derived assistant messages carry their durable provider/model identity and optional adapter-private replay metadata: @@ -192,6 +194,10 @@ interface LlmModelInfo { name: string /** Optional user-facing distinction from otherwise similar models. */ description?: string + /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ + inputModalities?: readonly ModelModality[] + /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ + outputModalities?: readonly ModelModality[] } ``` diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 257ce90cda..f3b82801a2 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -208,6 +208,7 @@ declare abstract class LlmAdapter { interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } diff --git a/knip.json b/knip.json index 3a7a443718..3dd84847d9 100644 --- a/knip.json +++ b/knip.json @@ -197,6 +197,11 @@ "src/**/*.ts" ] }, + "packages/attachment/attachment": { + "project": [ + "src/**/*.ts" + ] + }, "packages/util/timeout": { "entry": [ "tests/**/*.spec.ts" diff --git a/packages/README.md b/packages/README.md index b138ea0b65..46a0478654 100644 --- a/packages/README.md +++ b/packages/README.md @@ -24,6 +24,7 @@ Packages live at `packages///`; groups are containers, while names r | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | +| [`attachment/`](attachment/README.md) | Durable attachment seam and DSH_HOME backend | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | diff --git a/packages/attachment/README.md b/packages/attachment/README.md new file mode 100644 index 0000000000..de975aa2f1 --- /dev/null +++ b/packages/attachment/README.md @@ -0,0 +1,10 @@ +# attachment/ - durable attachment capability family + +The durable binary attachment seam and its local filesystem implementation. Both are product packages. + +| Package | Role | ctx key | +|---|---|---| +| `attachment/` | Immutable attachment references, image limits, and storage service | `ctx.attachments` | +| `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) | + +Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output. diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md new file mode 100644 index 0000000000..21883bc9ff --- /dev/null +++ b/packages/attachment/attachment-local/README.md @@ -0,0 +1,19 @@ +# @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. + +`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. + +## Model Experience + +Indirectly, through durable replay of historical user images and structured model image output after restart and fork. + +#### KV Cache effect + +None beyond the image block owned by the requesting adapter. + +## Known Limitations and Deferred Work + +- Objects are retained indefinitely; reference-aware garbage collection is deferred. +- The local backend assumes the host and provider adapter share this filesystem service. +- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned. diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json new file mode 100644 index 0000000000..b87fd83e79 --- /dev/null +++ b/packages/attachment/attachment-local/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-attachment-local", + "description": "Private content-addressed DSH_HOME attachment storage", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { "schemastery": "^3.18.0" }, + "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts new file mode 100644 index 0000000000..59ddb4ebda --- /dev/null +++ b/packages/attachment/attachment-local/src/image.ts @@ -0,0 +1,101 @@ +/** Minimal raster header validation used before bytes enter durable storage. */ + +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' + +/** Decoded metadata from a supported image header. */ +export interface DetectedImage { + mediaType: ImageMediaType + width: number + height: number +} + +function ascii(data: Uint8Array, start: number, value: string): boolean { + 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) +} + +function u16le(data: Uint8Array, offset: number): number { + return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) +} + +function u24le(data: Uint8Array, offset: number): number { + return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) | ((data[offset + 2] ?? 0) << 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 +} + +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 +} + +function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage { + if (width < 1 || height < 1) throw new AttachmentError('Image dimensions must be positive.', 'INVALID_IMAGE') + return { mediaType, width, height } +} + +function jpeg(data: Uint8Array): DetectedImage | null { + if (data[0] !== 0xff || data[1] !== 0xd8) return null + const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) + let offset = 2 + while (offset + 3 < data.length) { + while (data[offset] === 0xff) offset++ + const marker = data[offset] + if (marker === undefined || marker === 0xd9 || marker === 0xda) break + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset++ + continue + } + const length = u16be(data, offset + 1) + if (length < 2 || offset + 1 + length > data.length) throw new AttachmentError('JPEG data is truncated.', 'INVALID_IMAGE') + if (sof.has(marker)) { + if (length < 7) throw new AttachmentError('JPEG dimensions are truncated.', 'INVALID_IMAGE') + return dimensions(u16be(data, offset + 6), u16be(data, offset + 4), 'image/jpeg') + } + offset += length + 1 + } + throw new AttachmentError('JPEG dimensions are missing.', 'INVALID_IMAGE') +} + +/** + * Detect a supported raster type and intrinsic dimensions from encoded bytes. + * @param data - complete encoded image bytes. + * @returns verified format and dimensions. + */ +export function detectImage(data: Uint8Array): DetectedImage { + if (data.length >= 24 + && data[0] === 0x89 && ascii(data, 1, 'PNG\r\n\u001a\n') && ascii(data, 12, 'IHDR')) { + return dimensions(u32be(data, 16), u32be(data, 20), 'image/png') + } + if (data.length >= 10 && (ascii(data, 0, 'GIF87a') || ascii(data, 0, 'GIF89a'))) { + return dimensions(u16le(data, 6), u16le(data, 8), 'image/gif') + } + const detectedJpeg = jpeg(data) + if (detectedJpeg !== null) return detectedJpeg + if (data.length >= 30 && ascii(data, 0, 'RIFF') && ascii(data, 8, 'WEBP')) { + const declaredLength = u32le(data, 4) + 8 + 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 + 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) { + return dimensions(u16le(data, 26) & 0x3fff, u16le(data, 28) & 0x3fff, 'image/webp') + } + throw new AttachmentError('WebP dimensions are missing.', 'INVALID_IMAGE') + } + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') +} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts new file mode 100644 index 0000000000..1c8e923867 --- /dev/null +++ b/packages/attachment/attachment-local/src/index.ts @@ -0,0 +1,74 @@ +/** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */ + +import { join, resolve } from 'node:path' +import { Context } from 'cordis' +import z from 'schemastery' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { readImageFile, saveImageFile } from './store.ts' + +export { detectImage } from './image.ts' +export { readImageFile, saveImageFile } from './store.ts' +export { AttachmentError } from '@deepseek-ai/dsh-attachment' +export type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' + +/** Default maximum encoded bytes for one image. */ +export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Default maximum images in one prompt. */ +export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10 +/** Default maximum aggregate image bytes in one prompt. */ +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024 +/** Default maximum intrinsic pixels for one image. */ +export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 + +/** Local attachment backend configuration. */ +export interface Config { + /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Maximum encoded bytes accepted for one image. */ + maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number + /** Maximum intrinsic width multiplied by height accepted for one image. */ + maxImagePixels?: number +} + +/** Persistent content-addressed local attachment store. */ +export class LocalAttachmentStore extends AttachmentStore { + static Config: z = z.object({ + dshHome: z.string(), + maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES), + maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), + maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), + maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), + }) + + /** Absolute versioned storage root. */ + readonly root: string + readonly imageLimits: ImageAttachmentLimits + + constructor(ctx: Context, config: Config) { + super(ctx) + this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) + this.imageLimits = Object.freeze({ + maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, + maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, + mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), + }) + } + + async saveImage(input: SaveImageAttachment): Promise { + return saveImageFile(this.root, input, this.imageLimits) + } + + async readImage(ref: ImageAttachmentRef): Promise { + return readImageFile(this.root, ref, this.imageLimits) + } +} + +export default LocalAttachmentStore diff --git a/packages/attachment/attachment-local/src/invariant.ts b/packages/attachment/attachment-local/src/invariant.ts new file mode 100644 index 0000000000..eb14a84af6 --- /dev/null +++ b/packages/attachment/attachment-local/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local' +/** Cordis companion plugin name. */ +export const name = 'attachment-local-invariant' +/** Services required before package ownership can be reserved. */ +export const inject = ['invariants', 'attachments'] +/** No runtime invariant: immutable writes and verified reads are enforced directly at the backend boundary. */ +const install: InvariantInstaller = () => {} +/** + * Register the package invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the registration disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts new file mode 100644 index 0000000000..24fa3645be --- /dev/null +++ b/packages/attachment/attachment-local/src/store.ts @@ -0,0 +1,121 @@ +/** Content-addressed, owner-private local attachment storage. */ + +import { createHash, randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { + AttachmentError, + AttachmentId, +} from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import { detectImage } from './image.ts' + +const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ + +function digest(data: Uint8Array): string { + return createHash('sha256').update(data).digest('hex') +} + +function displayName(value: string | undefined): string | undefined { + if (value === undefined) return undefined + const clean = basename(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) + return clean === '' ? undefined : clean +} + +function objectPath(root: string, sha256: string): string { + return join(root, 'objects', sha256.slice(0, 2), sha256) +} + +function ensureReference(ref: ImageAttachmentRef): string { + const match = ID_PATTERN.exec(String(ref.attachmentId)) + if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') + return match[1] +} + +function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits): 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 } +} + +/** + * Save and verify immutable image bytes below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - encoded bytes and declared metadata. + * @param limits - resolved storage policy. + * @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 sha256 = digest(input.data) + const bucket = join(root, 'objects', sha256.slice(0, 2)) + const staging = join(root, 'tmp') + await mkdir(bucket, { recursive: true, mode: 0o700 }) + await mkdir(staging, { recursive: true, mode: 0o700 }) + await chmod(bucket, 0o700) + await chmod(staging, 0o700) + const temporary = join(staging, randomUUID()) + const target = objectPath(root, sha256) + let handle + try { + handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) + await handle.writeFile(input.data) + await handle.sync() + await handle.close() + handle = undefined + try { + await link(temporary, target) + } catch (error) { + 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 + }) + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } + const name = displayName(input.name) + return { + attachmentId: AttachmentId(`sha256:${sha256}`), + ...metadata, + ...(name !== undefined ? { name } : {}), + } +} + +/** + * 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 { + const sha256 = ensureReference(ref) + let data: Uint8Array + try { + data = new Uint8Array(await readFile(objectPath(root, sha256))) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') + 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) + 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') + } + return { ref, data } +} diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts new file mode 100644 index 0000000000..9fff7d651e --- /dev/null +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -0,0 +1,96 @@ +import { createHash } from 'node:crypto' +import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import { readImageFile, saveImageFile } from '../src/store.ts' + +const PNG = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +)) + +const LIMITS: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2048, + maxImagePixels: 16, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], +} + +const roots: string[] = [] + +async function root(): Promise { + const value = await mkdtemp(join(tmpdir(), 'dsh-attachment-')) + roots.push(value) + return join(value, 'attachments', 'v1') +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +describe('local attachment store', () => { + it('publishes one private content-addressed object and deduplicates equal bytes', async () => { + const storageRoot = await root() + const first = await saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png', + }, LIMITS) + const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const sha256 = createHash('sha256').update(PNG).digest('hex') + const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + + expect(first).toEqual({ + attachmentId: `sha256:${sha256}`, + mediaType: 'image/png', + bytes: PNG.byteLength, + width: 1, + height: 1, + name: 'pixel.png', + }) + expect(second.attachmentId).toBe(first.attachmentId) + 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 }) + }) + + it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => { + const storageRoot = await root() + await expect(saveImageFile(storageRoot, { + data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + await expect(saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/jpeg', + }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' }) + await expect(saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', + }, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + + const wide = PNG.slice() + wide.set([0, 0, 0, 5, 0, 0, 0, 5], 16) + await expect(saveImageFile(storageRoot, { + data: wide, mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + }) + + it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { + const storageRoot = await root() + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const sha256 = String(ref.attachmentId).slice('sha256:'.length) + 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)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }, LIMITS)) + .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) + + const missingRoot = await root() + await mkdir(missingRoot, { recursive: true }) + await expect(readImageFile(missingRoot, ref, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + }) +}) diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json new file mode 100644 index 0000000000..528649e3a4 --- /dev/null +++ b/packages/attachment/attachment-local/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib/types" }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../attachment" }, + { "path": "../../util/paths" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md new file mode 100644 index 0000000000..2fcc99c477 --- /dev/null +++ b/packages/attachment/attachment/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-attachment + +The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. + +Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata. + +## Model Experience + +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference. + +#### KV Cache effect + +Adding an image changes the provider request and therefore invalidates the affected request suffix. + +## Known Limitations and Deferred Work + +- Version one accepts PNG, JPEG, WebP, and GIF only. +- Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects. +- Generic files, audio, video, and persistent unsent drafts require separate lifecycle and provider contracts. diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json new file mode 100644 index 0000000000..1b8394774e --- /dev/null +++ b/packages/attachment/attachment/package.json @@ -0,0 +1,27 @@ +{ + "name": "@deepseek-ai/dsh-attachment", + "description": "Durable immutable attachment storage seam for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts new file mode 100644 index 0000000000..000856be78 --- /dev/null +++ b/packages/attachment/attachment/src/index.ts @@ -0,0 +1,51 @@ +/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ + +import { Context, Service } from 'cordis' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from './types.ts' + +export { AttachmentError, AttachmentId } from './types.ts' +export type { + AttachmentId as AttachmentIdType, + ImageAttachmentLimits, + ImageAttachmentRef, + ImageMediaType, + SaveImageAttachment, + StoredImageAttachment, +} from './types.ts' + +declare module 'cordis' { + interface Context { + attachments: AttachmentStore + } +} + +/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */ +export abstract class AttachmentStore extends Service { + constructor(ctx: Context) { + super(ctx, 'attachments') + } + + /** Deployment-resolved image policy used by authoritative and fast-path validation. */ + abstract readonly imageLimits: ImageAttachmentLimits + + /** + * Validate and durably commit one image before its owning session event is appended. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns a durable content-addressed reference. + */ + abstract saveImage(input: SaveImageAttachment): Promise + + /** + * Read one image and verify that bytes still match the recorded reference. + * @param ref - durable reference from the session log. + * @returns the verified bytes and canonical reference. + */ + abstract readImage(ref: ImageAttachmentRef): Promise +} + +export default AttachmentStore diff --git a/packages/attachment/attachment/src/invariant.ts b/packages/attachment/attachment/src/invariant.ts new file mode 100644 index 0000000000..2c00d56ece --- /dev/null +++ b/packages/attachment/attachment/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-attachment' +/** Cordis companion plugin name. */ +export const name = 'attachment-invariant' +/** Service required before package ownership can be reserved. */ +export const inject = ['invariants'] +/** No runtime invariant: this stateless seam owns types while implementations enforce immutable-store checks. */ +const install: InvariantInstaller = () => {} +/** + * Register the package invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the registration disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts new file mode 100644 index 0000000000..88b1dceb52 --- /dev/null +++ b/packages/attachment/attachment/src/types.ts @@ -0,0 +1,75 @@ +/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque content-addressed identifier for one immutable attachment object. */ +export type AttachmentId = Branded<'AttachmentId'> + +/** + * Brand a validated storage identifier. + * @param value - backend-produced opaque identifier. + * @returns the branded identifier. + */ +export function AttachmentId(value: string): AttachmentId { + return value as AttachmentId +} + +/** Raster image formats accepted by the version-one attachment path. */ +export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + +/** Durable, serializable metadata for one immutable image object. */ +export interface ImageAttachmentRef { + /** Opaque storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Media type verified from the stored bytes. */ + mediaType: ImageMediaType + /** Exact encoded byte length. */ + bytes: number + /** Intrinsic encoded width in pixels. */ + width: number + /** Intrinsic encoded height in pixels. */ + height: number + /** Optional display name stripped of local path information. */ + name?: string +} + +/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +export interface ImageAttachmentLimits { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ImageMediaType[] +} + +/** Request to validate and durably commit one image. */ +export interface SaveImageAttachment { + data: Uint8Array + /** Caller-declared media type, checked against magic bytes. */ + mediaType: ImageMediaType + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + +/** Stored image bytes returned after reference and digest verification. */ +export interface StoredImageAttachment { + ref: ImageAttachmentRef + data: Uint8Array +} + +/** Stable failures suitable for host RPC error mapping. */ +export class AttachmentError extends Error { + /** Stable machine-routing failure code. */ + readonly code: string + + /** + * @param message - human-readable failure description without raw bytes or host paths. + * @param code - stable machine-routing code. + * @param options - optional chained cause. + */ + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options) + this.name = 'AttachmentError' + this.code = code + } +} diff --git a/packages/attachment/attachment/tsconfig.json b/packages/attachment/attachment/tsconfig.json new file mode 100644 index 0000000000..e9edd57b9e --- /dev/null +++ b/packages/attachment/attachment/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib/types" }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 778b431b71..c83100542b 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -29,6 +29,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 9ea6ba6dfe..cf4c9c2ac3 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -6,7 +6,7 @@ // The ./api and ./client subpath exports are the browser-safe channels added for this. export type { - ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7d4a93e888..ab0c45129d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -6,6 +6,7 @@ // approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, @@ -28,6 +29,16 @@ function sid(id: string): SessionId { return id as SessionId } +const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==' +const FIXTURE_IMAGE_REF: ImageAttachmentRef = { + attachmentId: 'fixture:image' as AttachmentIdType, + mediaType: 'image/png', + bytes: 68, + width: 160, + height: 90, + name: 'fixture-image.png', +} + /** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / steering / context. */ function buildAlphaLog(): SessionEvent[] { @@ -88,6 +99,12 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录') + push({ type: 'turn/start', data: { turn: 63, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')], source: { kind: 'user' } } }) + push({ type: 'step/start', data: { turn: 63, step: 0 } }) + push({ type: 'assistant/message', surfaceOp: 'append', data: { turn: 63, step: 0, content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], provenance: { provider: 'fixture', model: 'fx-vision' } } }) + push({ type: 'step/end', data: { turn: 63, step: 0 } }) + push({ type: 'turn/end', data: { turn: 63, reason: { kind: 'completed' } } }) return events as unknown as SessionEvent[] } @@ -186,6 +203,18 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Fixture mirror of host session-scoped attachment authorization. */ +function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean { + const visit = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(visit) + if (typeof value !== 'object' || value === null) return false + const record = value as Record + if (record.attachmentId === attachmentId) return true + return Object.values(record).some(visit) + } + return log.some(event => visit(event.data)) +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -243,7 +272,11 @@ export function createFixtureApi(): ApiProxy { { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) - const nextTurn = new Map([[sid('fx-alpha'), 60]]) + const nextTurn = new Map([[sid('fx-alpha'), 64]]) + const attachments = new Map([[ + String(FIXTURE_IMAGE_REF.attachmentId), + { attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA }, + ]]) let nextSession = 1 let nextRpc = 1 const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) @@ -394,21 +427,44 @@ export function createFixtureApi(): ApiProxy { } summary.updatedAt = Date.now() const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') + const durable: ContentBlock[] = content.map((block) => { + if (block.type === 'text') return block + const attachment: ImageAttachmentRef = { + attachmentId: `fixture:${crypto.randomUUID()}` as AttachmentIdType, + mediaType: block.mediaType, + bytes: Math.max(1, Math.floor(block.data.length * 3 / 4) - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0)), + width: 160, + height: 90, + ...block.name === undefined ? {} : { name: block.name }, + } + attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) + return { type: 'image', attachment } + }) if (mode === 'steer' && replays.has(id)) { // Steering: insert a steering message into the current turn; the replay continues. /* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */ const turn = (nextTurn.get(id) ?? 1) - 1 - append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } }) + append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content: durable, source: { kind: 'user' } } }) return ok(request, { accepted: true as const }) } const turn = nextTurn.get(id) ?? 0 nextTurn.set(id, turn + 1) setRunning(id, true) append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) - append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } }) + append(id, { type: 'user/message', surfaceOp: 'append', data: { content: durable, source: { kind: 'user' } } }) startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`) return ok(request, { accepted: true as const }) }, + attachment: (request) => { + const stored = attachments.get(String(request.payload.attachmentId)) + if (stored === undefined) { + return err(request, { code: 'attachment-error', message: 'fixture attachment missing', details: { reason: 'ATTACHMENT_NOT_FOUND' } }) + } + if (!logReferencesAttachment(logs.get(request.payload.sessionId) ?? [], String(request.payload.attachmentId))) { + return err(request, { code: 'attachment-error', message: 'fixture attachment is not referenced by this session', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }) + } + return ok(request, stored) + }, cancel: (request) => { const replay = replays.get(request.payload.sessionId) if (replay !== undefined) { @@ -421,7 +477,24 @@ export function createFixtureApi(): ApiProxy { }, }, host: { - describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), + describe: request => ok(request, { + version: '0.0.0-fixture', + cwd: '/tmp/fixture', + provider: 'fixture', + model: 'fx-vision', + activeModel: { + provider: 'fixture', id: 'fx-vision', name: 'Fixture Vision', + inputModalities: ['text', 'image'], outputModalities: ['text', 'image'], + }, + imageLimits: { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 10, + maxMessageImageBytes: 20 * 1024 * 1024, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], + }, + attachedSessions: 1, + }), }, events: { async *mux(_request, signal) { @@ -512,6 +585,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.create': return this.api.sessions.create(request) case 'session.history': return this.api.sessions.history(request) case 'session.prompt': return this.api.sessions.prompt(request) + case 'session.attachment': return this.api.sessions.attachment(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b017d1c9e2..fd6c5c3345 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -14,7 +14,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..3bb964b7a5 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -48,6 +48,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -64,6 +66,7 @@ export class FakeApiClient implements IApiClient { history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..6b81f4ef95 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -167,7 +167,7 @@ describe('createFixtureApi', () => { expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics) }) - it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { + it('steer with no replay in flight promotes image bytes to a session-scoped reference', async () => { const api = createFixtureApi() const abort = new AbortController() const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, @@ -175,14 +175,36 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') - // steer while idle + a non-text content block (covers the '' arm of the text join). + // steer while idle + an image: the fixture mirrors the host's durable send boundary. await api.sessions.prompt(req({ sessionId: created.result.value.sessionId, mode: 'steer' as const, - content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never], + content: [{ type: 'text' as const, text: '短' }, { + type: 'image' as const, + mediaType: 'image/png' as const, + data: 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==', + name: 'pixel.png', + }], })) const frames = await framesPromise const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert + const user = frames.find((f): f is Extract => + f.type === 'session/event' && f.event.type === 'user/message') + const image = ((user?.event.data as { content?: { type: string; attachment?: { attachmentId: never } }[] } | undefined)?.content) + ?.find(block => block.type === 'image') + expect(image?.attachment).toBeDefined() + if (image?.attachment === undefined) throw new Error('fixture image missing') + const loaded = await api.sessions.attachment(req({ + sessionId: created.result.value.sessionId, + attachmentId: image.attachment.attachmentId, + })) + expect(loaded.result).toMatchObject({ ok: true, value: { attachment: { name: 'pixel.png' } } }) + const denied = await api.sessions.attachment(req({ + sessionId: sid('fx-beta'), attachmentId: image.attachment.attachmentId, + })) + expect(denied.result).toMatchObject({ + ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + }) }) it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => { @@ -306,6 +328,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const id = created.result.value.sessionId expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) + expect((await client.sessions.attachment({ sessionId: sid('fx-alpha'), attachmentId: 'fixture:image' as never })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) }) diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 8b0357cf97..5308ef7241 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../llm/llm" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 384b2e2e3c..2d4ece6bbb 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -35,6 +35,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 18c5501972..10b5391faf 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,6 +4,7 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' /** Assistant content blocks sorted by what the UI cares about @@ -11,6 +12,7 @@ import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@ export type AssistantBlock = | { kind: 'text'; text: string } | { kind: 'reasoning'; text: string } + | { kind: 'image'; attachment: ImageAttachmentRef; alt?: string } | { kind: 'tool-call'; callId: string; name: string; argsRaw: string } | { kind: 'other'; block: unknown } @@ -32,6 +34,10 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { switch (block.type) { case 'text': return { kind: 'text', text: block.text } case 'reasoning': return { kind: 'reasoning', text: block.text } + case 'image': return { + kind: 'image', attachment: block.attachment, + ...block.alt === undefined ? {} : { alt: block.alt }, + } case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments } default: return { kind: 'other', block } } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 761aca96c2..53cac6ecc9 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -3,9 +3,9 @@ // created, they keep consuming mux frames in the background; React connects directly via // subscribe/getSnapshot. -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +import type { HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' import type { ObservableSnapshot } from '../contract/store.ts' import type { @@ -82,11 +82,11 @@ export class Session implements ObservableSnapshot { /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - core content blocks verbatim. + * @param content - text plus browser-owned temporary image uploads. * @param mode - queue appends after the current turn; steer interrupts it. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> { + async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> { this.promptError = null this.lastAgentError = null this.notifier.markDirty() @@ -103,6 +103,23 @@ export class Session implements ObservableSnapshot { return result } + /** + * Resolve one image referenced by this session into browser-consumable bytes. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + async readAttachment(attachmentId: AttachmentIdType): Promise> { + try { + const result = (await this.api.sessions.attachment({ sessionId: this.sessionId, attachmentId })).result + if (!result.ok) return result + const binary = atob(result.value.data) + const data = Uint8Array.from(binary, char => char.charCodeAt(0)) + return { ok: true, value: { attachment: result.value.attachment, data } } + } catch (error) { + return transportError(error) + } + } + /** * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). * @returns the cancel result. diff --git a/packages/client/runtime/tests/conversation.spec.ts b/packages/client/runtime/tests/conversation.spec.ts index 7da20bc6f3..345f21b598 100644 --- a/packages/client/runtime/tests/conversation.spec.ts +++ b/packages/client/runtime/tests/conversation.spec.ts @@ -1,22 +1,30 @@ /** Assistant block classifier (moved here with sessions/conversation.ts). */ import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client' import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' describe('toAssistantBlock', () => { it('classifies the four block shapes', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 1, + height: 1, + } const blocks: ContentBlock[] = [ { type: 'text', text: '正文' }, { type: 'reasoning', text: '思考' }, { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock, - { type: 'image', data: 'x' } as unknown as ContentBlock, + { type: 'image', attachment }, ] expect(toAssistantBlocks(blocks)).toEqual([ { kind: 'text', text: '正文' }, { kind: 'reasoning', text: '思考' }, { kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' }, - { kind: 'other', block: blocks[3] }, + { kind: 'image', attachment }, ]) expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' }) }) diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index c13ef09fcb..e8d2c43571 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -51,6 +51,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -67,6 +69,7 @@ export class FakeApiClient implements IApiClient { history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 149f73c1fc..84db7c73cf 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -239,6 +239,21 @@ describe('prompt and cancel errors', () => { expect(result.ok).toBe(false) expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) }) + + it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { + const { api, session } = makeSession() + const result = await session.readAttachment('attachment-1' as never) + expect(result).toEqual({ + ok: true, + value: { + attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: Uint8Array.of(0), + }, + }) + expect(api.callsOf('session.attachment')).toEqual([{ + sessionId: SID, attachmentId: 'attachment-1', + }]) + }) }) describe('pending interactions', () => { diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 9584ecc857..28ace18be3 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 51bdffb2d6..0dc65934f3 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -34,6 +34,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-i18n": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 266602e1b2..8087c42ebb 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -12,7 +12,9 @@ import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh- import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client' import type { SelectionTarget } from './contract/views.ts' -import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts' +import type { + ComposerAttachment, ConversationInjected, DetailsInjected, EmptyStateInjected, +} from './contract/slots.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { ToolViewRegistry } from './toolviews/registry.ts' @@ -92,15 +94,25 @@ export function apply(ctx: Context): void { subscribe: fn => conversation.subscribeViews(fn), version: () => conversation.viewsVersion(), }, - send: (text, mode) => { + addImages: (files) => { + const images = conversation.createDraftImages(files) + actions.addImages(images.map(image => image.id)) + }, + removeImage: (id) => { + conversation.releaseDraftImage(id) + actions.removeImage(id) + }, + draftImages: ids => conversation.draftImages(ids), + send: (text, images: readonly ComposerAttachment[], mode) => { const trimmed = text.trim() - if (trimmed === '') return + if (trimmed === '' && images.length === 0) return // Optimistic clear with failure restore (choreography lives with the // sender; the business failure also lands in snapshot.promptError). - // The store write path stays inside the declared actions set: - // restoreDraft itself no-ops once the user typed something new. + // The store write path stays inside the declared actions set. actions.clearDraft() - void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) }) + void scoped.send(trimmed, mode, images.map(image => image.file)) + .then(() => { conversation.releaseDraftImages(images) }) + .catch(() => { actions.restoreDraft(trimmed, images.map(image => image.id)) }) }, stop: () => { scoped.cancel().catch(() => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 61ac069b1c..cc2875add8 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -8,6 +8,7 @@ import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { ToolRow } from './ToolRow.tsx' +import { ImageGallery, type ImageLoader } from './MessageImage.tsx' import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { @@ -15,6 +16,7 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */ interrupted?: boolean | undefined + loadImage?: ImageLoader } function firstLine(text: string): string { @@ -36,14 +38,17 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { ) } -export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) { +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 // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null default: return @@ -54,3 +59,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
) }) + +function unavailableImage(): Promise { + return Promise.reject(new Error('图片读取服务不可用')) +} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b444e557ec..82379c7448 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -11,8 +11,9 @@ // map but only rows whose own selected bit flipped. import { - memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode, + memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode, } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,6 +25,7 @@ import type { ToolViewResolver } from '../contract/toolview.ts' import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { MessageItem } from './MessageItem.tsx' +import type { ImageLoader } from './MessageImage.tsx' import { PendingCard } from './PendingCard.tsx' import { ToolViewOutlet } from './ToolViewOutlet.tsx' import css from './ChatView.module.css' @@ -32,6 +34,7 @@ import css from './ChatView.module.css' export interface ChatViewDeps { toolviews: ToolViewResolver t: Translate + resolveImage?(sessionId: SessionId, attachment: ImageAttachmentRef): Promise } const FOLLOW_THRESHOLD = 24 @@ -102,16 +105,17 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, /** The streaming partial, isolated so chunk batches re-render only this tail. * onGrow lets the scroll owner follow content the parent never re-renders for. */ -function StreamingTail({ useSession, onGrow }: { +function StreamingTail({ useSession, onGrow, loadImage }: { useSession: UseConversation onGrow: () => void + loadImage: ImageLoader }) { const partial = useSession((s) => s.partial) useLayoutEffect(() => { onGrow() }) if (partial === null) return null - return + return } /** @@ -120,7 +124,7 @@ function StreamingTail({ useSession, onGrow }: { * @returns the ConvViewProps component registered as the chat view. */ export function createChatView(deps: ChatViewDeps): FC { - const { toolviews, t } = deps + const { toolviews, t, resolveImage = unavailableImage } = deps return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) { const useSession = useSessionWide as UseConversation @@ -132,6 +136,10 @@ export function createChatView(deps: ChatViewDeps): FC { const hasMore = useSession((s) => s.hasMore) const loadingOlder = useSession((s) => s.loadingOlder) const selectedCallId = useStore((s) => s.selection?.callId) + const loadImage = useCallback( + attachment => resolveImage(sessionId, attachment), + [resolveImage, sessionId], + ) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) @@ -229,11 +237,11 @@ export function createChatView(deps: ChatViewDeps): FC { } const node: ConversationNode = item.node if (node.kind === 'assistant') { - return + return } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return } return ( @@ -250,7 +258,7 @@ export function createChatView(deps: ChatViewDeps): FC { )} {items.map(renderItem)} - + {runningCalls.length > 0 && (
{runningCalls.map((call) => ( @@ -291,3 +299,7 @@ export function createChatView(deps: ChatViewDeps): FC { ) } } + +function unavailableImage(): Promise { + return Promise.reject(new Error('图片读取服务不可用')) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css b/packages/client/ui-conversation/src/client/chat/MessageImage.module.css new file mode 100644 index 0000000000..e05a6fc625 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.module.css @@ -0,0 +1,53 @@ +.gallery { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: min(240px, 100%); +} + +.gallery[data-align='end'] { + justify-content: flex-end; + align-self: flex-end; +} + +.gallery[data-align='start'] { + justify-content: flex-start; + align-self: flex-start; +} + +.frame { + display: grid; + flex: 0 0 auto; + place-items: center; + min-width: 44px; + min-height: 44px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.frame img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.loading, +.error { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.error { + max-width: 240px; + padding: 10px 12px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 10px; + background: var(--dsw-alias-interactive-bg-hover-danger); + cursor: pointer; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx new file mode 100644 index 0000000000..b4dcd19afd --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { ImageLightbox } from '../skeleton/ImageLightbox.tsx' +import css from './MessageImage.module.css' + +/** Loads a session-authorized durable image URL. */ +export type ImageLoader = (attachment: ImageAttachmentRef) => Promise + +/** Compact history renderer with retryable loading and double-click original preview. */ +export function MessageImage({ attachment, alt, load }: { + attachment: ImageAttachmentRef + alt?: string + load: ImageLoader +}) { + const [src, setSrc] = useState(null) + const [error, setError] = useState(false) + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + const size = useMemo(() => { + const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height) + return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) } + }, [attachment.height, attachment.width]) + + const request = useCallback(() => { + setError(false) + setSrc(null) + void load(attachment).then(setSrc).catch(() => { setError(true) }) + }, [attachment, load]) + + useEffect(() => { + let live = true + setError(false) + void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + return () => { live = false } + }, [attachment, load]) + + const label = alt ?? attachment.name ?? '图片' + if (error) return + return ( + <> + + {open && src !== null && } + + ) +} + +/** Wrapping image group shared by user and assistant history. */ +export function ImageGallery({ images, load, align }: { + images: readonly { attachment: ImageAttachmentRef; alt?: string }[] + load: ImageLoader + align: 'start' | 'end' +}) { + if (images.length === 0) return null + return ( +
+ {images.map((image, index) => ( + + ))} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 50e560278d..732cb97202 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -7,9 +7,18 @@ justify-content: flex-end; } +.userStack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + min-width: 0; + max-width: min(525px, 82%); +} + .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ - max-width: min(525px, 82%); + max-width: 100%; background: var(--dsw-specific-bubble); border-radius: 22px; /* 44px single-line bubble: 24 line + 10 vertical padding each side. */ diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 4bfe07d687..d77beeb5b3 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -9,33 +9,49 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import css from './MessageItem.module.css' +import { ImageGallery, type ImageLoader } from './MessageImage.tsx' export interface MessageItemProps { node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode + loadImage?: ImageLoader } -function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { +type UserImage = Extract + +function contentParts(content: readonly unknown[]): { + text: string + images: { attachment: UserImage['attachment']; alt?: string }[] + rest: unknown[] +} { const texts: string[] = [] + const images: { attachment: UserImage['attachment']; alt?: string }[] = [] const rest: unknown[] = [] for (const block of content) { - const b = block as { type?: string; text?: string } + const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) + else if (b.type === 'image' && b.attachment !== undefined) { + const image = b as UserImage + images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } }) + } else rest.push(block) } - return { text: texts.join(''), rest } + return { text: texts.join(''), images, rest } } -export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ node, loadImage = unavailableImage }: MessageItemProps) { switch (node.kind) { case 'user': case 'steering': { - const { text, rest } = contentText(node.content) + const { text, images, rest } = contentParts(node.content) return (
-
- {node.kind === 'steering' && 插话} - - {rest.map((block, i) => )} +
+ + {(text !== '' || rest.length > 0 || node.kind === 'steering') &&
+ {node.kind === 'steering' && 插话} + + {rest.map((block, i) => )} +
}
) @@ -54,3 +70,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) ) } }) + +function unavailableImage(): Promise { + return Promise.reject(new Error('图片读取服务不可用')) +} diff --git a/packages/client/ui-conversation/src/client/chat/register.ts b/packages/client/ui-conversation/src/client/chat/register.ts index b417ab4c0b..2796bffc15 100644 --- a/packages/client/ui-conversation/src/client/chat/register.ts +++ b/packages/client/ui-conversation/src/client/chat/register.ts @@ -46,7 +46,11 @@ export function registerChat(deps: RegisterChatDeps): () => void { id: 'chat', label: 'Chat', order: 0, - component: createChatView({ toolviews, t }), + component: createChatView({ + toolviews, + t, + resolveImage: (sessionId, attachment) => conversation.resolveImage(sessionId, attachment), + }), chrome: { footer: StatsLine }, }) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a7001fb21e..80cb6f0b3f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -12,6 +12,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { SelectionTarget, ViewEntry } from './views.ts' +/** Browser-owned image that has not crossed the durable host boundary. */ +export interface ComposerAttachment { + id: string + file: File + previewUrl: string +} + /** The shared chat store handle type (apply constructs one; conversation and details both declare it). */ export type ChatStore = ReturnType @@ -29,8 +36,14 @@ export interface ConversationInjected { subscribe(fn: () => void): () => void version(): number } + /** Create browser previews and append their ids through the declared store action. */ + addImages(files: readonly File[]): void + /** 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[] /** Send choreography: trims, clears the draft optimistically, restores it on failure. */ - send(text: string, mode: 'queue' | 'steer'): void + send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ @@ -60,7 +73,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & /** Injected share of the no-session empty-state slot. */ export interface EmptyStateInjected { /** The create → navigate → first-send chain, in one service call. */ - startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise + startSession(opts: { + cwd?: string + text: string + images?: readonly File[] + mode: 'queue' | 'steer' + }): Promise } /** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index e201b67e20..f1fa865834 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -69,6 +69,12 @@ export interface ChatStoreState { selection: SelectionTarget | null /** Composer draft (persisted; survives session switches and reloads). */ draft: string + /** + * Ordered browser-draft attachment ids. The matching File/object-URL + * objects stay in ConversationService because they are runtime-only; stale + * persisted ids are pruned by ConversationRoot after a page reload. + */ + imageIds: string[] /** Active conversation view id; null falls back to the first registered view. */ view: ViewId | null } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9c9ee639b8..9fc167320c 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -22,8 +22,27 @@ import type { Context } from 'cordis' // SessionsService tags contexts with — scopeOf then always returns undefined // in the browser while unit tests (single-instance path resolution) stay green. import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client' -import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ViewEntry, ViewId } from './index.ts' +import type { ComposerAttachment } from './contract/slots.ts' + +/** Opaque wrapper keeps browser `File` internals outside persisted store state. */ +class BrowserDraftAttachment implements ComposerAttachment { + readonly id: string + readonly previewUrl: string + readonly #file: File + + constructor(file: File) { + this.id = crypto.randomUUID() + this.previewUrl = URL.createObjectURL(file) + this.#file = file + } + + get file(): File { + return this.#file + } +} /** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */ interface ViewsState { @@ -36,6 +55,9 @@ interface ViewsState { /** 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 createdImageUrls = new Set() private readonly viewsState: ViewsState = { entries: new Map(), cache: null, tick: 0, listeners: new Set(), } @@ -46,6 +68,12 @@ export class ConversationService extends Service { */ constructor(ctx: Context) { super(ctx, 'conversation') + ctx.effect(() => () => { + for (const url of this.createdImageUrls) URL.revokeObjectURL(url) + this.createdImageUrls.clear() + this.draftAttachments.clear() + this.imageUrls.clear() + }, 'conversation attachment URL cache') } /** @@ -54,13 +82,98 @@ export class ConversationService extends Service { * exists for caller choreography (the composer restores the draft on it). * @param text - prompt text, sent verbatim as one text block. * @param mode - queue after the current turn, or steer into it. + * @param images - browser-owned temporary images promoted by the host during this call. */ - async send(text: string, mode: 'queue' | 'steer'): Promise { + async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { const session = this.scopedSession('send') - const result = await session.prompt([{ type: 'text', text }], mode) + const uploaded = await Promise.all(images.map(async file => ({ + type: 'image' as const, + mediaType: imageMediaType(file.type), + data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), + ...(file.name === '' ? {} : { name: file.name }), + }))) + const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + const result = await session.prompt(content, mode) if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) } + /** + * Create runtime-only draft attachments and their object URLs. + * @param files - browser-owned image files. + * @returns ordered attachment descriptors whose ids may enter the chat store. + */ + createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { + return files.map((file) => { + const attachment = new BrowserDraftAttachment(file) + this.draftAttachments.set(attachment.id, attachment) + this.createdImageUrls.add(attachment.previewUrl) + return attachment + }) + } + + /** + * Resolve ordered store ids to runtime-owned draft attachments. + * @param ids - ordered ids from the chat store. + * @returns attachments still available in this browser runtime. + */ + draftImages(ids: readonly string[]): readonly ComposerAttachment[] { + const attachments: ComposerAttachment[] = [] + for (const id of ids) { + const attachment = this.draftAttachments.get(id) + if (attachment !== undefined) attachments.push(attachment) + } + return attachments + } + + /** + * Release one draft attachment preview. + * @param id - draft-local attachment id. + */ + releaseDraftImage(id: string): void { + const attachment = this.draftAttachments.get(id) + if (attachment === undefined) return + this.draftAttachments.delete(id) + this.createdImageUrls.delete(attachment.previewUrl) + revokePreview(attachment.previewUrl) + } + + /** + * Release sent draft attachment previews. + * @param attachments - successfully submitted attachments. + */ + releaseDraftImages(attachments: readonly ComposerAttachment[]): void { + for (const attachment of attachments) this.releaseDraftImage(attachment.id) + } + + /** + * Resolve and cache one session-authorized historical image as an object URL. + * @param sessionId - session whose durable log grants the read. + * @param attachment - immutable reference from that log. + * @returns a browser URL for inline and original-size display. + */ + resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + const key = `${sessionId}:${attachment.attachmentId}` + const cached = this.imageUrls.get(key) + if (cached !== undefined) return cached + const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId) + .then((result) => { + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + if (typeof URL.createObjectURL !== 'function') { + return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + } + const bytes = Uint8Array.from(result.value.data) + const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + this.createdImageUrls.add(url) + return url + }) + .catch((error: unknown) => { + this.imageUrls.delete(key) + throw error + }) + this.imageUrls.set(key, pending) + return pending + } + /** 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') @@ -132,7 +245,12 @@ export class ConversationService extends Service { * awaited through the RPC round trip). * @param opts - project directory, prompt text, and send mode. */ - async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise { + async startSession(opts: { + cwd?: string + text: string + images?: readonly File[] + mode: 'queue' | 'steer' + }): Promise { const sessions = this.requireSessions() const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd }) // The manager notifier flushes per microtask; one await guarantees the @@ -146,7 +264,7 @@ export class ConversationService extends Service { // global store and still binds this service to the scoped ctx. const scopedConversation = scoped.get('conversation') if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope') - await scopedConversation.send(opts.text, opts.mode) + await scopedConversation.send(opts.text, opts.mode, opts.images ?? []) } /** Resolve the caller scope's Session or throw on root contexts. */ @@ -173,3 +291,28 @@ function bumpViews(state: ViewsState): void { state.tick += 1 for (const fn of [...state.listeners]) fn() } + +function imageMediaType(value: string): ImageMediaType { + switch (value) { + case 'image/png': + case 'image/jpeg': + case 'image/webp': + case 'image/gif': + return value + default: + throw new Error(`不支持的图片格式:${value || '未知格式'}`) + } +} + +function bytesToBase64(data: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let offset = 0; offset < data.length; offset += chunk) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) + } + return btoa(binary) +} + +function revokePreview(url: string): void { + if (url.startsWith('blob:')) URL.revokeObjectURL(url) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index de970381ff..d018d93457 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -5,7 +5,7 @@ // Breadcrumbs derive from useSessions with a pure parentId walk; the active // view id lives in the chat store's `view` field (per-session by store scope). -import { useMemo, useSyncExternalStore, type ReactNode } from 'react' +import { useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -36,7 +36,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationRoot({ sessionId, useSession, useSessions, useStore, actions, - views, send, stop, openDetails, loadOlder, open, + views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const list = views.list() @@ -47,11 +47,22 @@ export function ConversationRoot({ const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) const draft = useStore(s => s.draft) + const imageIds = useStore(s => s.imageIds) + const attachments = useMemo(() => draftImages(imageIds), [draftImages, imageIds]) const running = useSession(s => s.running) const removed = useSession(s => s.removed) const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) + // Browser File/object-URL values are intentionally runtime-only. A reload + // may rehydrate ids whose objects no longer exist; prune those ids through + // the declared store action after the first render. + useEffect(() => { + if (attachments.length !== imageIds.length) { + actions.pruneImages(attachments.map(attachment => attachment.id)) + } + }, [actions, attachments, imageIds]) + const error: InputBarError | null = promptError === null ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } @@ -128,12 +139,15 @@ export function ConversationRoot({ { send(draft, mode) }} + onAddImages={addImages} + 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 420ff7a622..e837154d2a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -6,10 +6,10 @@ // §6) plus a free-form new-directory input; submit runs the startSession // chain (create → open → send) in one service call. -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' -import type { EmptyStateSlotProps } from '../contract/slots.ts' +import type { ComposerAttachment, EmptyStateSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' @@ -36,6 +36,9 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') + const [attachments, setAttachments] = useState([]) + const attachmentsRef = useRef(attachments) + attachmentsRef.current = attachments const [cwd, setCwd] = useState('') const [custom, setCustom] = useState(false) const [sending, setSending] = useState(false) @@ -44,11 +47,16 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { const submit = (mode: 'queue' | 'steer'): void => { const text = draft.trim() /* v8 ignore next -- defensive: InputBar disables send while empty. */ - if (text === '' || sending) return + if ((text === '' && attachments.length === 0) || sending) return setSending(true) setError(null) const chosen = cwd.trim() - startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) }) + startSession({ + text, + ...(attachments.length === 0 ? {} : { images: attachments.map(item => item.file) }), + mode, + ...(chosen === '' ? {} : { cwd: chosen }), + }) .catch((reason: unknown) => { // The empty state survives failure with the draft intact (no session // exists to carry promptError; this is the only local error surface). @@ -58,6 +66,24 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } + useEffect(() => () => { + for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl) + }, []) + + const addImages = (files: readonly File[]): void => { + setAttachments(current => [...current, ...files.map(file => ({ + id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file), + }))]) + } + + 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) + }) + } + const picker = (
{custom @@ -102,6 +128,7 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
{}} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css new file mode 100644 index 0000000000..d6b96f9fd7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css @@ -0,0 +1,34 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 40px; + background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent); +} + +.image { + max-width: min(100%, 1600px); + max-height: calc(100vh - 80px); + object-fit: contain; + border-radius: 12px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv3); +} + +.close { + position: fixed; + top: 20px; + right: 20px; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 999px; + background: var(--dsw-specific-input-major); + color: var(--dsw-alias-label-primary); + font-size: 24px; + cursor: pointer; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx new file mode 100644 index 0000000000..5c53433713 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react' +import css from './ImageLightbox.module.css' + +/** Document-level original-image preview opened by an explicit double-click. */ +export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose(): void }) { + const closeRef = useRef(null) + const restoreRef = useRef(null) + + useEffect(() => { + restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null + closeRef.current?.focus() + const onKeyDown = (event: globalThis.KeyboardEvent): void => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + restoreRef.current?.focus() + } + }, [onClose]) + + return ( +
{ if (event.target === event.currentTarget) onClose() }} + > + {alt} + +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1139c3c9c1..7194a54f07 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -32,6 +32,7 @@ } .card { + position: relative; display: flex; flex-direction: column; /* figma Input 34:11458: 12px between the text area and the button row. */ @@ -49,6 +50,25 @@ line-height: 24px; } +.dragActive { + border-color: var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); +} + +.dropHint { + position: absolute; + z-index: 2; + inset: 4px; + display: grid; + place-items: center; + border-radius: 16px; + background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary)); + color: var(--dsw-alias-state-business-primary); + font-size: 14px; + font-weight: 600; + pointer-events: none; +} + /* New-session state rounds up (figma: r24 and a taller box). */ .hero .card { border-radius: 24px; @@ -61,6 +81,57 @@ padding: 10px 12px 0; } +.attachments { + display: flex; + gap: 8px; + min-width: 0; + padding: 12px 12px 0; + overflow-x: auto; + overflow-y: hidden; +} + +.attachment { + position: relative; + flex: 0 0 72px; + width: 72px; + height: 72px; +} + +.thumbnail { + width: 72px; + height: 72px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: -6px; + right: -6px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--dsw-specific-input-major); + border-radius: 999px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-specific-input-major); + font-size: 16px; + line-height: 1; + cursor: pointer; +} + /* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height (min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea MUST share font, line-height, padding and wrapping rules or heights diverge. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c5c8307ffc..eba7c52451 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -5,11 +5,19 @@ // LOCKS the input: textarea disabled with the draft visible, stop is the only // action; the turn ending re-enables and refocuses. -import { useEffect, useRef } from 'react' -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' +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' @@ -18,6 +26,7 @@ export interface InputBarError { export interface InputBarProps { draft: string + attachments?: readonly ComposerAttachment[] running: boolean disabled: boolean error: InputBarError | null @@ -27,15 +36,22 @@ 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 + onRemoveAttachment?: (id: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void } export function InputBar({ - draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, + draft, attachments = [], running, disabled, error, variant, placeholder, accessory, + onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop, }: InputBarProps) { - const empty = draft.trim() === '' + const empty = draft.trim() === '' && attachments.length === 0 + const [preview, setPreview] = useState(null) + const [dragActive, setDragActive] = useState(false) + const [dropError, setDropError] = useState(null) const inputRef = useRef(null) + const dragDepthRef = useRef(0) // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend. const composingRef = useRef(false) @@ -72,6 +88,56 @@ export function InputBar({ if (!empty && !locked) onSend('queue') } + const onPaste = (event: ClipboardEvent): void => { + const files = [...event.clipboardData.items] + .filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type)) + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null) + if (files.length === 0) return + event.preventDefault() + setDropError(null) + onAddImages(files) + } + + const onDragEnter = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + if (locked) return + dragDepthRef.current += 1 + setDropError(null) + setDragActive(true) + } + + const onDragOver = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + event.dataTransfer.dropEffect = locked ? 'none' : 'copy' + } + + const onDragLeave = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files') || locked) return + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setDragActive(false) + } + + const onDrop = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + dragDepthRef.current = 0 + 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) + } + + const closePreview = useCallback(() => { setPreview(null) }, []) + // Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly. const keepFocus = (e: MouseEvent): void => { e.preventDefault() @@ -95,8 +161,38 @@ export function InputBar({ {error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message}
)} -
+ {dropError !== null &&
{dropError}
} +
+ {dragActive &&
松开以添加图片
} {accessory !== undefined &&
{accessory}
} + {attachments.length > 0 && ( +
+ {attachments.map(attachment => ( +
+ + +
+ ))} +
+ )} {/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper (min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting rows by '\n' cannot see soft wraps. */} @@ -110,6 +206,7 @@ export function InputBar({ rows={2} onChange={(e) => onDraftChange(e.target.value)} onKeyDown={onKeyDown} + onPaste={onPaste} onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} /> @@ -137,6 +234,7 @@ export function InputBar({
+ {preview !== null && } ) } diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index ed27290827..cb18637290 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -19,8 +19,11 @@ import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.t type ChatActions = { select: (draft: ChatStoreState, target: SelectionTarget | null) => void setDraft: (draft: ChatStoreState, text: string) => void + addImages: (draft: ChatStoreState, ids: readonly string[]) => void + removeImage: (draft: ChatStoreState, id: string) => void + pruneImages: (draft: ChatStoreState, available: readonly string[]) => void clearDraft: (draft: ChatStoreState) => void - restoreDraft: (draft: ChatStoreState, text: string) => void + restoreDraft: (draft: ChatStoreState, text: string, imageIds: readonly string[]) => void setView: (draft: ChatStoreState, view: ViewId) => void } @@ -37,15 +40,30 @@ export function createChatStore(): EngineStoreHandle, so init and the // contract cannot drift. - init: (): ChatStoreState => ({ selection: null, draft: '', view: null }), + init: (): ChatStoreState => ({ selection: null, draft: '', imageIds: [], view: null }), persist: 'dsh.conversation.chat', actions: { select: (d, target: SelectionTarget | null) => { d.selection = target }, setDraft: (d, text: string) => { d.draft = text }, - clearDraft: (d) => { d.draft = '' }, - // Optimistic-send failure restore: only when the user typed nothing new - // since the clear (send choreography lives in the inject factory). - restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text }, + addImages: (d, ids: readonly string[]) => { d.imageIds.push(...ids) }, + removeImage: (d, id: string) => { + d.imageIds = d.imageIds.filter(candidate => candidate !== id) + }, + pruneImages: (d, available: readonly string[]) => { + const keep = new Set(available) + d.imageIds = d.imageIds.filter(id => keep.has(id)) + }, + clearDraft: (d) => { + d.draft = '' + d.imageIds = [] + }, + // Optimistic-send failure restore keeps any newer typing/images while + // restoring the submitted draft material that disappeared on clear. + restoreDraft: (d, text: string, imageIds: readonly string[]) => { + if (d.draft === '') d.draft = text + const current = new Set(d.imageIds) + d.imageIds = [...imageIds.filter(id => !current.has(id)), ...d.imageIds] + }, setView: (d, view: ViewId) => { d.view = view }, }, }) diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 4631e08d77..b766f12afc 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -132,25 +132,25 @@ describe('conversation slot inject surface', () => { const { instance, injected } = b.conversationSurface(ROOT) // Whitespace-only: no send, and the (whitespace) draft is not cleared. instance.actions.setDraft(' ') - injected.send(' ', 'queue') + injected.send(' ', [], 'queue') expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(instance.store.getSnapshot().draft).toBe(' ') // Success: cleared and stays cleared. instance.actions.setDraft('hello') - injected.send('hello', 'queue') + injected.send('hello', [], 'queue') expect(instance.store.getSnapshot().draft).toBe('') await Promise.resolve() expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') // Failure: restored (draft still empty when the rejection lands). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) instance.actions.setDraft('retry me') - injected.send('retry me', 'queue') + injected.send('retry me', [], 'queue') await vi.waitFor(() => { expect(instance.store.getSnapshot().draft).toBe('retry me') }) // Failure landing after new typing: no clobber (restoreDraft fills empty only). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) - injected.send('retry me', 'queue') + injected.send('retry me', [], 'queue') instance.actions.setDraft('typed during flight') await new Promise(r => setTimeout(r, 0)) expect(instance.store.getSnapshot().draft).toBe('typed during flight') diff --git a/packages/client/ui-conversation/tests/chat-store.spec.ts b/packages/client/ui-conversation/tests/chat-store.spec.ts index 5993662352..865d6b0f3f 100644 --- a/packages/client/ui-conversation/tests/chat-store.spec.ts +++ b/packages/client/ui-conversation/tests/chat-store.spec.ts @@ -15,9 +15,9 @@ beforeEach(() => { }) describe('createChatStore', () => { - it('init shape: empty selection/draft/view', () => { + it('init shape: empty selection/draft/images/view', () => { const store = createChatStore().create() - expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null }) }) it('actions cover the declared write set', () => { @@ -30,6 +30,11 @@ describe('createChatStore', () => { store.actions.setDraft('hello') expect(store.store.getSnapshot().draft).toBe('hello') + store.actions.addImages(['a', 'b']) + store.actions.removeImage('a') + expect(store.store.getSnapshot().imageIds).toEqual(['b']) + store.actions.pruneImages([]) + expect(store.store.getSnapshot().imageIds).toEqual([]) store.actions.clearDraft() expect(store.store.getSnapshot().draft).toBe('') @@ -40,12 +45,15 @@ describe('createChatStore', () => { it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => { const store = createChatStore().create() // Rollback path: draft was cleared by send, nothing typed since. - store.actions.restoreDraft('failed text') + store.actions.restoreDraft('failed text', ['old-image']) expect(store.store.getSnapshot().draft).toBe('failed text') + expect(store.store.getSnapshot().imageIds).toEqual(['old-image']) // The user typed something new before the failure landed: keep theirs. store.actions.setDraft('newer input') - store.actions.restoreDraft('stale text') + store.actions.addImages(['new-image']) + store.actions.restoreDraft('stale text', ['old-image']) expect(store.store.getSnapshot().draft).toBe('newer input') + expect(store.store.getSnapshot().imageIds).toEqual(['old-image', 'new-image']) }) it('persists per scope key and rehydrates a fresh instance', () => { diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 660127946b..eb03543781 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -129,3 +129,89 @@ describe('error strip and variants', () => { expect(view.container.querySelector('[class*="hero"]')).not.toBeNull() }) }) + +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 image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) + const prevented = fireEvent.paste(textarea, { + clipboardData: { + items: [ + { kind: 'string', type: 'text/plain', getAsFile: () => null }, + { kind: 'file', type: 'image/png', getAsFile: () => image }, + ], + }, + }) + expect(prevented).toBe(false) + expect(onAddImages).toHaveBeenCalledWith([image]) + + fireEvent.paste(textarea, { + clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] }, + }) + expect(onAddImages).toHaveBeenCalledTimes(1) + }) + + it('accepts supported image drops, highlights the target, and prevents browser navigation', () => { + const onAddImages = vi.fn() + 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' }) + const dataTransfer = { + types: ['Files'], + files: [image], + dropEffect: 'none', + } + expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false) + expect(view.getByRole('status').textContent).toContain('松开以添加图片') + expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false) + expect(dataTransfer.dropEffect).toBe('copy') + expect(fireEvent.drop(card, { dataTransfer })).toBe(false) + expect(view.queryByRole('status')).toBeNull() + expect(onAddImages).toHaveBeenCalledWith([image]) + }) + + it('ignores unsupported dropped files and refuses drops while locked', () => { + const onAddImages = vi.fn() + 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() + + const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' }) + const locked = setup({ draft: '', disabled: true, onAddImages }) + const lockedCard = locked.view.container.querySelector('[class*="card"]')! + const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } + fireEvent.dragEnter(lockedCard, { dataTransfer }) + expect(locked.view.queryByRole('status')).toBeNull() + fireEvent.dragOver(lockedCard, { dataTransfer }) + expect(dataTransfer.dropEffect).toBe('none') + fireEvent.drop(lockedCard, { dataTransfer }) + expect(onAddImages).not.toHaveBeenCalled() + }) + + 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 onRemoveAttachment = vi.fn() + const { view, textarea, props } = setup({ + draft: '', attachments: [attachment], onRemoveAttachment, + }) + const send = view.getByRole('button', { name: '发送' }) as HTMLButtonElement + expect(send.disabled).toBe(false) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(props.onSend).toHaveBeenCalledWith('queue') + + fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) + expect(onRemoveAttachment).toHaveBeenCalledWith('draft-1') + fireEvent.doubleClick(view.getByTitle('双击查看原图')) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + expect(view.getAllByAltText('pixel.png').every(node => (node as HTMLImageElement).src.includes('blob:draft-1'))).toBe(true) + fireEvent.keyDown(window, { key: 'Escape' }) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx new file mode 100644 index 0000000000..d78fca2069 --- /dev/null +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom + +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' + +afterEach(cleanup) + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('MessageImage', () => { + it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) + expect(frame.getAttribute('style')).toContain('width: 240px') + expect(frame.getAttribute('style')).toContain('height: 120px') + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledWith(attachment) + fireEvent.doubleClick(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('surfaces a retry control when durable bytes cannot be read', async () => { + const load = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce('blob:retry') + const view = render() + const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retry) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index a637b4fa18..32fb513929 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -175,6 +175,6 @@ describe('selection survives on the store seat', () => { await flush() const reborn = storeFor(b, 'conversation', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null }) }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 8b8f4f0dff..b82276e555 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -93,6 +93,29 @@ describe('send / cancel', () => { await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/) }) + it('uploads temporary browser files as base64 image parts at the send boundary', async () => { + const b = await bench() + const file = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) + Object.defineProperty(file, 'arrayBuffer', { + value: () => Promise.resolve(Uint8Array.of(1, 2, 3).buffer), + }) + await b.scopedSvc(sid('s1')).send('describe', 'queue', [file]) + expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith([ + { type: 'image', mediaType: 'image/png', data: 'AQID', name: 'pixel.png' }, + { type: 'text', text: 'describe' }, + ], 'queue') + }) + + it('rejects unsupported browser media before prompting the session', async () => { + const b = await bench() + const file = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) + Object.defineProperty(file, 'arrayBuffer', { + value: () => Promise.resolve(Uint8Array.of(1).buffer), + }) + await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file])).rejects.toThrow(/不支持的图片格式/) + expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled() + }) + it('cancel resolves on ok and throws the folded business error', async () => { const b = await bench() const s = b.scopedSvc(sid('s1')) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 52d89d2b19..1d4b76091a 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -71,6 +71,9 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} @@ -129,6 +132,9 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 75172dd207..d7e490c7ee 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -121,6 +121,9 @@ describe('ConversationRoot', () => { subscribe: () => () => {}, version: () => 1, }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={send} stop={stop} openDetails={openDetails} @@ -183,7 +186,7 @@ describe('ConversationRoot', () => { // Typing goes through actions.setDraft into the shared store. expect(chat.store.getSnapshot().draft).toBe('hi') fireEvent.keyDown(box, { key: 'Enter' }) - expect(send).toHaveBeenCalledWith('hi', 'queue') + expect(send).toHaveBeenCalledWith('hi', [], 'queue') }) }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 7755337093..d8730e7a1a 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index e7f99d9128..6b35741846 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -96,6 +96,9 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = subscribe: (fn) => svc.subscribeViews(fn), version: () => svc.viewsVersion(), }} + addImages={vi.fn()} + removeImage={vi.fn()} + draftImages={() => []} send={vi.fn()} stop={vi.fn()} openDetails={vi.fn()} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3b9111f558..52ff1dd7ff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -152,6 +152,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'attachments', + summary: 'Immutable binary attachment service.', + methods: [ + { + signature: 'abstract saveImage(input: SaveImageAttachment): Promise', + jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', + }, + { + signature: 'abstract readImage(ref: ImageAttachmentRef): Promise', + jsDoc: '/**\n * Read one image and verify that bytes still match the recorded reference.\n * @param ref - durable reference from the session log.\n * @returns the verified bytes and canonical reference.\n */', + }, + ], + }, { key: 'bash', summary: 'Abstract bash execution service.', @@ -1195,6 +1209,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssistantProvenance', declaration: 'export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n}', }, + { + name: 'AttachmentId', + declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;', + }, { name: 'BashEnvContributor', declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}', @@ -1309,7 +1327,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContentBlockMap', - declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', + declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', }, { name: 'ContentBlockType', @@ -1451,6 +1469,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HookContext', declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}', }, + { + name: 'ImageAttachmentRef', + declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n}', + }, + { + name: 'ImageBlock', + declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n alt?: string;\n}', + }, + { + name: 'ImageMediaType', + declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', + }, { name: 'InjectOptions', declaration: 'export interface InjectOptions extends Omit {\n meta?: JsonValue;\n}', @@ -1481,7 +1511,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelInfo', - declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', + declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n outputModalities?: readonly ModelModality[];\n}', }, { name: 'LlmProviderInfo', @@ -1499,6 +1529,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}', }, + { + name: 'ModelModality', + declaration: 'export type ModelModality = ModelModalityMap[keyof ModelModalityMap];', + }, + { + name: 'ModelModalityMap', + declaration: 'export interface ModelModalityMap {\n text: \'text\';\n image: \'image\';\n}', + }, { name: 'OutOfBandSessionEventMap', declaration: 'export interface OutOfBandSessionEventMap {\n}', @@ -1651,6 +1689,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, + { + name: 'SaveImageAttachment', + declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', + }, { name: 'SaveTextSpill', declaration: 'export interface SaveTextSpill {\n owner: SpillOwner;\n source: SpillSource;\n suggestedName: string;\n content: string;\n}', @@ -1827,6 +1869,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SpillSource', declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', }, + { + name: 'StoredImageAttachment', + declaration: 'export interface StoredImageAttachment {\n ref: ImageAttachmentRef;\n data: Uint8Array;\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index fe50c16a60..5b80030dc9 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -40,6 +40,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index cb8de392f5..4b484ae594 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -5,6 +5,9 @@ import { z } from 'zod' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' +import { imageMediaTypeSchema } from './sessions.schema.ts' + +const modalitySchema = z.union([z.literal('text'), z.literal('image')]) /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> @@ -15,5 +18,20 @@ export const hostDescribeValueSchema = z.object({ cwd: z.string(), provider: z.string().optional(), model: z.string().optional(), + activeModel: z.object({ + provider: z.string(), + id: z.string(), + name: z.string(), + description: z.string().optional(), + inputModalities: z.array(modalitySchema).optional(), + outputModalities: z.array(modalitySchema).optional(), + }).optional(), + imageLimits: z.object({ + maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), + maxImagePixels: z.number().int().positive(), + mediaTypes: z.array(imageMediaTypeSchema), + }).optional(), attachedSessions: z.number().int().nonnegative(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index fd07b33ced..ccf23d5cfb 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -4,6 +4,8 @@ */ import type { RpcRequest, RpcResponse } from './rpc.ts' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types' /** Host-level unary methods. */ export interface HostApi { @@ -20,6 +22,10 @@ export interface HostApi { cwd: string provider?: string model?: string + /** Catalog entry for the active route; absent means its capabilities are unknown. */ + activeModel?: LlmModelInfo + /** Resolved authoritative image-upload limits. */ + imageLimits?: ImageAttachmentLimits attachedSessions: number }>> } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c51c785a7c..dc0ac0204d 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -19,7 +19,7 @@ export interface ApiProxy { } // ---- Domain interfaces and payload entities ---- -export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' +export type { HistoryEntry, PromptContentPart, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b37cc062ff..74b05fdb3c 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -14,6 +14,7 @@ export interface RpcMethodMap { 'session.create': SessionsApi['create'] 'session.history': SessionsApi['history'] 'session.prompt': SessionsApi['prompt'] + 'session.attachment': SessionsApi['attachment'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] } diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index d993e763b7..ba998cd86a 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -35,6 +35,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 46f737817c..4273a32e71 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -32,6 +32,7 @@ export interface RpcErrorDetailsMap { 'bad-request': { issues: ZodIssue[] } 'session-not-found': { sessionId: SessionId } 'agent-busy': { reason: string } + 'attachment-error': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..1eacc61715 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,6 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { HistoryEntry, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType @@ -84,14 +85,25 @@ export const sessionHistoryValueSchema = z.object({ hasMore: z.boolean(), }) satisfies z.ZodType>> -/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ -export const contentBlockSchema = z.looseObject({ type: z.string() }) +/** Raster image media types accepted by the version-one browser wire. */ +export const imageMediaTypeSchema = z.union([ + z.literal('image/png'), + z.literal('image/jpeg'), + z.literal('image/webp'), + z.literal('image/gif'), +]) + +/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */ +export const promptContentPartSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }), +]) /** session.prompt request payload. */ export const sessionPromptRequestSchema = z.object({ sessionId: sessionIdSchema, mode: z.union([z.literal('queue'), z.literal('steer')]), - content: z.array(contentBlockSchema), + content: z.array(promptContentPartSchema), }) as unknown as z.ZodType> /** session.prompt response value. */ @@ -99,6 +111,31 @@ export const sessionPromptValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> +/** Opaque attachment id after string-shape validation. */ +export const attachmentIdSchema = z.string().min(1) as unknown as z.ZodType + +/** Durable image reference returned from the authenticated session lookup. */ +export const imageAttachmentRefSchema = z.object({ + attachmentId: attachmentIdSchema, + mediaType: imageMediaTypeSchema, + bytes: z.number().int().positive(), + width: z.number().int().positive(), + height: z.number().int().positive(), + name: z.string().optional(), +}) as unknown as z.ZodType + +/** session.attachment request payload. */ +export const sessionAttachmentRequestSchema = z.object({ + sessionId: sessionIdSchema, + attachmentId: attachmentIdSchema, +}) satisfies z.ZodType>> + +/** session.attachment response value. */ +export const sessionAttachmentValueSchema = z.object({ + attachment: imageAttachmentRefSchema, + data: z.string(), +}) satisfies z.ZodType>> + /** session.cancel request payload. */ export const sessionCancelRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..6b3dedfd35 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -4,7 +4,7 @@ * else references RequestPayload<'session.*'> / ResponseValue<'session.*'>. */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts' import type { ToolEventView } from './events.ts' @@ -44,6 +44,11 @@ export interface SessionSummary { cwd?: string } +/** Browser-submitted prompt content; image bytes are promoted to durable references by the host. */ +export type PromptContentPart = + | { type: 'text'; text: string } + | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string } + /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -64,10 +69,14 @@ export interface SessionsApi { history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): Promise> - /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ - prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): + /** Sends text plus temporary base64 image uploads; the host persists images before calling the agent. */ + prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>): Promise> + /** Reads one durable image after proving that this session's log references its id. */ + attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }> ): + Promise> + /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 901cf7bd2a..406e66baea 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -16,6 +16,7 @@ import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' import { hostDescribeValueSchema } from '../api/host.schema.ts' import { sessionCancelValueSchema, + sessionAttachmentValueSchema, sessionCreateValueSchema, sessionHistoryValueSchema, sessionListValueSchema, @@ -43,6 +44,7 @@ export interface IApiClient { create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise>> history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> + attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } host: { @@ -65,6 +67,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.create', payload, signal), history: (payload, signal) => this.callUnary('session.history', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), + attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 03b9f6500f..c28305a1c5 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -16,6 +16,7 @@ import type { Wire } from '../api/rpc.schema.ts' import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' import { sessionCancelRequestSchema, + sessionAttachmentRequestSchema, sessionCreateRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, @@ -42,6 +43,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) }, 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, + 'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25af7e2f75..818a1d5b09 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -30,6 +30,10 @@ function scriptedApi(overrides: { create: r => ok(r, { sessionId: sid('s-new') }), history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { accepted: true as const }), + attachment: r => ok(r, { + attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: 'AA==', + }), cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..336b61efb8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -33,6 +33,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async prompt(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async attachment(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } }, + } + }, async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 764c9673b9..16b986a941 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -6,7 +6,8 @@ import { } from '../src/api/rpc.schema.ts' import { z } from 'zod' import { - contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, + promptContentPartSchema, sessionAttachmentRequestSchema, sessionAttachmentValueSchema, + sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, sessionSummarySchema, @@ -31,6 +32,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') + expect(rpcErrorSchema.parse({ code: 'attachment-error', message: 'm', details: { reason: 'r' } }).code).toBe('attachment-error') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -105,7 +107,20 @@ describe('sessions domain schemas', () => { expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true) expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) - expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) + expect(promptContentPartSchema.parse({ type: 'text', text: 'x', extra: 1 })).toEqual({ type: 'text', text: 'x' }) + expect(promptContentPartSchema.parse({ + type: 'image', mediaType: 'image/png', data: 'AA==', name: 'pixel.png', + })).toMatchObject({ type: 'image', mediaType: 'image/png', name: 'pixel.png' }) + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } + expect(sessionAttachmentRequestSchema.parse({ sessionId: 's1', attachmentId: attachment.attachmentId })) + .toMatchObject({ sessionId: 's1' }) + expect(sessionAttachmentValueSchema.parse({ attachment, data: 'AA==' }).attachment).toEqual(attachment) }) }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 718c5a9042..d7fbdec7a5 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../util/brand" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../llm/llm" }, diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index ea32040012..c3d90d776e 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -31,6 +31,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-i18n": "workspace:^", @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 0621fbecb9..8681cb3b3e 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -9,10 +9,12 @@ import { randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { AttachmentError } from '@deepseek-ai/dsh-attachment-local' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -22,6 +24,69 @@ const DEFAULT_MAX_MESSAGES = 50 /** Surface message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) +function decodeBase64(data: string): Uint8Array { + if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) { + throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + } + const decoded = Buffer.from(data, 'base64') + if (decoded.toString('base64') !== data) throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + return new Uint8Array(decoded) +} + +async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise { + const limits = ctx.attachments.imageLimits + const prepared = content.map(part => part.type === 'text' + ? part + : { part, data: decodeBase64(part.data) }) + const images = prepared.filter((part): part is Extract => 'data' in part) + if (images.length > limits.maxImagesPerMessage) { + throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + } + const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) + if (totalBytes > limits.maxMessageImageBytes) { + throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } + return Promise.all(prepared.map(async (item): Promise => { + if (!('data' in item)) return { type: 'text', text: item.text } + const attachment = await ctx.attachments.saveImage({ + data: item.data, + mediaType: item.part.mediaType, + ...item.part.name === undefined ? {} : { name: item.part.name }, + }) + return { type: 'image', attachment } + })) +} + +function imageInContent(content: unknown, attachmentId: string): ImageAttachmentRef | undefined { + if (!Array.isArray(content)) return undefined + for (const value of content) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + if (String(ref.attachmentId) === attachmentId) return ref + } + if (block.type === 'tool-result') { + const nested = imageInContent(block.content, attachmentId) + if (nested !== undefined) return nested + } + } + return undefined +} + +function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { + for (const event of events) { + const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } } + const direct = imageInContent(data.content, attachmentId) + if (direct !== undefined) return direct + if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') { + const streamed = imageInContent([data.chunk.block], attachmentId) + if (streamed !== undefined) return streamed + } + } + return undefined +} + /** * Message-boundary pagination: count maxMessages surface messages backwards from * the window tail; the cut is the starting seq of the oldest message group @@ -321,15 +386,52 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { - if (mode === 'steer') agent.steer(content, { source }) - else agent.send(content, { source }) + if (content.some(part => part.type === 'image')) { + const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model) + if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) { + return err(request, { + code: 'attachment-error', + message: `Model "${defaults.model}" does not support image input.`, + details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + }) + } + } + const durable = await durablePromptContent(ctx, content) + if (mode === 'steer') agent.steer(durable, { source }) + else agent.send(durable, { source }) } catch (error: unknown) { + if (error instanceof AttachmentError) { + return err(request, { code: 'attachment-error', message: error.message, details: { reason: error.code } }) + } // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) } return ok(request, { accepted: true as const }) }, + async attachment(request) { + const { sessionId, attachmentId } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const ref = referencedImage(found.agent.session.events, String(attachmentId)) + if (ref === undefined) { + return err(request, { + code: 'attachment-error', + message: 'Image is not referenced by this session.', + details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + }) + } + try { + const stored = await ctx.attachments.readImage(ref) + return ok(request, { attachment: stored.ref, data: Buffer.from(stored.data).toString('base64') }) + } catch (error: unknown) { + if (error instanceof AttachmentError) { + return err(request, { code: 'attachment-error', message: error.message, details: { reason: error.code } }) + } + return err(request, { code: 'internal', message: 'Unable to read image attachment.', details: {} }) + } + }, + cancel(request) { const { sessionId } = request.payload const agent = ctx.agents.get(sessionId) @@ -346,15 +448,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, host: { - describe(request) { + async describe(request) { + const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model) // TODO(step2): version should read apps/cli's package.json; placeholder for now. - return Promise.resolve(ok(request, { + return ok(request, { version: '0.0.1', cwd: process.cwd(), provider: defaults.provider, model: defaults.model, + ...activeModel === undefined ? {} : { activeModel }, + imageLimits: { + ...ctx.attachments.imageLimits, + mediaTypes: [...ctx.attachments.imageLimits.mediaTypes], + }, attachedSessions: ctx.agents.list().length, - })) + }) }, }, diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index ca40b9f8b3..651f2d969e 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import Timer from '@cordisjs/plugin-timer' import LlmService from '@deepseek-ai/dsh-llm' +import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -14,6 +15,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import TaskService from '@deepseek-ai/dsh-tasks' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import * as toolBash from '@deepseek-ai/dsh-tool-bash' @@ -42,10 +45,14 @@ import * as spillPolicy from '@deepseek-ai/dsh-spill-policy' export interface BootHostOptions { /** Root directory for JSONL session persistence. */ persistenceRoot: string + /** Explicit harness home for durable attachments; omitted follows DSH_HOME then ~/.dsh. */ + dshHome?: string /** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */ provider?: string /** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */ model?: string + /** Additional pi-ai provider routes available to visual-capable Web sessions. */ + piAiProviders?: PiAiProviderProfile[] /** * Default project directory for sessions created without an explicit cwd * (defaults to the host process working directory). A session's cwd is its @@ -88,6 +95,9 @@ export async function bootHost(options: BootHostOptions): Promise { const ctx = new Context() await ctx.plugin(Timer) await ctx.plugin(LlmService) + await ctx.plugin(LocalAttachmentStore, { + ...options.dshHome === undefined ? {} : { dshHome: options.dshHome }, + }) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRegistry) @@ -95,6 +105,9 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(TaskService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, {}) + if (options.piAiProviders !== undefined && options.piAiProviders.length > 0) { + await ctx.plugin(LlmPiAi, { providers: options.piAiProviders }) + } await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot, compression: 'none' }) await ctx.plugin(LocalBashExecutor, {}) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f3a2dc9a82..c48931e579 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -1,11 +1,11 @@ -import { mkdtempSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmModelInfo, ModelModality, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -15,10 +15,20 @@ import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/i /** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */ class ScriptedAdapter extends LlmAdapter { - constructor(private script: (StreamChunk[] | 'hang')[]) { + constructor( + private script: (StreamChunk[] | 'hang')[], + private readonly inputModalities: readonly ModelModality[] = ['text', 'image'], + ) { super() } + override listModels(provider: string): Promise { + return Promise.resolve([{ + provider, id: 'test-model', name: 'test-model', + inputModalities: this.inputModalities, outputModalities: ['text'], + }]) + } + async * stream(options: GenerateOptions): AsyncIterable { const entry = this.script.shift() if (!entry) throw new Error('ScriptedAdapter: script exhausted') @@ -48,6 +58,8 @@ function request

(payload: P): RpcRequest

{ } let nextRpc = 1 +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' + function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject: Agent, status: string) => { @@ -171,14 +183,83 @@ describe('sessions.prompt / cancel', () => { }) it('maps a synchronous send throw to agent-busy', async () => { - const { api } = await boot() + const { api, ctx } = await boot() const { sessionId } = expectOk(await api.sessions.create(request({}))) - const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never - const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned })) + vi.spyOn(ctx.agents.get(sessionId) as Agent, 'send').mockImplementation(() => { + throw new Error('disposed during prompt') + }) + const response = await api.sessions.prompt(request({ + sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }], + })) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy') }) + it('persists uploaded bytes before the user event and serves them only through the owning session', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-image-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-image-home-')) + host = await startHost({ + boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('seen')])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + const idle = waitForIdle(host.ctx, agent) + const response = await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [ + { type: 'text' as const, text: 'describe' }, + { type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64, name: '/tmp/pixel.png' }, + ], + })) + expectOk(response) + await idle + + const user = agent.session.events.find(event => event.type === 'user/message') + const content = (user?.data as { content?: ContentBlock[] } | undefined)?.content ?? [] + const image = content.find(block => block.type === 'image') + expect(image?.type).toBe('image') + if (image?.type !== 'image') throw new Error('image block missing') + expect(JSON.stringify(user)).not.toContain(PNG_BASE64) + expect(image.attachment.name).toBe('pixel.png') + const sha256 = String(image.attachment.attachmentId).slice('sha256:'.length) + const object = join(dshHome, 'attachments', 'v1', 'objects', sha256.slice(0, 2), sha256) + expect(existsSync(object)).toBe(true) + expect(readFileSync(object).toString('base64')).toBe(PNG_BASE64) + + const loaded = expectOk(await host.api.sessions.attachment(request({ + sessionId, attachmentId: image.attachment.attachmentId, + }))) + expect(loaded).toEqual({ attachment: image.attachment, data: PNG_BASE64 }) + const { sessionId: other } = expectOk(await host.api.sessions.create(request({}))) + const denied = await host.api.sessions.attachment(request({ + sessionId: other, attachmentId: image.attachment.attachmentId, + })) + expect(denied.result).toMatchObject({ + ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + }) + }) + + it('rejects images for an explicitly text-only model without creating a session event', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-text-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-text-home-')) + host = await startHost({ + boot: { persistenceRoot, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + 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: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + }) + expect(host.ctx.agents.get(sessionId)?.session.events.some(event => event.type === 'user/message')).toBe(false) + expect(existsSync(join(dshHome, 'attachments'))).toBe(false) + }) + 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/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 9836e0b5fd..1264a1954c 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -17,9 +17,15 @@ { "path": "../../llm/llm" }, + { + "path": "../../attachment/attachment-local" + }, { "path": "../../llm/llm-deepseek" }, + { + "path": "../../llm/llm-pi-ai" + }, { "path": "../../core/session" }, diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 3ca81f678c..331fc1c34b 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -116,6 +116,8 @@ export class DeepSeekAdapter extends LlmAdapter { id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, + inputModalities: ['text'], + outputModalities: ['text'], }))) } diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f463a4e30e..5c411cfe15 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -2,10 +2,12 @@ * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by - * thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected. + * thinking-mode passback. Core image blocks are rejected explicitly because this wire route is text-only; + * unknown declaration-merged block types retain the adapter's documented extension fallback. * @module dsh-llm-deepseek/serialize */ +import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -23,6 +25,16 @@ function flattenText(blocks: ContentBlock[]): string { .join('') } +/** Reject core image content before any text-flattening path can silently erase it. */ +function assertTextOnly(blocks: readonly ContentBlock[]): void { + for (const block of blocks) { + if (block.type === 'image') { + throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') + } + if (block.type === 'tool-result') assertTextOnly(block.content) + } +} + /** Serialize one assistant message (text + reasoning + tool calls). */ function serializeAssistant(message: Message): WireMessage { const text = flattenText(message.content) @@ -68,6 +80,7 @@ function serializeAssistant(message: Message): WireMessage { export function serializeMessages(messages: Message[]): WireMessage[] { const wire: WireMessage[] = [] for (const message of messages) { + assertTextOnly(message.content) if (message.role === 'system') { wire.push({ role: 'system', content: flattenText(message.content) }) continue diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index f147323645..9f007040e7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -528,8 +528,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro', inputModalities: ['text'], outputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash')) .resolves.toEqual({ contextWindow: 128_000 }) @@ -540,8 +540,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro', inputModalities: ['text'], outputModalities: ['text'] }, ]) }) @@ -562,8 +562,8 @@ describe('plugin registration and config', () => { ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'private-fast', name: 'private-fast' }, - { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, + { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'], outputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast')) .resolves.toEqual({ contextWindow: 32_000 }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index e84909fc20..901697378a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '../src/serialize.ts' @@ -123,6 +124,19 @@ describe('serializeMessages', () => { expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) }) + it('rejects image blocks instead of silently flattening them away', () => { + expect(() => serializeMessages([{ + role: 'user', + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', bytes: 68, width: 1, height: 1, + }, + }], + }])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it('emits an empty user message rather than dropping block-less messages', () => { const wire = serializeMessages([{ role: 'user', content: [] }]) expect(wire).toEqual([{ role: 'user', content: '' }]) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 590e49f323..ac09dbf2c6 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", @@ -37,6 +38,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index ed0fb9fae4..64cdb1699f 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -12,6 +12,7 @@ import type { Model, SimpleStreamOptions, } from '@earendil-works/pi-ai' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' @@ -24,6 +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 } /** @@ -69,10 +72,12 @@ function requestHeaders(headers: Readonly> | undefined): */ export class PiAiAdapter extends LlmAdapter { private readonly profiles: ReadonlyMap + private readonly attachments: AttachmentStore | undefined constructor(options: PiAiAdapterOptions) { super() this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) + this.attachments = options.attachments } override listModels(provider: string): Promise { @@ -84,6 +89,8 @@ export class PiAiAdapter extends LlmAdapter { provider, id: model.id, name: model.name, + inputModalities: [...model.input], + outputModalities: ['text'], }))) } @@ -112,7 +119,6 @@ export class PiAiAdapter extends LlmAdapter { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } const model = resolveModel(profile, options.model) - const consumer = new AbortController() const upstream = options.signal === undefined ? consumer.signal @@ -121,7 +127,22 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const events = streamSimple(model, toPiContext(options), { + const containsImage = options.messages.some((message) => { + // The discriminant is part of same-process message validity and is read before content. + void message.role + return message.content.some(block => block.type === 'image' + || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image'))) + }) + 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) { + throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT') + } + const context = this.attachments === undefined + ? toPiContext(options) + : await toPiContext(options, this.attachments) + const events = streamSimple(model, context, { ...profileOptions(profile), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index ddb8284448..90d2176b1c 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,9 +4,10 @@ * @module dsh-llm-pi-ai/context */ -import { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { Context as PiContext, Message as PiMessage, Tool as PiTool } from '@earendil-works/pi-ai' +import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' /** Join the text blocks of a harness message. */ @@ -17,20 +18,122 @@ function flattenText(message: Message): string { .join('') } +async function userContent( + blocks: readonly ContentBlock[], + attachments: AttachmentStore, +): Promise { + const content: (TextContent | ImageContent)[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text.length > 0) content.push({ type: 'text', text: block.text }) + break + case 'image': { + const stored = await attachments.readImage(block.attachment) + content.push({ + type: 'image', + data: Buffer.from(stored.data).toString('base64'), + mimeType: stored.ref.mediaType, + }) + break + } + case 'tool-result': + break + default: + // Other merge-extensible blocks are not user-input vocabulary for pi-ai. + break + } + } + if (content.every(block => block.type === 'text')) return content.map(block => block.text).join('') + return content +} + +function toolsOf(options: GenerateOptions): PiTool[] | undefined { + return options.tools?.map(tool => ({ + name: tool.name, + description: tool.description, + // ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema + // (TypeBox) is structurally JSON Schema, so it assigns directly. + parameters: tool.parameters, + })) +} + +/** Assemble the request-level pi-ai context envelope shared by both conversion paths. */ +function piContext(options: GenerateOptions, messages: PiMessage[]): PiContext { + const tools = toolsOf(options) + return { + ...options.system !== undefined ? { systemPrompt: options.system } : {}, + messages, + ...tools !== undefined && tools.length > 0 ? { tools } : {}, + } +} + +function textOnlyContext(options: GenerateOptions): PiContext { + const toolNames = new Map() + const messages: PiMessage[] = [] + for (const message of options.messages) { + if (message.content.some(block => block.type === 'image' + || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))) { + throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT') + } + if (message.role === 'system') { + messages.push({ role: 'user', content: flattenText(message), timestamp: 0 }) + continue + } + if (message.role === 'assistant') { + const assistant = toPiAssistant(message) + for (const block of assistant.content) if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name) + messages.push(assistant) + continue + } + const text = flattenText(message) + const results = message.content.filter(block => block.type === 'tool-result') + if (text.length > 0 || results.length === 0) messages.push({ role: 'user', content: text, timestamp: 0 }) + for (const result of results) { + messages.push({ + role: 'toolResult', + toolCallId: result.toolCallId, + toolName: toolNames.get(result.toolCallId) ?? 'unknown', + content: [{ + type: 'text', + text: result.content.filter(block => block.type === 'text').map(block => block.text).join('') || '(no output)', + }], + isError: result.isError ?? false, + timestamp: 0, + }) + } + } + return piContext(options, messages) +} + /** - * Convert harness history to a pi-ai Context. Tool results need the tool - * NAME (pi-ai's `toolName`), which the harness doesn't carry on the result - * block — it is recovered from the preceding assistant tool-call with the - * same id. + * Convert text-only harness history to a synchronous pi-ai Context. Tool + * result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @returns the pi-ai context; `tools` is omitted entirely when the request declares none. + * @returns the pi-ai context; `tools` is omitted when the request declares none. */ -export function toPiContext(options: GenerateOptions): PiContext { +export function toPiContext(options: GenerateOptions): PiContext +/** + * Convert harness history to a pi-ai Context while resolving durable images. + * Tool result names are recovered from preceding assistant tool calls. + * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. + * @param attachments - durable byte resolver for image references. + * @returns the asynchronously resolved pi-ai context. + */ +export function toPiContext(options: GenerateOptions, attachments: AttachmentStore): Promise +export function toPiContext(options: GenerateOptions, attachments?: AttachmentStore): PiContext | Promise { + return attachments === undefined ? textOnlyContext(options) : toPiContextWithImages(options, attachments) +} + +async function toPiContextWithImages(options: GenerateOptions, attachments: AttachmentStore): Promise { const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { if (message.role === 'system') { + if (message.content.some(block => block.type === 'image')) { + throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') + } // pi-ai has a single systemPrompt slot; in-history system messages are // folded into user messages to preserve order (rare in practice — the // harness sends the system prompt via options.system). @@ -46,40 +149,26 @@ export function toPiContext(options: GenerateOptions): PiContext { continue } // user role: text + tool results (each result becomes its own message). - const text = flattenText(message) + const regular = message.content.filter(block => block.type !== 'tool-result') + const content = await userContent(regular, attachments) const results = message.content.filter(block => block.type === 'tool-result') - if (text.length > 0 || results.length === 0) { - messages.push({ role: 'user', content: text, timestamp: 0 }) + if (content.length > 0 || results.length === 0) { + messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { + const resultContent = await userContent(result.content, attachments) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, toolName: toolNames.get(result.toolCallId) ?? 'unknown', - content: [{ - type: 'text', - text: result.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') || '(no output)', - }], + content: typeof resultContent === 'string' + ? [{ type: 'text', text: resultContent || '(no output)' }] + : resultContent, isError: result.isError ?? false, timestamp: 0, }) } } - const tools: PiTool[] | undefined = options.tools?.map(tool => ({ - name: tool.name, - description: tool.description, - // ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema - // (TypeBox) is structurally JSON Schema, so it assigns directly. - parameters: tool.parameters, - })) - - return { - ...options.system !== undefined ? { systemPrompt: options.system } : {}, - messages, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - } + return piContext(options, messages) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ab08f21b81..2b0d842e47 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -36,6 +36,10 @@ 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 adapter = new PiAiAdapter({ profiles }) + const attachments = ctx.get('attachments') + const adapter = new PiAiAdapter({ + profiles, + ...(attachments === undefined ? {} : { attachments }), + }) ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) } diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 4e4fe679a5..a4ff9fb9b6 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -134,6 +134,8 @@ function foreignAssistant(message: Message): AssistantMessage { name: block.name, arguments: parseArguments(block.arguments), }); break + case 'image': + throw new LlmError('pi-ai chat history cannot represent structured assistant image output', 'UNSUPPORTED_CONTENT') default: // plugin-added block types are not representable in pi-ai. break diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index f37b07f624..63e30c0ed5 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -320,6 +320,7 @@ describe('provider profile lifecycle', () => { const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', + inputModalities: ['text', 'image'], outputModalities: ['text'], }) expect(models.every(model => model.provider === 'openai')).toBe(true) const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1') diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 15471875d2..23f0cd78ad 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { toPiContext } from '../src/context.ts' @@ -63,6 +65,48 @@ describe('toPiContext', () => { expect(context.tools).toBeUndefined() }) + it('resolves durable image references into native pi-ai image content', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 3, + width: 1, + height: 1, + } + const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const context = await toPiContext({ + provider: 'openai', + model: 'gpt-4.1', + messages: [{ + role: 'user', + content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], + }], + }, { readImage } as unknown as AttachmentStore) + + expect(readImage).toHaveBeenCalledWith(attachment) + expect(context.messages[0]).toEqual({ + role: 'user', + content: [ + { type: 'text', text: 'describe' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ], + timestamp: 0, + }) + }) + + it('rejects structured image history when no durable resolver is supplied', () => { + expect(() => toPiContext({ + provider: 'openai', model: 'gpt-4.1', + messages: [{ role: 'user', content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }] }], + })).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it('maps assistant text/reasoning/tool-call blocks', () => { const context = toPiContext({ provider: 'deepseek', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 45c2af21a5..91af731a0f 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../support/invariants" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index cc13bc183e..a518647aa3 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -36,11 +36,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 6765833e8c..075208bfe8 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -235,6 +235,8 @@ export class LlmService extends Service { id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, + ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] }, + ...model.outputModalities === undefined ? {} : { outputModalities: [...model.outputModalities] }, } }) } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 12febecf42..325138ab02 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,6 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { CallId, ProviderRequestId } from './brand.ts' /** Serializable provider-boundary facts; policy decides whether they are retryable. */ @@ -33,6 +34,15 @@ export interface ReasoningBlock { text: string } +/** A durable raster image reference, valid in user or assistant content. */ +export interface ImageBlock { + type: 'image' + /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ + attachment: ImageAttachmentRef + /** Optional provider- and UI-facing alternative text. */ + alt?: string +} + /** A tool invocation requested by the model. */ export interface ToolCallBlock { type: 'tool-call' @@ -58,6 +68,7 @@ export interface ToolResultBlock { export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } @@ -143,6 +154,15 @@ export interface LlmProviderInfo { name: string } +/** Merge-extensible provider model modality vocabulary. */ +export interface ModelModalityMap { + text: 'text' + image: 'image' +} + +/** Any declared provider model modality. */ +export type ModelModality = ModelModalityMap[keyof ModelModalityMap] + /** One adapter-discovered model; catalog membership is advisory, not request validation. */ export interface LlmModelInfo { /** Provider route that owns this model entry. */ @@ -153,6 +173,10 @@ export interface LlmModelInfo { name: string /** Optional user-facing distinction from otherwise similar models. */ description?: string + /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ + inputModalities?: readonly ModelModality[] + /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ + outputModalities?: readonly ModelModality[] } /** Provider-owned context capacity for one exact provider/model route. */ diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 5bc7a9fcf5..fa9af39002 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../util/brand" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../support/invariants" } diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index e17b0c06b6..7a22c6dfbe 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -25,6 +25,11 @@ const CHARS_PER_TOKEN = 4 /** Per-block structural overhead for JSON framing and type tags. */ const BLOCK_OVERHEAD = 4 +/** Provider-neutral visual estimate: base cost plus one cost unit per 512px tile. */ +const IMAGE_BASE_TOKENS = 85 +const IMAGE_TILE_TOKENS = 170 +const IMAGE_TILE_EDGE = 512 + /** Role-field framing overhead added to every priced message. */ const ROLE_OVERHEAD = 4 @@ -357,6 +362,12 @@ export class TokenMeterService extends Service { case 'reasoning': tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD break + case 'image': { + const tiles = Math.ceil(block.attachment.width / IMAGE_TILE_EDGE) + * Math.ceil(block.attachment.height / IMAGE_TILE_EDGE) + tokens += IMAGE_BASE_TOKENS + tiles * IMAGE_TILE_TOKENS + BLOCK_OVERHEAD + break + } case 'tool-call': tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 91453e3387..dfb7ba4e2b 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -56,6 +56,8 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | switch (block.type) { case 'text': return { type: 'text', text: block.text } + case 'image': + return { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` } // reasoning → streamed as agent_thought_chunk, not a message block // tool-call / tool-result → the tool_call / tool_call_update path // plugin-added block types → not surfaced diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9b18ff2e5..d088f4922d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,6 +362,37 @@ importers: specifier: 1.1.0 version: 1.1.0 + packages/attachment/attachment: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/attachment/attachment-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../attachment + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-invariants': @@ -486,6 +517,9 @@ importers: packages/client/connection: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -521,6 +555,9 @@ importers: packages/client/runtime: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -555,6 +592,9 @@ importers: packages/client/ui-conversation: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-i18n': specifier: workspace:^ version: link:../i18n @@ -1917,6 +1957,9 @@ importers: packages/host/apiproxy: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -1960,6 +2003,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-attachment-local': + specifier: workspace:^ + version: link:../../attachment/attachment-local '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../bash/bash-local @@ -2005,6 +2051,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../llm/llm-pi-ai '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2096,6 +2145,9 @@ importers: packages/llm/llm: devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -2134,6 +2186,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4131,6 +4186,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../packages/attachment/attachment '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../packages/bash/bash diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index dbafb21881..d3762a4855 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -13,6 +13,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 40f1a22377..4b4a62c2bd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -57,6 +57,9 @@ export const LINK_MAP: Record = { ApprovalPolicy: 'approval.md', ApprovalRequest: 'approval.md', ApprovalService: 'approval.md', + ImageAttachmentRef: 'attachment.md', + SaveImageAttachment: 'attachment.md', + StoredImageAttachment: 'attachment.md', BashExecRequest: 'bash.md', BashExecSpec: 'bash.md', BashProcess: 'bash.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c93f70cf81..4e92ffdb97 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -56,6 +56,7 @@ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service' const GROUP_ORDER = [ 'util', + 'attachment', 'llm', 'core', 'goal', @@ -82,6 +83,15 @@ const GROUP_ORDER = [ ] const SERVICE_ROLES: ServiceRole[] = [ + { + key: 'attachments', + pkg: 'attachment', + title: 'Durable binary attachment storage', + mode: 'seam', + implementations: ['attachment-local'], + consumers: ['host-runtime', 'llm-pi-ai'], + note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', + }, { key: 'llm', pkg: 'llm', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f95f47e580..1c223839b3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -609,6 +609,31 @@ "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "ImageMediaType", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "ImageAttachmentRef", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "ImageAttachmentLimits", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "SaveImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/core-data-structures/attachment.md", + "symbol": "StoredImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/core-data-structures/bash.md", "symbol": "DshEnvironmentKey", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6fc46dbdb8..ee2afb4094 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -41,6 +41,8 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, + 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2018a1c747..47406bd02a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -66,6 +66,7 @@ "./packages/tasks/*/src/invariant.ts", "./packages/workflow/*/src/invariant.ts", "./packages/web/*/src/invariant.ts", + "./packages/attachment/*/src/invariant.ts", "./packages/spill/*/src/invariant.ts", "./packages/timeout/*/src/invariant.ts", "./packages/todo/*/src/invariant.ts", @@ -131,6 +132,7 @@ "./packages/tasks/*/src", "./packages/workflow/*/src", "./packages/web/*/src", + "./packages/attachment/*/src", "./packages/spill/*/src", "./packages/timeout/*/src", "./packages/todo/*/src", From 580e05b7943a9bf768b3fa17b42cff05ba0137c5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 19:38:37 +0800 Subject: [PATCH 02/45] 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) { From 304f034c83fb38d7fdcbdb7810c85a0007dfc964 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 20:07:40 +0800 Subject: [PATCH 03/45] fix(ci): repair branch gates after the master merge - regenerate docs/module-graph.md for the attachment packages - pack @deepseek-ai/dsh-attachment in the packed-install e2e closure so npm resolves the new dsh-llm peer from the tarball set instead of the registry - gate the pi-ai anthropic e2e profile strictly on ANTHROPIC_API_KEY: the DeepSeek endpoint does not serve anthropic-messages, so the DEEPSEEK_API_KEY fallback turned the keyless skip into a 404 across the whole suite --- .claude/worktrees/provider-file-ids | 1 + docs/module-graph.md | 32 +++++++++++++------ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 6 ++-- .../sandbox-local/tests/packed-install.e2e.ts | 1 + 4 files changed, 29 insertions(+), 11 deletions(-) create mode 160000 .claude/worktrees/provider-file-ids diff --git a/.claude/worktrees/provider-file-ids b/.claude/worktrees/provider-file-ids new file mode 160000 index 0000000000..7069d37059 --- /dev/null +++ b/.claude/worktrees/provider-file-ids @@ -0,0 +1 @@ +Subproject commit 7069d37059db3a48dec1ef748bb71ada9a85d5d4 diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..b77129f8b3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -131,6 +131,10 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] @@ -230,14 +234,10 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_runtime --> pkg_invariants pkg_host_webserver --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_invariants - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_invariants @@ -250,9 +250,21 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_timeout @@ -805,15 +817,17 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | 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 6bee0aa0d0..85dcbd5706 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -25,8 +25,10 @@ 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 +// Strictly ANTHROPIC_*: the DeepSeek endpoint does not serve the anthropic-messages +// protocol, so falling back to DEEPSEEK_API_KEY turns the keyless skip into a 404. +const anthropicApiKey = process.env.ANTHROPIC_API_KEY +const anthropicBaseURL = process.env.DSH_PI_AI_ANTHROPIC_BASE_URL const providerCases: ProviderCase[] = [ { diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 9fcfe23de8..e453aad6a4 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -25,6 +25,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-local', 'packages/sandbox/sandbox', 'packages/llm/llm', + 'packages/attachment/attachment', 'packages/util/brand', 'packages/support/invariants', ] From b868355f012d316b3d21ea7583ddb58a1f09be62 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 20:08:08 +0800 Subject: [PATCH 04/45] chore: drop accidentally committed .claude/worktrees gitlink and ignore the directory --- .claude/worktrees/provider-file-ids | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 160000 .claude/worktrees/provider-file-ids diff --git a/.claude/worktrees/provider-file-ids b/.claude/worktrees/provider-file-ids deleted file mode 160000 index 7069d37059..0000000000 --- a/.claude/worktrees/provider-file-ids +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7069d37059db3a48dec1ef748bb71ada9a85d5d4 diff --git a/.gitignore b/.gitignore index ae9b4b5ddd..b714a02b38 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ python/**/__pycache__/ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ +.claude/worktrees/ From f57a4a044ad036b6b46718af12e163f655e3391c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 20:55:25 +0800 Subject: [PATCH 05/45] fix(gui): address ds-review-bot findings on image attachments - dsh web gains --provider/--model: a non-deepseek provider mounts the matching pi-ai catalog route (ambient credentials), making image input reachable from the shipped Web assembly; requires an explicit --model - attachment-local syncs the publication directories after the hard-link publish so a reported durable reference survives a crash (POSIX; Windows relies on filesystem metadata journaling) - the attachment seam gains storage-free validateImage; the host validates a complete multi-image prompt before persisting any member, so one malformed image cannot strand valid members as unreferenced objects - startSession sends before navigating: a rejected first send keeps the empty state, its error strip, and the complete draft mounted - the webserver rejects an undeclared-length body the moment it crosses the configured limit instead of draining a potentially endless stream to EOF --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +-- ...dal-image-input-and-durable-attachments.md | 10 +++--- ...-image-input-and-durable-attachments.zh.md | 10 +++--- apps/cli/README.md | 2 +- apps/cli/src/web.ts | 20 ++++++++++- docs/cordis-catalog/services.md | 8 +++++ .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/src/index.ts | 8 +++-- .../attachment/attachment-local/src/store.ts | 33 +++++++++++++++++++ .../attachment-local/tests/index.spec.ts | 19 +++++++++++ packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/src/index.ts | 8 +++++ .../src/client/contract/slots.ts | 6 +++- .../ui-conversation/src/client/service.ts | 18 +++++----- .../tests/service-orchestration.spec.ts | 18 +++++++--- .../cordis/tool-cordis/src/api-catalog.ts | 4 +++ packages/host/runtime/src/api-proxy.ts | 10 ++++++ .../host/runtime/tests/host-runtime.spec.ts | 25 ++++++++++++++ packages/host/webserver/src/index.ts | 18 +++++----- .../host/webserver/tests/webserver.spec.ts | 21 ++++++++++++ packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 4 +++ scripts/test-invariants.ts | 4 +++ 23 files changed, 217 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/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 index 5d438dfc9d..19d5dbc04a 100644 --- a/.agents/notes/implemented/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: 156bec4a4f9bb05490847cbe775368bc472a33e9 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: fb0f8d3031bb2d0e6af97136892cd07c22d575b3 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: a88cc0fd460bcc2b7f29c8cf5a3b9a91c5fc10e6 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: ad2b8724e677c61121356921382d99d6e8741a0b diff --git a/.agents/notes/implemented/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 index 156bec4a4f..a88cc0fd46 100644 --- a/.agents/notes/implemented/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 @@ -26,7 +26,7 @@ Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-onl - 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; 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. +- A failed send restores the complete text and image draft. The empty state navigates to the new session only after its first send is accepted, so a rejected send keeps the draft and its error surface mounted. 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. @@ -63,7 +63,7 @@ interface ComposerAttachment { This split uses the slots framework's store seat and bound actions as the single subscription path for UI state while keeping non-serializable browser objects out of persisted JSON. Draft text and ordered image identifiers continue to use `localStorage`; after a reload, `ConversationRoot` prunes identifiers whose runtime objects no longer exist. Unsent images therefore do not survive reload because browser `File` and object URLs are not durable. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -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 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, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) 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. 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. @@ -108,7 +108,7 @@ 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. +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 — the complete batch, through the seam's storage-free `validateImage`, before persisting any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. 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 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. @@ -118,7 +118,7 @@ Model catalog entries gain optional merge-extensible input and output modality d 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 `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. +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. The shipped Web assembly reaches it through `dsh web --provider --model `, which mounts that pi-ai catalog route with the provider's ambient credentials; the DeepSeek-only default remains text-only. 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. @@ -134,7 +134,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### 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. 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. +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; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. 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. diff --git a/.agents/notes/implemented/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 index fb0f8d3031..ad2b8724e6 100644 --- a/.agents/notes/implemented/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 @@ -26,7 +26,7 @@ Status: implemented - 空状态输入区与常驻输入区共用附件栏;附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 - 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 - 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。 -- 发送失败时恢复完整的文本与图片草稿。移除、发送成功、空状态释放、已渲染会话释放和应用释放都会撤销各自持有的对象 URL。 +- 发送失败时恢复完整的文本与图片草稿。空状态只有在首次发送被接受后才导航到新会话,因此发送被拒绝时草稿及其错误提示仍保持挂载。移除、发送成功、空状态释放、已渲染会话释放和应用释放都会撤销各自持有的对象 URL。 - 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 - 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 - 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 @@ -63,7 +63,7 @@ interface ComposerAttachment { 这一拆分让 UI 状态通过 slots 框架的 store 席位和绑定 actions 使用唯一的订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。草稿文本和有序图片标识符继续使用 `localStorage`;重载后,`ConversationRoot` 会清理缺少对应运行时对象的标识符。未发送图片因此无法跨重载保留,因为浏览器 `File` 与对象 URL 不具备持久性。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -108,7 +108,7 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数——并在持久化任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用的孤儿对象。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 @@ -118,7 +118,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把默认模型和图片限制投影到 `SessionsService`;两种输入区都会在分配对象 URL 或 base64 前使用这些限制,只有无会话输入区会使用默认模型,针对明确仅支持文本的情况提前反馈。解码像素校验与每个常驻会话的实际路由均由宿主作出权威判定。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径——该命令用提供方的环境凭据挂载对应的 pi-ai 目录路由;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -134,7 +134,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。Web 载体会独立限制 API 请求体的缓冲大小;`dsh web` 根据图片总量限制加上 base64 和请求封装的膨胀量推导默认值,并允许显式覆盖。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。Web 载体会独立限制 API 请求体的缓冲大小;`dsh web` 根据图片总量限制加上 base64 和请求封装的膨胀量推导默认值,并允许显式覆盖;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/apps/cli/README.md b/apps/cli/README.md index e6a33247ca..89390a6dff 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -10,7 +10,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request. +The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. `dsh web --provider --model ` selects the default agent route; a non-`deepseek` provider additionally mounts that pi-ai catalog route with the provider's ambient credentials (e.g. `ANTHROPIC_API_KEY`), which is how image input reaches a visual-capable model, and requires an explicit `--model`. The headless surface retains deterministic fallback titles without making the auxiliary title-model request. ## Install (developer machine) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index ea7ed1f2eb..97da4734c1 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -49,6 +49,8 @@ export async function runWeb(argv: string[]): Promise { host: { type: 'string', default: LOOPBACK_HOST }, port: { type: 'string', default: '3080' }, 'max-request-body-bytes': { type: 'string' }, + provider: { type: 'string' }, + model: { type: 'string' }, dev: { type: 'boolean', default: false }, }, allowPositionals: false, @@ -77,12 +79,28 @@ export async function runWeb(argv: string[]): Promise { process.exit(1) } - // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). + // Default routing: --provider deepseek (implicit) keeps the DeepSeek-only + // assembly; any other --provider additionally mounts that pi-ai catalog + // route (credentials via the provider's ambient discovery, e.g. + // ANTHROPIC_API_KEY) so visual-capable models are reachable from the shipped + // Web app. A non-default provider requires an explicit --model — this shell + // has no evidence for inventing another provider's default. + const provider = values.provider ?? 'deepseek' + if (provider !== 'deepseek' && values.model === undefined) { + process.stderr.write(`dsh web: --provider ${provider} requires an explicit --model\n`) + process.exit(1) + } + + // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught + // by design); an unknown --provider fails the pi-ai catalog check the same way. const host = await startHost({ boot: { persistenceRoot: './.sessions', workspaceContext: { maxBytes: 65_536 }, sessionTitleLlm: true, + ...provider === 'deepseek' ? {} : { piAiProviders: [{ provider }] }, + ...values.provider === undefined ? {} : { provider: values.provider }, + ...values.model === undefined ? {} : { model: values.model }, }, }) const attachments = host.ctx.get('attachments') diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0c6e930a44..51297f8a45 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -253,6 +253,14 @@ Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-ap Immutable binary attachment service. Implementations validate bytes before publishing a reference. ```ts cordis-catalog +/** + * Validate one image against the deployment policy without persisting anything. + * Callers persisting a multi-image batch validate every member first so a + * malformed member cannot leave earlier members as unreferenced objects. + * @param input - encoded bytes, declared media type, and optional display name. + */ +abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 3e867695d6..d1c4327203 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. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. +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, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash; 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/index.ts b/packages/attachment/attachment-local/src/index.ts index acb06f16c9..18ce810f7b 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -6,10 +6,10 @@ import z from 'schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { readImageFile, saveImageFile } from './store.ts' +import { readImageFile, saveImageFile, validateImageFile } from './store.ts' export { detectImage } from './image.ts' -export { readImageFile, saveImageFile } from './store.ts' +export { readImageFile, saveImageFile, validateImageFile } from './store.ts' export { AttachmentError } from '@deepseek-ai/dsh-attachment' export type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -62,6 +62,10 @@ export class LocalAttachmentStore extends AttachmentStore { }) } + validateImage(input: SaveImageAttachment): void { + validateImageFile(input, this.imageLimits) + } + async saveImage(input: SaveImageAttachment): Promise { return saveImageFile(this.root, input, this.imageLimits) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 22e2ca2309..34431f5a2b 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -52,6 +52,32 @@ function validateAdmission(metadata: Omit { + /* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */ + if (process.platform === 'win32') return + const handle = await open(path, constants.O_RDONLY) + try { + await handle.sync() + } finally { + await handle.close() + } +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -86,6 +112,13 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } + // The synced file becomes durable only once its directory entries are: sync + // the bucket (the new object entry) and its parent (the possibly new bucket + // entry) before this reference can reach a session checkpoint. The dedup + // path syncs too — the earlier save that created the entry may have crashed + // before its own directory sync. + await syncDirectory(bucket) + await syncDirectory(join(root, 'objects')) await unlink(temporary) } catch (error) { /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index b21b0d544d..b99df8b179 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,4 +1,5 @@ import { Context } from 'cordis' +import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -36,4 +37,22 @@ describe('local attachment service', () => { await rm(dshHome, { recursive: true, force: true }) } }) + + it('validates without persisting: a rejected image leaves no storage root behind', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) }) + .toThrow(/Unsupported or malformed image data/) + const valid = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow() + // Validation is storage-free: nothing below the root may exist yet. + expect(existsSync(service.root)).toBe(false) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) }) diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 2fcc99c477..11e2d4035f 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,7 +2,7 @@ The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata. +Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so one malformed member cannot strand earlier members as unreferenced objects (there is no garbage collection). `readImage` verifies the content-addressed object against its logged metadata. ## Model Experience diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 000856be78..8ddf7dd1d1 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -33,6 +33,14 @@ export abstract class AttachmentStore extends Service { /** Deployment-resolved image policy used by authoritative and fast-path validation. */ abstract readonly imageLimits: ImageAttachmentLimits + /** + * Validate one image against the deployment policy without persisting anything. + * Callers persisting a multi-image batch validate every member first so a + * malformed member cannot leave earlier members as unreferenced objects. + * @param input - encoded bytes, declared media type, and optional display name. + */ + abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 6b46086be3..7bea1c7e33 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -187,7 +187,11 @@ export interface EmptyStateInjected { 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. */ + /** + * The create → first-send → navigate chain, in one service call. Navigation + * happens only after the send is accepted, so a failure leaves the empty + * state and its draft mounted. + */ startSession(opts: { cwd?: string text: string diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 0336809191..f6b0582d85 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -209,12 +209,12 @@ export class ConversationService extends Service { /** * Empty-state first-send chain (root-context method; does not read scope): - * create the session, navigate to it, then send through the new scope. - * The create → open ordering is safe: the manager merges the new summary - * synchronously before create() resolves, so the list store is projected by - * the time open() validates against it (manager notification batching is - * microtask-based; SessionsService projects on the same flush that create - * awaited through the RPC round trip). + * create the session, send through the new scope, and navigate only after + * the send is accepted. Navigation is the publication point — opening + * earlier would unmount the empty state (releasing its draft previews) + * while the send can still fail, leaving the failure with no surface and + * the user with a lost draft; on rejection here the still-mounted empty + * state keeps the draft and shows the error locally. * @param opts - project directory, prompt text, images, and send mode. */ async startSession(opts: { @@ -226,9 +226,10 @@ export class ConversationService extends Service { const sessions = this.requireSessions() const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd }) // The manager notifier flushes per microtask; one await guarantees the - // list-store projection landed before sessions.open validates against it. + // list-store projection landed before sessions.open validates against it + // (the manager merges the new summary synchronously before create() + // resolves; batching is microtask-based). await Promise.resolve() - sessions.open(id) const scoped = sessions.scope(id) if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`) // ctx.get, not scoped.conversation: property access walks the fiber @@ -237,6 +238,7 @@ export class ConversationService extends Service { const scopedConversation = scoped.get('conversation') if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope') await scopedConversation.send(opts.text, opts.mode, opts.images ?? []) + sessions.open(id) } /** Resolve the caller scope's Session or throw on root contexts. */ diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 1362fbd851..bc4c0200d0 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -2,7 +2,7 @@ /** * ConversationService orchestration half after the store-seat slimming: * scope-addressed send/cancel (result folding, root throw), the startSession - * chain (create → sessions.open → scoped send), and the service-unavailable + * chain (create → scoped send → sessions.open), and the service-unavailable * loud failures. Selection/draft state left this service for the declared * chat store (chat-store.spec.ts / selection-survival.spec.ts); the view * registry left for the 'conversation.view' slot (views-type-chain.spec.tsx). @@ -280,13 +280,23 @@ describe('image admission and URL lifecycle', () => { }) describe('startSession chain', () => { - it('creates, navigates through sessions.open, then sends through the new scope', async () => { + it('creates, sends through the new scope, then navigates through sessions.open', async () => { const b = await bench() await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' }) expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' }) expect(b.openMock).toHaveBeenCalledWith(sid('new-1')) - expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'first' }], 'queue') + const prompt = b.sessionDoubles.get(sid('new-1'))!.prompt + expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: 'first' }], 'queue') + // Navigation is the publication point: it must not precede send acceptance. + expect(b.openMock.mock.invocationCallOrder[0]!).toBeGreaterThan(prompt.mock.invocationCallOrder[0]!) + }) + + it('does not navigate when the first send is rejected (empty state keeps the draft)', async () => { + const b = await bench() + const doomed = b.sessionsFake.manager.get(sid('new-1')) as unknown as SessionDouble + doomed.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'nope' } }) + await expect(b.svc.startSession({ text: 'first', mode: 'queue' })).rejects.toThrow(/agent-busy/) + expect(b.openMock).not.toHaveBeenCalled() }) it('omits cwd from create when not chosen', async () => { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0ef2082f2a..cf4bcbff5c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -156,6 +156,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'attachments', summary: 'Immutable binary attachment service.', methods: [ + { + signature: 'abstract validateImage(input: SaveImageAttachment): void', + jsDoc: '/**\n * Validate one image against the deployment policy without persisting anything.\n * Callers persisting a multi-image batch validate every member first so a\n * malformed member cannot leave earlier members as unreferenced objects.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 5ca1e853b1..b56a49aab0 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -54,6 +54,16 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (totalBytes > limits.maxMessageImageBytes) { throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') } + // Validate the complete batch before persisting any member: the store has no + // garbage collection, so one malformed image must not leave the batch's + // valid members as published objects no message event will ever reference. + for (const image of images) { + ctx.attachments.validateImage({ + data: image.data, + mediaType: image.part.mediaType, + ...image.part.name === undefined ? {} : { name: image.part.name }, + }) + } return Promise.all(prepared.map(async (item): Promise => { if (!('data' in item)) return { type: 'text', text: item.text } const attachment = await ctx.attachments.saveImage({ diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f7cf357431..2abc8ba48f 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -614,6 +614,31 @@ describe('sessions.prompt / cancel', () => { }) }) + it('publishes nothing when one member of a multi-image prompt is malformed', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-batch-session-')) + const dshHome = mkdtempSync(join(tmpdir(), 'dsh-batch-home-')) + host = await startHost({ + boot: { persistenceRoot, workspaceContext: false, dshHome, provider: 'scripted', model: 'test-model' }, + }) + host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('unused')])) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + 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 }, + // Canonical base64, but the bytes are not a PNG: the whole batch must + // be validated before any member persists, or the valid image above + // would become a permanently unreferenced object (this store has no GC). + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQID' }, + ], + })) + expect(response.result).toMatchObject({ + ok: false, error: { details: { reason: 'INVALID_IMAGE' } }, + }) + expect(existsSync(join(dshHome, 'attachments'))).toBe(false) + }) + it('rejects images for an explicitly text-only model without creating a session event', async () => { const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-text-session-')) const dshHome = mkdtempSync(join(tmpdir(), 'dsh-text-home-')) diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 77c7bf4301..9406b66634 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -214,21 +214,19 @@ async function bridge( } const chunks: 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 + // Reject the moment the threshold is crossed: draining a chunked body to + // EOF first would let a client without Content-Length stream + // indefinitely while holding the socket and this request task. + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return } - if (!oversized) chunks.push(buffer) - } - if (oversized) { - res.writeHead(413) - res.end() - return + chunks.push(buffer) } /* 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 */ diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 63552127bc..c6ab019564 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -435,6 +435,27 @@ describe('/api bridge', () => { expect(status).toBe(413) }) + it('rejects an unterminated chunked body at the threshold without draining to EOF', async () => { + const base = await boot(() => undefined, 8) + const target = new URL(`${base}/api/echo`) + // The client never calls end(): the 413 must arrive the moment the limit + // is crossed, or a hostile stream would hold the socket open forever. + 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('123456789') + }) + 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/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index de12d81e5e..749627e961 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -226,6 +226,10 @@ describe('PiAiAdapter provider routing', () => { mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('not used') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } 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 85dcbd5706..09e21dea6e 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -74,6 +74,10 @@ async function harness(image?: StoredImageAttachment): Promise { mediaTypes: [fixture.ref.mediaType], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('e2e attachment fixture is read-only') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 0f58cbd059..c843f94a5a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -96,6 +96,10 @@ class TestAttachmentStore extends AttachmentStore { mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('test invariant attachment store does not validate images') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From dbd93fed023f5c696a1114f77c7197611a63faa1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 24 Jul 2026 21:42:27 +0800 Subject: [PATCH 06/45] test: follow send-unify event shape and refresh the cordis-inspect snapshot - host-runtime attachment-authorization tests append injected context as user/message with a plugin source (context/message was folded by send-unify) - refresh cordis-inspect-jsdoc expected outputs for the AttachmentStore validateImage seam addition --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- .../snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl | 2 +- packages/host/runtime/tests/host-runtime.spec.ts | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 487f0517b1..bc2e50cb82 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n alt?: string;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 56cc640977..23ea901fcb 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Inspect the exact tools service","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n alt?: string;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index d6c965dc00..1a27e1fd23 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -496,7 +496,8 @@ describe('sessions.prompt / cancel', () => { const { sessionId: nestedSession } = expectOk(await host.api.sessions.create(request({}))) const nestedAgent = host.ctx.agents.get(nestedSession) as Agent - nestedAgent.session.append('context/message', { + // Injected context is a user/message with a non-user source (send-unify). + nestedAgent.session.append('user/message', { content: [ null, [], @@ -511,7 +512,7 @@ describe('sessions.prompt / cancel', () => { content: [{ type: 'image', attachment: image.attachment }], }, ] as never, - source: { kind: 'user' }, + source: { kind: 'plugin', plugin: 'spec' }, }, { surfaceOp: 'append' }) expectOk(await host.api.sessions.attachment(request({ sessionId: nestedSession, @@ -534,9 +535,9 @@ describe('sessions.prompt / cancel', () => { ...image.attachment, attachmentId: `sha256:${'b'.repeat(64)}` as never, } - streamedAgent.session.append('context/message', { + streamedAgent.session.append('user/message', { content: [{ type: 'image', attachment: missingRef }], - source: { kind: 'user' }, + source: { kind: 'plugin', plugin: 'spec' }, }, { surfaceOp: 'append' }) const missing = await host.api.sessions.attachment(request({ sessionId: streamedSession, From 4db6628416d5d80ced44b4a4f2b8d11168f37152 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 23:23:11 +0800 Subject: [PATCH 07/45] fix(ci): refresh multimodal merge coverage --- docs/module-graph.md | 32 +++++++++++++------ packages/acp/acp/tests/turns.spec.ts | 29 +++++++++++++++++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 1 + 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 9e8b3adf8a..d7b83d278f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,10 @@ flowchart TD pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] @@ -240,17 +244,13 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_invariants @@ -273,9 +273,21 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_timeout @@ -813,10 +825,9 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | @@ -825,8 +836,11 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index cf081d1f2a..adef80adc2 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -38,6 +38,35 @@ describe('ACP prompt lifecycle', () => { await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') }) }) + it('renders an assistant image as an explicit attachment placeholder', async () => { + const attachmentId = `sha256:${'a'.repeat(64)}` as never + harness = await makeBridgeHarness({ + script: [[ + { type: 'block-start', index: 0, blockType: 'image' }, + { + type: 'block-end', + index: 0, + block: { + type: 'image', + attachment: { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + }, + { type: 'finish', reason: { kind: 'stop' } }, + ]], + }) + const sessionId = await newSession(harness) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) + await vi.waitFor(() => { + expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`) + }) + }) + it('rejects a failed turn and never publishes its partial chunks', async () => { harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] }) const sessionId = await newSession(harness) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index b2234db578..3740696e88 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -96,6 +96,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) + expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) }) From 1a27c0f7797c3c5a50ad646c261ff91d82ed6f7d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 23:27:34 +0800 Subject: [PATCH 08/45] refactor(gui): reuse image serialization --- packages/client/ui-conversation/src/client/service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2f55eabf78..e0b030f8e5 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -73,12 +73,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), - data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), - ...(file.name === '' ? {} : { name: file.name }), - }))) + const uploaded = await this.serializeImages(images) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode) if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) From a1835c3228682bc459556ab2264ebd52ec23c18b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 14:29:19 +0800 Subject: [PATCH 09/45] fix(gui): close attachment durability gaps --- .../attachment/attachment-local/src/store.ts | 36 ++++++++---- .../attachment-local/tests/store.spec.ts | 37 +++++++++++- packages/host/apiproxy/src/api/host.schema.ts | 4 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 56 ++++++++++++++++++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 25 ++++++++- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 34431f5a2b..e64647bee2 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' -import { basename, join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { AttachmentError, AttachmentId, @@ -78,6 +78,25 @@ async function syncDirectory(path: string): Promise { } } +/** + * Create one private directory tree and persist every newly published ancestor. + * @param path - absolute directory to create. + */ +async function ensureDurableDirectory(path: string): Promise { + const target = resolve(path) + const firstCreated = await mkdir(target, { recursive: true, mode: 0o700 }) + await chmod(target, 0o700) + if (firstCreated === undefined) return + + const highestCreated = resolve(firstCreated) + let created = target + while (true) { + await syncDirectory(dirname(created)) + if (created === highestCreated) return + created = dirname(created) + } +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -91,10 +110,8 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') - await mkdir(bucket, { recursive: true, mode: 0o700 }) - await mkdir(staging, { recursive: true, mode: 0o700 }) - await chmod(bucket, 0o700) - await chmod(staging, 0o700) + await ensureDurableDirectory(bucket) + await ensureDurableDirectory(staging) const temporary = join(staging, randomUUID()) const target = objectPath(root, sha256) let handle @@ -112,11 +129,10 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } - // The synced file becomes durable only once its directory entries are: sync - // the bucket (the new object entry) and its parent (the possibly new bucket - // entry) before this reference can reach a session checkpoint. The dedup - // path syncs too — the earlier save that created the entry may have crashed - // before its own directory sync. + // Persist the target entry and close a concurrent bucket-creation window + // before the reference can reach a session checkpoint. The dedup path + // repeats both syncs because it may observe another writer's link before + // that writer reaches its own durability boundary. await syncDirectory(bucket) await syncDirectory(join(root, 'objects')) await unlink(temporary) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index d7ee6de31b..73ebb3bdec 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -1,12 +1,26 @@ import { createHash } from 'node:crypto' +import { constants } from 'node:fs' import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import { readImageFile, saveImageFile } from '../src/store.ts' +const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters): ReturnType { + if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0])) + return actual.open(...args) + }, + } +}) + const PNG = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -33,6 +47,27 @@ afterEach(async () => { }) describe('local attachment store', () => { + it.skipIf(process.platform === 'win32')('syncs every newly created object ancestor before returning', async () => { + const storageRoot = await root() + const base = join(storageRoot, '..', '..') + const sha256 = createHash('sha256').update(PNG).digest('hex') + const objects = join(storageRoot, 'objects') + const bucket = join(objects, sha256.slice(0, 2)) + fsControl.syncedDirectories.length = 0 + + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + expect(fsControl.syncedDirectories).toEqual([ + objects, + storageRoot, + join(storageRoot, '..'), + base, + storageRoot, + bucket, + objects, + ]) + }) + it('publishes one private content-addressed object and deduplicates equal bytes', async () => { const storageRoot = await root() const first = await saveImageFile(storageRoot, { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 4b484ae594..4e42ad55ce 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,11 +3,13 @@ */ import { z } from 'zod' +import type { ModelModality } from '@deepseek-ai/dsh-llm' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import { imageMediaTypeSchema } from './sessions.schema.ts' -const modalitySchema = z.union([z.literal('text'), z.literal('image')]) +/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */ +const modalitySchema = z.string() as unknown as z.ZodType /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 9f4f11b9df..00616da0b8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,12 +1,24 @@ import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' +import type { ResponseValue } from '../src/api/rpc-map.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' +declare module '@deepseek-ai/dsh-llm' { + interface ModelModalityMap { + audio: 'audio' + } +} + /** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */ -function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy { +function fakeApi(overrides: Partial<{ + muxFrames: MuxFrame[] + hostFrames: HostFrame[] + crashOn: string + hostDescription: ResponseValue<'host.describe'> +}> = {}): ApiProxy { const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] async function * stream(frames: F[], signal: AbortSignal): AsyncGenerator> { @@ -45,7 +57,13 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, host: { async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } + return { + rpcId: request.rpcId, + result: { + ok: true, + value: overrides.hostDescription ?? { version: 'v', cwd: '/w', attachedSessions: 0 }, + }, + } }, }, workspace: { @@ -137,6 +155,40 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) + it('round-trips a declaration-merged model modality through host.describe', async () => { + const c = client(fakeApi({ + hostDescription: { + version: 'v', + cwd: '/w', + activeModel: { + provider: 'future', + id: 'audio-model', + name: 'Audio Model', + inputModalities: ['text', 'audio'], + outputModalities: ['audio'], + }, + attachedSessions: 0, + }, + })) + + const response = await c.host.describe({}) + expect(response.result).toEqual({ + ok: true, + value: { + version: 'v', + cwd: '/w', + activeModel: { + provider: 'future', + id: 'audio-model', + name: 'Audio Model', + inputModalities: ['text', 'audio'], + outputModalities: ['audio'], + }, + attachedSessions: 0, + }, + }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index d257020298..042e5aa2a0 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -155,11 +155,32 @@ describe('sessions domain schemas', () => { }) describe('host domain schemas', () => { - it('validates describe request/value', () => { + it('validates describe request/value and preserves merge-extensible modalities', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 }) + const value = hostDescribeValueSchema.parse({ + version: '1', + cwd: '/x', + provider: 'p', + model: 'm', + activeModel: { + provider: 'p', + id: 'm', + name: 'Model', + inputModalities: ['text', 'audio'], + outputModalities: ['text', 'audio'], + }, + attachedSessions: 2, + }) expect(value.attachedSessions).toBe(2) + expect(value.activeModel?.inputModalities).toEqual(['text', 'audio']) + expect(value.activeModel?.outputModalities).toEqual(['text', 'audio']) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() + expect(() => hostDescribeValueSchema.parse({ + version: '1', + cwd: '/x', + activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] }, + attachedSessions: 0, + })).toThrow() }) }) From 7e68cf898614737b5fe41bce55dd1114d8aa5a9f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 14:45:46 +0800 Subject: [PATCH 10/45] test: cover the readAttachment and hostDescription bench stubs directly --- packages/client/test-runtime/tests/runtime.spec.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 22d3bb5cfc..21bef157b5 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -467,9 +467,12 @@ describe('fixture session face', () => { await runtime.sessions.add({ id: 's1' }) const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) + expect(() => bare.readAttachment('att-1' as Parameters[0])).toThrow(/readAttachment is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) + // No host handshake exists in the bench unless a fixture supplies one. + expect(runtime.sessions.hostDescription()).toBeUndefined() await runtime.dispose() }) From ae94d32c35d4a6866a01724589f6f1d06d8e22b9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 17:53:13 +0800 Subject: [PATCH 11/45] fix(fixture): follow the message-event shape and keep todo_write the alpha log tail The multimodal sample turn carried the pre-send-unify event shape (bare content/provenance instead of the message wrapper), crashing the built client fold; it also sat after the todo_write turn, retiring the plan projection the TodoPanel tests pin. Wrap both messages through the fixture helpers and order the image turn before the todo turn; re-record the code-mode trajectory ordinals the two added message nodes shift. --- apps/web/tests/code-mode-fixture.snapshot.ts | 6 +-- .../client/connection/src/client/fixture.ts | 53 ++++++++++--------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index ce5a98b2eb..6214332166 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -215,9 +215,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a }).toMatchInlineSnapshot(` { "subCells": [ - "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s", - "#52Subread · {"path":"notes/demo.txt"}+0.8s", - "#53Subread · {"path":"notes/missing.txt"}+0.8s", + "#49Subbash · {"command":"ls notes","description":"List notes"}+0.8s", + "#50Subread · {"path":"notes/demo.txt"}+0.8s", + "#51Subread · {"path":"notes/missing.txt"}+0.8s", ], } `) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 52336a1fe7..3e16ef28fa 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -48,10 +48,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u return createUserMessage({ content, source }) } -function assistantMessage(content: ContentBlock[]): AssistantMessage { +function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage { return createAssistantMessage({ content, - source: { provider: 'fixture', model: 'fx-1' }, + source: { provider: 'fixture', model }, }) } @@ -228,7 +228,30 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 65: multimodal image sample — image blocks in a user message and an + // assistant message, both referencing the fixture attachment (the log + // reference authorizes the sessions.attachment fetch). Ordered BEFORE the + // todo turn: the plan projection retires at the next turn/start, so the + // todo_write turn must stay the log tail for the TodoPanel strip to show. + push({ type: 'turn/start', data: { turn: 65, trigger: { kind: 'message', source: { kind: 'user' } } } }) + push({ + type: 'user/message', + surfaceOp: 'append', + data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), + }) + push({ type: 'step/start', data: { turn: 65, step: 0 } }) + push({ + type: 'assistant/message', + surfaceOp: 'append', + data: { + turn: 65, + step: 0, + message: assistantMessage([...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], 'fx-vision'), + }, + }) + push({ type: 'step/end', data: { turn: 65, step: 0 } }) + push({ type: 'turn/end', data: { turn: 65, reason: { kind: 'completed' } } }) + // Turn 66: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. const fixtureTodos = [ { content: '梳理需求', status: 'completed' }, @@ -236,35 +259,13 @@ function buildAlphaLog(): SessionEvent[] { { content: '浏览器验收', status: 'pending' }, ] const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). const callIndex = events.length - 4 const callTime = events[callIndex]?.time as number events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) - push({ type: 'turn/start', data: { turn: 66, trigger: { kind: 'message', source: { kind: 'user' } } } }) - push({ - type: 'user/message', - surfaceOp: 'append', - data: { - content: [{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')], - source: { kind: 'user' }, - }, - }) - push({ type: 'step/start', data: { turn: 66, step: 0 } }) - push({ - type: 'assistant/message', - surfaceOp: 'append', - data: { - turn: 66, - step: 0, - content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], - provenance: { provider: 'fixture', model: 'fx-vision' }, - }, - }) - push({ type: 'step/end', data: { turn: 66, step: 0 } }) - push({ type: 'turn/end', data: { turn: 66, reason: { kind: 'completed' } } }) events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } From e7b30799fec0bc57994f5fcb28c6e36d36fefdbc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 17:58:30 +0800 Subject: [PATCH 12/45] chore: retrigger CI (dropped webhook for 010d948aa) From adce3b833d66f1425cfcdbe8420870a3601c913f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 18:56:40 +0800 Subject: [PATCH 13/45] fix: address ds-review-bot v6/v7 findings on the image-input assembly - resolveLlmRoute: reuse the yml pi-ai row for providers it already routes (DUPLICATE_ADAPTER boot failure) and detect an unset model by origin, not by comparison against one deployment default; covered by a new spec. - LlmService.resolveModelInfoFor preserves (and validates) modality metadata, arming the host image preflight for exact-route resolution. - session.selectModel refuses a text-only target once the session log carries an image on any replayed route; an accepted switch would strand every later turn with no in-product recovery. - The composer no longer gates image intake on the handshake activeModel snapshot (wrong authority for a per-session decision); the host preflight plus the error strip own capability, deployment limits stay client-side. - InputHub shell teardown releases the scope's draft images (File objects and object URLs leaked for the page lifetime). - session.prompt image parts carry optional alt into the durable block; ImageBlock documents assistant-side rendering as forward compatibility. - Assembled built-client lane apps/web/tests/image-display.snapshot.ts pins the history galleries over the authorized attachment route, the lightbox, and the composer paste rail; the attachment rail is an accessible group. - Docs: validateImage on the seam page, fixture byte metadata matches its PNG, and the Agent Note claims now match the shipped coverage. --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 6 +- ...-image-input-and-durable-attachments.zh.md | 6 +- apps/cli/src/app-cli-entry.ts | 74 +++++++- apps/cli/tests/llm-route.spec.ts | 61 ++++++ apps/web/tests/image-display.snapshot.ts | 177 ++++++++++++++++++ .../core-data-structures/attachment.i18n.yaml | 4 +- docs/core-data-structures/attachment.md | 2 +- docs/core-data-structures/attachment.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 4 +- .../ui-conversation/src/client/input/hub.ts | 6 + .../ui-conversation/src/client/service.ts | 10 +- .../src/client/skeleton/InputBar.tsx | 2 +- .../tests/service-orchestration.spec.ts | 27 ++- packages/host/apiproxy/src/api-proxy.ts | 42 ++++- .../host/apiproxy/src/api/sessions.schema.ts | 2 +- packages/host/apiproxy/src/api/sessions.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 87 +++++++++ packages/llm/llm/src/index.ts | 12 ++ packages/llm/llm/src/types.ts | 9 +- packages/llm/llm/tests/service.spec.ts | 23 +++ 21 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 apps/cli/tests/llm-route.spec.ts create mode 100644 apps/web/tests/image-display.snapshot.ts diff --git a/.agents/notes/implemented/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 index 20d85c2712..c58ffcd573 100644 --- a/.agents/notes/implemented/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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 746ae8a5ffb111866c99c6aad5cb0906f252481e -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5061e8e29a14790d22456a44e44fa162d244f116 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 2b676c1965dbef0bdc24ba6d5b4af5bf66840780 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: f13648051bcf4c81c8b032897645c9b6aeb0d32a diff --git a/.agents/notes/implemented/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 index 746ae8a5ff..2b676c1965 100644 --- a/.agents/notes/implemented/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 @@ -120,7 +120,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida 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. 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 active model and image limits into `SessionsService`; the resident `InputBar` uses them before allocating object URLs or base64 for fast feedback. Decoded-pixel validation and the session's actual route remain authoritative on the host. +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 host-default active model and the image limits into `SessionsService`; the composer applies only the deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Decoded-pixel validation and the session's actual route remain authoritative on the host. 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. The shipped Web assembly reaches it through `dsh web --provider --model `, which mounts that pi-ai catalog route with the provider's ambient credentials; the DeepSeek-only default remains text-only. 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. @@ -194,8 +194,8 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog ## Testing - 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. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, and refusal of a text-only `session.selectModel` once the session log carries an image (an accepted switch would strand every later turn). +- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, and draft/session-scope/application object-URL cleanup; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. - 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. diff --git a/.agents/notes/implemented/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 index 5061e8e29a..f13648051b 100644 --- a/.agents/notes/implemented/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 @@ -120,7 +120,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把当前模型和图片限制投影到 `SessionsService`;常驻 `InputBar` 会在分配对象 URL 或 base64 前使用这些信息提供快速反馈。解码像素校验与会话的实际路由仍由宿主作出权威判定。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径——该命令用提供方的环境凭据挂载对应的 pi-ai 目录路由;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -194,8 +194,8 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制,以及大小受限的 HTTP 请求体。 -- 客户端单元测试和组装后的 Chromium 测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、历史用户与助手图片、原图预览、顺序,以及草稿、会话和应用层级的对象 URL 清理。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体,以及在会话日志已含图片时拒绝切换到纯文本模型的 `session.selectModel`(接受该切换会让此后每一轮都失败)。 +- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序,以及草稿、会话作用域和应用层级的对象 URL 清理;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 - 当前生产适配器集合声明仅支持文本输出;输出提供方认证不在第一版范围内。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 3fbbd1970a..4bed5bbba7 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -61,6 +61,61 @@ export function resolveLanTrust( return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } +/** One provider/model source layer for {@link resolveLlmRoute}, in override order. */ +export interface LlmRouteInput { + /** CLI flag values (highest precedence). */ + cli: { provider?: string | undefined; model?: string | undefined } + /** Profile-json values (parsed JSON — validated here, the config boundary). */ + profile: { provider?: unknown; model?: unknown } + /** The api-gateway yml row's config values (deployment defaults). */ + gateway: { provider?: unknown; model?: unknown } + /** Providers the shipped yml already routes through its static pi-ai row. */ + ymlPiAiProviders: readonly string[] +} + +/** The boot's resolved LLM routing decision. */ +export interface LlmRoute { + /** Effective api-gateway provider. */ + provider: string + /** Pi-ai provider to mount dynamically; undefined when DeepSeek or a yml-routed provider serves the request. */ + dynamicPiAiProvider: string | undefined +} + +/** + * Resolve the boot's LLM route from the layered provider/model sources. + * A non-DeepSeek provider requires a model set at least as explicitly as the + * provider itself (flag/profile) — origin decides, never a comparison against + * any deployment's default model value, so editing the yml default cannot + * silently disarm the guard. Providers the shipped yml pi-ai row already + * routes are NOT mounted again: `LlmService.registerAdapter` rejects + * duplicate routes, so the gateway provider/model patch alone selects them. + * @param input - the layered provider/model sources and the yml pi-ai roster. + * @returns the effective provider and the dynamic pi-ai mount decision. + */ +export function resolveLlmRoute(input: LlmRouteInput): LlmRoute { + const provider = input.cli.provider ?? input.profile.provider ?? input.gateway.provider + if (typeof provider !== 'string' || provider === '') { + throw new Error('dsh: api-gateway provider must be a non-empty string') + } + if (provider !== 'deepseek') { + const providerFromYml = input.cli.provider === undefined && input.profile.provider === undefined + // A yml-set provider trusts its own row pairing; an override must bring + // its model along instead of inheriting the yml default's. + const model = providerFromYml + ? input.gateway.model + : input.cli.model ?? input.profile.model + if (typeof model !== 'string' || model === '') { + throw new Error(`dsh: provider ${provider} requires an explicit model`) + } + } + return { + provider, + dynamicPiAiProvider: provider === 'deepseek' || input.ymlPiAiProviders.includes(provider) + ? undefined + : provider, + } +} + /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -207,15 +262,16 @@ export class AppCLIEntry { if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model) const gatewayConfig = rows.get('api-gateway')?.config as Record | undefined - const provider = this.options.provider ?? profile.provider ?? gatewayConfig?.provider - const model = this.options.model ?? profile.model ?? gatewayConfig?.model - if (typeof provider !== 'string' || provider === '') { - throw new Error('dsh: api-gateway provider must be a non-empty string') - } - if (provider !== 'deepseek' && (typeof model !== 'string' || model === '' || model === 'deepseek-v4-flash')) { - throw new Error(`dsh: provider ${provider} requires an explicit model`) - } - this.piAiProvider = provider === 'deepseek' ? undefined : provider + const piAiRow = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined + const route = resolveLlmRoute({ + cli: { provider: this.options.provider, model: this.options.model }, + profile: { provider: profile.provider, model: profile.model }, + gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model }, + ymlPiAiProviders: (piAiRow?.providers ?? []) + .map(p => p.provider) + .filter((value): value is string => typeof value === 'string'), + }) + this.piAiProvider = route.dynamicPiAiProvider // Source 2b: authorities for the /api browser-trust fence (rationale on // resolveLanTrust). diff --git a/apps/cli/tests/llm-route.spec.ts b/apps/cli/tests/llm-route.spec.ts new file mode 100644 index 0000000000..7dfc8adf41 --- /dev/null +++ b/apps/cli/tests/llm-route.spec.ts @@ -0,0 +1,61 @@ +/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */ +import { describe, expect, it } from 'vitest' +import { resolveLlmRoute } from '../src/app-cli-entry.ts' + +/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */ +const SHIPPED = { + gateway: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + ymlPiAiProviders: ['openai', 'anthropic'], +} + +describe('resolveLlmRoute', () => { + it('keeps the DeepSeek default without any dynamic mount', () => { + expect(resolveLlmRoute({ cli: {}, profile: {}, ...SHIPPED })) + .toEqual({ provider: 'deepseek', dynamicPiAiProvider: undefined }) + }) + + it('reuses the yml pi-ai row for providers it already routes (no duplicate adapter)', () => { + expect(resolveLlmRoute({ + cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, ...SHIPPED, + })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) + }) + + it('mounts pi-ai dynamically only for providers absent from the yml row', () => { + expect(resolveLlmRoute({ + cli: { provider: 'google', model: 'gemini-3-pro' }, profile: {}, ...SHIPPED, + })).toEqual({ provider: 'google', dynamicPiAiProvider: 'google' }) + }) + + it('requires an explicit model wherever the provider override came from, by origin', () => { + // CLI provider with no CLI/profile model: the yml DeepSeek default must not leak in. + expect(() => resolveLlmRoute({ cli: { provider: 'anthropic' }, profile: {}, ...SHIPPED })) + .toThrow(/provider anthropic requires an explicit model/) + // Profile provider paired with a profile model is explicit enough. + expect(resolveLlmRoute({ + cli: {}, profile: { provider: 'openai', model: 'gpt-5' }, ...SHIPPED, + })).toEqual({ provider: 'openai', dynamicPiAiProvider: undefined }) + // Profile provider with only the yml default model: same gap, same refusal. + expect(() => resolveLlmRoute({ cli: {}, profile: { provider: 'openai' }, ...SHIPPED })) + .toThrow(/provider openai requires an explicit model/) + }) + + it('trusts a yml-set non-DeepSeek provider only when its own row carries the model', () => { + expect(resolveLlmRoute({ + cli: {}, profile: {}, + gateway: { provider: 'anthropic', model: 'claude-opus-4-8' }, + ymlPiAiProviders: ['openai', 'anthropic'], + })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) + expect(() => resolveLlmRoute({ + cli: {}, profile: {}, + gateway: { provider: 'anthropic' }, + ymlPiAiProviders: ['openai', 'anthropic'], + })).toThrow(/provider anthropic requires an explicit model/) + }) + + it('fails loud on a missing or empty provider', () => { + expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] })) + .toThrow(/provider must be a non-empty string/) + expect(() => resolveLlmRoute({ cli: { provider: '' }, profile: {}, ...SHIPPED })) + .toThrow(/provider must be a non-empty string/) + }) +}) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts new file mode 100644 index 0000000000..b0d4125eac --- /dev/null +++ b/apps/web/tests/image-display.snapshot.ts @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session whose turn 65 carries an image in BOTH a +// user message and an assistant message, and pins the product surfaces: the +// history ImageGallery loading real fixture bytes through the authorized +// sessions.attachment route, the double-click ImageLightbox, and the composer +// intake chain (paste → thumbnail rail → image-only send enablement → remove). +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Boot the complete built client graph against the populated fixture branch. */ +function boot(): void { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) +} + +/** Open the fixture history session (the alpha log carrying the turn-65 image pair) and wait for its gallery. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group === null || group === undefined) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) +} + +it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => { + boot() + await openFixtureSession() + + // Both the user-side (align=end) and assistant-side (align=start) galleries + // load real fixture bytes over sessions.attachment (data: fallback in jsdom). + await waitFor(() => { + const user = document.querySelector('[data-align="end"] img') + const assistant = document.querySelector('[data-align="start"] img') + if (user === null || assistant === null) throw new Error('history image galleries missing') + // jsdom serves object URLs; environments without createObjectURL fall back to data:. + expect(user.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + expect(assistant.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + }, { timeout: 10_000 }) + const userImage = document.querySelector('[data-align="end"] img')! + expect(userImage.getAttribute('alt')).toBe('fixture-image.png') + + // Double-click opens the original-size lightbox; Escape/close dismisses it. + const frame = userImage.closest('button') + if (frame === null) throw new Error('image frame button missing') + fireEvent.doubleClick(frame) + const lightbox = await screen.findByRole('dialog') + expect(within(lightbox).getByRole('img').getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) + +it('accepts a pasted image into the composer rail and removes it', async () => { + boot() + await openFixtureSession() + + // Image-only send arming is pinned at package level (input-bar.spec.tsx); + // this assembled lane pins the intake chain over the built graph. + const textarea = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + + // The rail is an accessible group holding the draft thumbnail (queried via + // DOM: jsdom's a11y-visibility computation hides the composer subtree). + const rail = await waitFor(() => { + const el = document.querySelector('[role="group"][aria-label="待发送图片"]') + if (el === null) throw new Error('attachment rail missing') + return el + }, { timeout: 5_000 }) + expect(rail.querySelector('img')?.getAttribute('src')).toMatch(/^(blob:|data:)/) + + const remove = rail.querySelector('button[aria-label^="移除图片"]') + if (remove === null) throw new Error('remove button missing') + fireEvent.click(remove) + await waitFor(() => { + expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull() + }) +}) diff --git a/docs/core-data-structures/attachment.i18n.yaml b/docs/core-data-structures/attachment.i18n.yaml index 2edde07da0..145d21a267 100644 --- a/docs/core-data-structures/attachment.i18n.yaml +++ b/docs/core-data-structures/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/attachment.md -attachment.md: bb25b62b3260dff0ae85a03819c8655213c25563 -attachment.zh.md: b9bf182ef7623d2d121f6d6190fd14de49401f67 +attachment.md: c4c974b075300025868e90f9905193d62d68c4ee +attachment.zh.md: 12802a414018d081460459ad96ee4aa91801da62 diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md index bb25b62b32..c4c974b075 100644 --- a/docs/core-data-structures/attachment.md +++ b/docs/core-data-structures/attachment.md @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks (magic bytes, declared media type, size and pixel limits) without persisting anything — batch callers MUST validate every member through it before persisting any, so a rejected batch leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/attachment.zh.md b/docs/core-data-structures/attachment.zh.md index b9bf182ef7..12802a4140 100644 --- a/docs/core-data-structures/attachment.zh.md +++ b/docs/core-data-structures/attachment.zh.md @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行同一套准入检查(magic bytes、声明的媒体类型、大小与像素上限)但不落任何持久化——批量调用方必须先对每个成员通过它校验、再持久化任何一个,从而保证被拒绝的批次不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 3e16ef28fa..5bcf2ee608 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -108,7 +108,9 @@ const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklE const FIXTURE_IMAGE_REF: ImageAttachmentRef = { attachmentId: 'fixture:image' as AttachmentIdType, mediaType: 'image/png', - bytes: 68, + // Matches the decoded FIXTURE_IMAGE_DATA exactly (the real backend serves + // verified metadata; a mismatched fixture would mislead comparisons). + bytes: 247, width: 160, height: 90, name: 'fixture-image.png', diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index ad8e941901..66c2b5189d 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -28,6 +28,7 @@ interface ConversationAttachmentFace { mode: 'queue' | 'steer', imageIds: readonly string[], ): Promise + releaseDraftImage(id: string): void } /** Session-addressed input facade registry (InputService face + composer-layer extras). */ @@ -84,8 +85,13 @@ export class InputHub implements InputService { ] return () => { for (const off of offs) off() + // Draft attachments die with the scope: the shell only holds ids, so + // the service-owned File objects and object URLs must be released + // here or they leak for the page lifetime. + const drafts = shell.snapshot.imageIds shell.dispose() this.shells.delete(id) + for (const imageId of drafts) this.conversation().releaseDraftImage(imageId) } }, 'conversation.input: session shell') return shell diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 7787a1e016..94afce5a85 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -330,11 +330,13 @@ export class ConversationService extends Service implements IConversation { current: readonly ComposerAttachment[], ): void { if (files.length === 0 && current.length === 0) return + // Deployment-wide limits only. Model capability is deliberately NOT + // checked here: the handshake's activeModel is the host default, not the + // session's current target (session.selectModel never refreshes it), so a + // client-side modality gate refuses sessions the host would accept and + // vice versa. The host preflight on session.prompt is the authority; its + // rejection renders through the composer error strip. const description = this.requireSessions().hostDescription() - const modalities = description?.activeModel?.inputModalities - if (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) { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 063e5bc535..524f341c9b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -385,7 +385,7 @@ export function InputBar({ {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {attachments.length > 0 && ( -
+
{attachments.map(attachment => (
return ( <> @@ -55,7 +54,7 @@ export function MessageImage({ attachment, alt, load }: { /** Wrapping image group shared by user and assistant history. */ export function ImageGallery({ images, load, align }: { - images: readonly { attachment: ImageAttachmentRef; alt?: string }[] + images: readonly { attachment: ImageAttachmentRef }[] load: ImageLoader align: 'start' | 'end' }) { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 31084387df..1d5ee66a72 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -23,18 +23,17 @@ type UserImage = Extract function contentParts(content: readonly unknown[]): { text: string - images: { attachment: UserImage['attachment']; alt?: string }[] + images: { attachment: UserImage['attachment'] }[] rest: unknown[] } { const texts: string[] = [] - const images: { attachment: UserImage['attachment']; alt?: string }[] = [] + const images: { attachment: UserImage['attachment'] }[] = [] const rest: unknown[] = [] for (const block of content) { - const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string } + const b = block as { type?: string; text?: string; attachment?: unknown } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) else if (b.type === 'image' && b.attachment !== undefined) { - const image = b as UserImage - images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } }) + images.push({ attachment: (b as UserImage).attachment }) } else rest.push(block) } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 96a66880ae..e0568ce70f 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -151,7 +151,12 @@ export class InputHub implements InputService { // see them — release the drafts here instead of resurrecting them onto // a dead instance where they would leak for the page lifetime. if (this.shells.get(session.sessionId) === shell) { - shell?.restoreImages(imageIds) + if (shell?.snapshot.imageIds.length === 0) { + shell.restoreImages(imageIds) + } else { + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const id of imageIds) conversation?.releaseDraftImage(id) + } if (shell?.snapshot.draft === '') shell.setDraft(text) return } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 94afce5a85..1259664b45 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -29,10 +29,9 @@ export interface IConversation { * Send a prompt into the caller scope's session. * @param text - prompt text, sent verbatim as one text block. * @param mode - queue after the current turn, or steer into it. - * @param images - browser-owned temporary images promoted by the host during this call. * @returns completion; business failures reject (and land in promptError). */ - send(text: string, mode: 'queue' | 'steer', images?: readonly File[]): Promise + send(text: string, mode: 'queue' | 'steer'): Promise /** * Cancel the scoped session's in-flight turn. * @returns completion; failures reject as in send. @@ -43,57 +42,11 @@ export interface IConversation { * @returns completion of the page pull. */ loadOlder(): Promise - /** - * Create runtime-only draft attachments and preview URLs. - * @param files - browser-owned image files. - * @param current - images already present in the composer. - * @returns ordered descriptors for the input state. - */ - createDraftImages( - files: readonly File[], - current?: readonly ComposerAttachment[], - ): readonly ComposerAttachment[] - /** - * Resolve ordered draft ids to runtime-owned attachments. - * @param ids - ordered composer attachment ids. - * @returns attachments still available in this browser runtime. - */ - draftImages(ids: readonly string[]): readonly ComposerAttachment[] - /** - * Release one draft attachment and its preview URL. - * @param id - draft-local attachment id. - */ - releaseDraftImage(id: string): void - /** - * Resolve a session-authorized historical image to an object URL. - * @param sessionId - session whose durable log grants the read. - * @param attachment - durable image reference from that log. - * @returns browser URL for inline and original-size rendering. - */ - resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise - /** - * Release every historical image URL owned by one rendered session. - * @param sessionId - session whose rendered image scope is ending. - */ - releaseSessionImages(sessionId: SessionId): void } -/** 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 - - constructor(file: File) { - this.id = crypto.randomUUID() - this.previewUrl = URL.createObjectURL(file) - this.#file = file - } - - get file(): File { - return this.#file - } +/** Create one browser-only draft descriptor; only its id enters input state. */ +function browserDraftAttachment(file: File): ComposerAttachment { + return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file } } interface ImageUrlEntry { @@ -106,7 +59,7 @@ interface ImageUrlEntry { export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ readonly input: InputService - private readonly draftAttachments = new Map() + private readonly draftAttachments = new Map() private readonly imageUrls = new Map() private readonly imageGenerations = new Map() private readonly createdImageUrls = new Set() @@ -135,11 +88,10 @@ export class ConversationService extends Service implements IConversation { * exists for caller choreography (the composer restores the draft on it). * @param text - prompt text, sent verbatim as one text block when non-empty. * @param mode - queue after the current turn, or steer into it. - * @param images - browser-owned temporary images promoted by the host during this call. */ - async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { + async send(text: string, mode: 'queue' | 'steer'): Promise { const session = this.scopedSession('send') - await this.sendFiles(session, text, mode, images) + await this.sendFiles(session, text, mode, []) } /** @@ -190,7 +142,7 @@ export class ConversationService extends Service implements IConversation { ): readonly ComposerAttachment[] { this.validateImages(files, current) return files.map((file) => { - const attachment = new BrowserDraftAttachment(file) + const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) this.createdImageUrls.add(attachment.previewUrl) return attachment @@ -330,19 +282,12 @@ export class ConversationService extends Service implements IConversation { current: readonly ComposerAttachment[], ): void { if (files.length === 0 && current.length === 0) return - // Deployment-wide limits only. Model capability is deliberately NOT - // checked here: the handshake's activeModel is the host default, not the - // session's current target (session.selectModel never refreshes it), so a - // client-side modality gate refuses sessions the host would accept and - // vice versa. The host preflight on session.prompt is the authority; its - // rejection renders through the composer error strip. + // Model capability is checked only by the host against the session's + // current target; the client owns deployment limits and the one-image UI. const description = this.requireSessions().hostDescription() 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 + if (all.length > 1) throw new Error('每条消息最多添加 1 张图片') for (const file of all) { const mediaType = imageMediaType(file.type) if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) { @@ -351,10 +296,6 @@ export class ConversationService extends Service implements IConversation { if (limits !== undefined && file.size > limits.maxImageBytes) { throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) } - totalBytes += file.size - } - if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { - throw new Error('图片总大小超过单条消息限制') } } diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx index b6746ff2ed..c6f991e18d 100644 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -48,14 +48,14 @@ describe('MessageImage', () => { Promise.resolve('blob:middle')} />, ) - const image = await view.findByAltText('middle') + const image = await view.findByAltText('history.png') const before = view.getByText('before') const after = view.getByText('after') expect(before.compareDocumentPosition(image) & 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 a73b119ac4..a9e1ce525a 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -29,6 +29,25 @@ async function bench() { } describe('ConversationService', () => { + it('keeps the browser draft to one image', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-one') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const [first] = b.root.createDraftImages([new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' })]) + if (first === undefined) throw new Error('draft attachment missing') + expect(() => b.root.createDraftImages( + [new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' })], + [first], + )).toThrow('每条消息最多添加 1 张图片') + expect(created).toHaveBeenCalledOnce() + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + it('routes operations through the public Session binding', async () => { const b = await bench() await b.scoped.send('hello', 'steer') diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index d6b55e5ba3..9abbe88420 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -644,7 +644,6 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { case 'image': return { type: 'image', content: stringifySourceValue(block.attachment), - ...(block.alt !== undefined ? { imageAlt: block.alt } : {}), } case 'other': return sourceBlock(block.block) } diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 129f756aa6..5a4af19657 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -60,7 +60,7 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'conversation', 'sessions']) + expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -72,10 +72,10 @@ describe('tsdown client artifact', () => { name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin injects 'conversation' as an ordering edge and 'sessions' + // The plugin injects 'conversation' as an ordering edge and 'sessionHistory' // for its per-session history callback; this bench supplies both. ctx.provide('conversation', {}) - ctx.provide('sessions', {}) + ctx.provide('sessionHistory', {}) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dc3b4bcf56..81dfe86af5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -160,10 +160,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'attachments', summary: 'Immutable binary attachment service.', methods: [ - { - signature: 'abstract validateImage(input: SaveImageAttachment): void', - jsDoc: '/**\n * Validate one image against the deployment policy without persisting anything.\n * Callers persisting a multi-image batch validate every member first so a\n * malformed member cannot leave earlier members as unreferenced objects.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', - }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', @@ -1859,7 +1855,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageBlock', - declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n alt?: string;\n}', + declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n}', }, { name: 'ImageMediaType', @@ -1919,7 +1915,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelInfo', - declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n outputModalities?: readonly ModelModality[];\n}', + declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n}', }, { name: 'LlmModelReasoningInfo', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 4b5fb30e92..006eade076 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -41,7 +41,6 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 053f5abf24..c9eae83342 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,8 +11,8 @@ import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, } from '@deepseek-ai/dsh-agent' -import { AttachmentError } from '@deepseek-ai/dsh-attachment-local' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' @@ -79,37 +79,23 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - const limits = ctx.attachments.imageLimits - const prepared = content.map(part => part.type === 'text' - ? part - : { part, data: decodeBase64(part.data) }) - const images = prepared.filter((part): part is Extract => 'data' in part) - if (images.length > limits.maxImagesPerMessage) { - throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + if (content.filter(part => part.type === 'image').length > 1) { + throw new AttachmentError('A prompt may contain at most one image.', 'TOO_MANY_IMAGES') } - const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) - if (totalBytes > limits.maxMessageImageBytes) { - throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') - } - // Validate the complete batch before persisting any member: the store has no - // garbage collection, so one malformed image must not leave the batch's - // valid members as published objects no message event will ever reference. - for (const image of images) { - ctx.attachments.validateImage({ - data: image.data, - mediaType: image.part.mediaType, - ...image.part.name === undefined ? {} : { name: image.part.name }, - }) - } - return Promise.all(prepared.map(async (item): Promise => { - if (!('data' in item)) return { type: 'text', text: item.text } + const durable: ContentBlock[] = [] + for (const part of content) { + if (part.type === 'text') { + durable.push({ type: 'text', text: part.text }) + continue + } const attachment = await ctx.attachments.saveImage({ - data: item.data, - mediaType: item.part.mediaType, - ...item.part.name === undefined ? {} : { name: item.part.name }, + data: decodeBase64(part.data), + mediaType: part.mediaType, + ...part.name === undefined ? {} : { name: part.name }, }) - return { type: 'image', attachment, ...item.part.alt === undefined ? {} : { alt: item.part.alt } } - })) + durable.push({ type: 'image', attachment }) + } + return durable } /** @@ -1222,8 +1208,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const target = targetFor(agent).current const provider = target.provider const model = target.model - const activeModel = await ctx.llm.resolveModelInfo(provider, model) - if (activeModel.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) { + const modelInfo = await ctx.llm.resolveModelInfo(provider, model) + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { return err(request, { code: 'attachment-error', message: `Model "${model}" does not support image input.`, @@ -1419,24 +1405,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, host: { - async describe(request) { - const activeModel = (await ctx.llm.listModels(defaults.provider)) - .find(model => model.id === defaults.model) + describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. - return ok(request, { + return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project // must match where an unspecified-cwd session actually lands. cwd: defaults.cwd, provider: defaults.provider, model: defaults.model, - ...activeModel === undefined ? {} : { activeModel }, imageLimits: { ...ctx.attachments.imageLimits, mediaTypes: [...ctx.attachments.imageLimits.mediaTypes], }, attachedSessions: ctx.agents.list().length, - }) + })) }, async pickDirectory(request, signal) { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e9120c9b44..e4ba100f17 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,15 +3,11 @@ */ import { z } from 'zod' -import type { ModelModality } from '@deepseek-ai/dsh-llm' import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import { imageMediaTypeSchema } from './sessions.schema.ts' -/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */ -const modalitySchema = z.string() as unknown as z.ZodType - /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> @@ -21,18 +17,8 @@ export const hostDescribeValueSchema = z.object({ cwd: z.string(), provider: z.string().optional(), model: z.string().optional(), - activeModel: z.object({ - provider: z.string(), - id: z.string(), - name: z.string(), - description: z.string().optional(), - inputModalities: z.array(modalitySchema).optional(), - outputModalities: z.array(modalitySchema).optional(), - }).optional(), imageLimits: z.object({ maxImageBytes: z.number().int().positive(), - maxImagesPerMessage: z.number().int().positive(), - maxMessageImageBytes: z.number().int().positive(), maxImagePixels: z.number().int().positive(), mediaTypes: z.array(imageMediaTypeSchema), }).optional(), diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 1b53d6caa8..46e9c44c88 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,7 +5,6 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types' /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { @@ -49,8 +48,6 @@ export interface HostApi { cwd: string provider?: string model?: string - /** Catalog entry for the active route; absent means its capabilities are unknown. */ - activeModel?: LlmModelInfo /** Resolved authoritative image-upload limits. */ imageLimits?: ImageAttachmentLimits attachedSessions: number diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index d501933615..c7cd2d02e0 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -210,7 +210,7 @@ export const imageMediaTypeSchema = z.union([ /** Prompt wire content is intentionally narrower than merge-extensible durable core content. */ export const promptContentPartSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string() }), - z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional(), alt: z.string().optional() }), + z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }), ]) /** session.prompt request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 5579a89f94..ead239d247 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -162,7 +162,7 @@ export interface SessionSummary { /** Browser-submitted prompt content; image bytes are promoted to durable references by the host. */ export type PromptContentPart = | { type: 'text'; text: string } - | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string; alt?: string } + | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string } /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index db9d17b5b2..d265759da7 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -118,6 +118,26 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { + it('rejects a second prompt image before attachment persistence', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' } + const response = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [image, image], + })) + expect(response.result).toEqual({ + ok: false, + error: { + code: 'attachment-error', + message: 'A prompt may contain at most one image.', + details: { reason: 'TOO_MANY_IMAGES' }, + }, + }) + await ctx.fiber.dispose() + }) + it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek', diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 80a46af8cf..4295fe5b76 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -264,13 +264,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { hostDescription: { version: 'v', cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, attachedSessions: 0, }, })) @@ -281,13 +274,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { value: { version: 'v', cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, attachedSessions: 0, }, }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index da3d9a89e5..ea6a25a6b0 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -249,25 +249,10 @@ describe('host domain schemas', () => { cwd: '/x', provider: 'p', model: 'm', - activeModel: { - provider: 'p', - id: 'm', - name: 'Model', - inputModalities: ['text', 'audio'], - outputModalities: ['text', 'audio'], - }, attachedSessions: 2, }) expect(value.attachedSessions).toBe(2) - expect(value.activeModel?.inputModalities).toEqual(['text', 'audio']) - expect(value.activeModel?.outputModalities).toEqual(['text', 'audio']) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ - version: '1', - cwd: '/x', - activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] }, - attachedSessions: 0, - })).toThrow() }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 6f32d1bbc6..d4da9a96a1 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../attachment/attachment" }, - { - "path": "../../attachment/attachment-local" - }, { "path": "../../llm/llm" }, diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8338becd7f..308da6dcb9 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -74,7 +74,6 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, inputModalities: ['text'], - outputModalities: ['text'], } } @@ -171,7 +170,7 @@ export class DeepSeekAdapter extends LlmAdapter { // capability — "unknown" here would let the host accept and persist // images the serializer must then reject. ...configured === undefined - ? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] } + ? { provider, id: model, name: model, inputModalities: ['text' as const] } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, ...this.options.defaults?.thinking === 'disabled' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index dcf095bc2a..0dc969c17f 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -658,8 +658,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ @@ -762,8 +762,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) }) @@ -784,8 +784,8 @@ describe('plugin registration and config', () => { ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast')) .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 6f8a442e2b..2a4b08e607 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -130,7 +130,6 @@ export class PiAiAdapter extends LlmAdapter { id: model.id, name: model.name, inputModalities: [...model.input], - outputModalities: ['text'], }))) } @@ -155,7 +154,6 @@ export class PiAiAdapter extends LlmAdapter { id: model, name: resolvedModel.name, inputModalities: [...resolvedModel.input], - outputModalities: ['text'], context: { contextWindow: resolvedModel.contextWindow }, reasoning: { efforts: levels.map(level => ({ diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 6ffccfa0e1..1f51486584 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -250,16 +250,10 @@ describe('PiAiAdapter provider routing', () => { class LateAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('not used') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } @@ -440,7 +434,7 @@ describe('provider profile lifecycle', () => { const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) expect(models.every(model => model.provider === 'openai')).toBe(true) const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1') 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 0d1bdfadc5..db5b28bf9f 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -68,16 +68,10 @@ async function harness(image?: StoredImageAttachment): Promise { 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], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('e2e attachment fixture is read-only') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } @@ -228,7 +222,7 @@ for (const profile of providerCases) { 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' }, + { type: 'image', attachment: ref }, ], source: { kind: 'plugin', plugin: 'test' }, })], diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 72747bbe5e..e617d3aa2d 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: ecd6da304a894a687153608f5b468f867ad32e6b -README.zh.md: c43da45bf0c573a78c194d731bff1d9765b1eed6 +README.md: f6bf6ed88cc3aa21c57b5a08249848bdfff2f0c1 +README.zh.md: e466568de5a57b36dcc9fb0bd876ec2216a6c5f3 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ecd6da304a..f6bf6ed88c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -23,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. -Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity or modality metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`. +Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`. Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index c43da45bf0..e466568de5 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -23,7 +23,7 @@ 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 -确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份或模态元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 +确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8fba36e3eb..d024db3c85 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -254,26 +254,9 @@ export class LlmService extends Service { return this.registration(provider).retryPolicy } - /** - * Validate adapter-owned modality arrays and detach them. One rule for the - * advisory catalog and exact resolution: both validate, both copy — two - * readings of the same adapter field with different trust or detachment - * would be an unexplained asymmetry. - * @param provider - provider route (diagnostic context). - * @param code - error code matching the calling surface. - * @param modalities - adapter-owned array, or undefined for unknown. - * @returns a detached copy, or undefined when absent. - */ - private detachedModalities( - provider: string, - code: 'INVALID_CATALOG' | 'INVALID_MODEL_INFO', - modalities: readonly unknown[] | undefined, - ): ModelModality[] | undefined { - if (modalities === undefined) return undefined - if (!Array.isArray(modalities) || modalities.some(entry => typeof entry !== 'string')) { - throw new LlmError(`adapter returned invalid modality metadata for provider "${provider}"`, code) - } - return [...(modalities as readonly ModelModality[])] + /** Detach typed adapter-owned modality metadata. */ + private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined { + return modalities === undefined ? undefined : [...modalities] } /** @@ -300,15 +283,13 @@ export class LlmService extends Service { throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG') } seen.add(model.id) - const inputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.inputModalities) - const outputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.outputModalities) + const inputModalities = this.detachedModalities(model.inputModalities) return { provider: model.provider, id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, ...inputModalities === undefined ? {} : { inputModalities }, - ...outputModalities === undefined ? {} : { outputModalities }, } }) } @@ -360,15 +341,13 @@ export class LlmService extends Service { } // Capability metadata rides through: an explicit modality omission is // negative capability downstream preflights act on (image admission). - const inputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.inputModalities) - const outputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.outputModalities) + const inputModalities = this.detachedModalities(resolved.inputModalities) const info: LlmResolvedModelInfo = { provider, id: model, name: resolved.name, ...resolved.description === undefined ? {} : { description: resolved.description }, ...inputModalities === undefined ? {} : { inputModalities }, - ...outputModalities === undefined ? {} : { outputModalities }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, } const reasoning = resolved.reasoning diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index ba03b51a8a..3d61f5b8a3 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -57,8 +57,6 @@ export interface ImageBlock { type: 'image' /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ attachment: ImageAttachmentRef - /** Optional provider- and UI-facing alternative text, carried from the prompt wire's image part. */ - alt?: string } /** A tool invocation requested by the model. */ @@ -156,8 +154,6 @@ export interface LlmModelInfo { description?: string /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ inputModalities?: readonly ModelModality[] - /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ - outputModalities?: readonly ModelModality[] } /** Provider-owned context capacity for one exact provider/model route. */ diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index e0d173599d..98ccb1befb 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -857,8 +857,6 @@ describe('LlmService', () => { [{ provider: 'route', id: 'model', name: 1 }, 'non-string name'], [{ provider: 'route', id: 'model', name: '' }, 'empty name'], [{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'], - [{ provider: 'route', id: 'model', name: 'Model', inputModalities: 'text' }, 'non-array input modalities'], - [{ provider: 'route', id: 'model', name: 'Model', outputModalities: [1] }, 'non-string output modality'], ] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => { const ctx = new Context() await ctx.plugin(LlmService) @@ -880,7 +878,7 @@ describe('LlmService', () => { override resolveModel(): Promise { return Promise.resolve({ provider: 'route', id: 'model', name: 'Model', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) } }(SCRIPT) @@ -890,7 +888,7 @@ describe('LlmService', () => { // rebuild that drops it silently reads as "modalities unknown". await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toEqual({ provider: 'route', id: 'model', name: 'Model', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) }) @@ -1177,8 +1175,6 @@ describe('LlmService', () => { [{ provider: 'route', id: 'm', name: 1 }, 'non-string name'], [{ provider: 'route', id: 'm', name: '' }, 'empty name'], [{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'], - [{ provider: 'route', id: 'm', name: 'M', inputModalities: 'text' }, 'non-array input modalities'], - [{ provider: 'route', id: 'm', name: 'M', outputModalities: [1] }, 'non-string output modality'], ] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index ec2698393e..533ebd2453 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -25,11 +25,6 @@ const CHARS_PER_TOKEN = 4 /** Per-block structural overhead for JSON framing and type tags. */ const BLOCK_OVERHEAD = 4 -/** Provider-neutral visual estimate: base cost plus one cost unit per 512px tile. */ -const IMAGE_BASE_TOKENS = 85 -const IMAGE_TILE_TOKENS = 170 -const IMAGE_TILE_EDGE = 512 - /** Role-field framing overhead added to every priced message. */ const ROLE_OVERHEAD = 4 @@ -362,12 +357,6 @@ export class TokenMeterService extends Service { case 'reasoning': tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD break - case 'image': { - const tiles = Math.ceil(block.attachment.width / IMAGE_TILE_EDGE) - * Math.ceil(block.attachment.height / IMAGE_TILE_EDGE) - tokens += IMAGE_BASE_TOKENS + tiles * IMAGE_TILE_TOKENS + BLOCK_OVERHEAD - break - } case 'tool-call': tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 4ad886f419..e658fe2981 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, createMessage } 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' @@ -129,16 +128,6 @@ 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', @@ -152,7 +141,7 @@ describe('TokenMeterService pricing', () => { role: 'assistant', content: blocks, source: { kind: 'plugin', plugin: 'test' }, })) - expect(estimated).toBe(813) + expect(estimated).toBeGreaterThan(30) expect(service.estimateMessage(textMessage('abcd'))).toBe(9) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2e7da8af..a57fec0692 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2965,9 +2965,6 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment - '@deepseek-ai/dsh-attachment-local': - specifier: workspace:^ - version: link:../../attachment/attachment-local '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index c843f94a5a..d388f31241 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -90,16 +90,10 @@ const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invari class TestAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('test invariant attachment store does not validate images') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From 8165518561cc44cc053f7e0b815e9da09888c94a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:32:46 +0800 Subject: [PATCH 16/45] fix(web): query the composer by the zh default placeholder in image-display snapshot --- apps/web/tests/image-display.snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 40501dced1..5e5aa1c3dd 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -167,7 +167,7 @@ it('accepts a pasted image into the composer rail and removes it', async () => { // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. - const textarea = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 }) + const textarea = await screen.findByPlaceholderText('给智能体发消息', {}, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { From 3733bd1374f33e6abd8603a4a420c5139da85dd1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 00:30:58 +0800 Subject: [PATCH 17/45] fix(gui): remove temporary multimodal routing state --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 6 +- ...-image-input-and-durable-attachments.zh.md | 6 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 117 ++---------------- apps/cli/src/args.ts | 9 -- apps/cli/src/bin.ts | 2 - apps/cli/src/web.ts | 6 - apps/cli/tests/args.spec.ts | 5 +- apps/cli/tests/llm-route.spec.ts | 76 ------------ packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/client/connection.ts | 5 +- .../client/connection/src/client/fixture.ts | 14 --- .../connection/tests/connection.spec.ts | 3 - .../runtime/src/client/contract/sessions.ts | 7 +- packages/client/runtime/src/client/index.ts | 1 - .../runtime/src/client/sessions/service.ts | 19 +-- .../client/runtime/tests/client-apply.spec.ts | 6 - packages/client/test-runtime/src/sessions.ts | 9 -- .../test-runtime/tests/runtime.spec.tsx | 2 - .../ui-conversation/src/client/apply.ts | 4 +- .../src/client/contract/slots.ts | 2 +- .../ui-conversation/src/client/service.ts | 49 +------- .../src/client/skeleton/InputBar.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 8 +- .../tests/service-orchestration.spec.ts | 25 ++++ packages/host/apiproxy/src/api-proxy.ts | 13 +- packages/host/apiproxy/src/api/host.schema.ts | 20 --- packages/host/apiproxy/src/api/host.ts | 6 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 34 ----- .../host/apiproxy/tests/rpc-schemas.spec.ts | 17 +-- 35 files changed, 69 insertions(+), 426 deletions(-) delete mode 100644 apps/cli/tests/llm-route.spec.ts diff --git a/.agents/notes/implemented/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 index 2428e493fa..49c35959ea 100644 --- a/.agents/notes/implemented/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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 7c14a8bdbb6d8164b2a6eb4432d196936271e4d1 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e9d7f8ebe78aa76285367e83374a6ace3a9eef21 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: dff89f1ab5124eaa96e5d95214911a82be022d16 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: c518edd5c15ebd2a6b776a899049870a4274b5d7 diff --git a/.agents/notes/implemented/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 index 7c14a8bdbb..dff89f1ab5 100644 --- a/.agents/notes/implemented/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 @@ -120,9 +120,9 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida 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. 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 host-default active model and the image limits into `SessionsService`; the composer applies only the deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Decoded-pixel validation and the session's actual route remain authoritative on the host. +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. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. -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. The shipped Web assembly reaches it through `dsh web --provider --model `: the yml pi-ai row already routes openai/anthropic with ambient credentials, and only a catalog provider absent from that row is mounted dynamically; the DeepSeek-only default remains text-only. 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. +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. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. 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. @@ -138,7 +138,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### 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. The client connection carrier independently caps buffered API request bodies, deriving the cap from the aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +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 enforced by the host before persistence. The client connection carrier independently caps buffered API request bodies, deriving the cap from the host attachment service's aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. 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. diff --git a/.agents/notes/implemented/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 index e9d7f8ebe7..c518edd5c1 100644 --- a/.agents/notes/implemented/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 @@ -120,9 +120,9 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径:yml 的 pi-ai row 已用环境凭据路由 openai/anthropic,只有该 row 之外的目录 provider 才会动态挂载;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -138,7 +138,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据图片总量限制加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据宿主附件服务的图片总量限制,加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index b9f1b0d40c..bb5f370009 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 337370f6b3738cef22bc826501633b9aca1f8c62 -README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711 +README.md: 93c36d18abd06bbd7a80c918f520b92489180395 +README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd diff --git a/apps/cli/README.md b/apps/cli/README.md index 337370f6b3..93c36d18ab 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider --model ` selects that route: for the shipped roster (openai/anthropic) the already-mounted yml pi-ai row serves it with provider-native ambient credentials, while a pi-ai catalog provider absent from that row is mounted dynamically; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e1bdad1745..85f4624a59 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider --model ` 选择对应路由:出货清单内的 provider(openai/anthropic)由 yml 中已挂载的 pi-ai row 以提供方原生环境凭据直接服务,只有该 row 之外的 pi-ai catalog provider 才会动态挂载;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index bfea7f3662..a030522c5a 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -61,89 +61,6 @@ export function resolveLanTrust( return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } -/** One provider/model source layer for {@link resolveLlmRoute}, in override order. */ -export interface LlmRouteInput { - /** CLI flag values (highest precedence). */ - cli: { provider?: string | undefined; model?: string | undefined } - /** Profile-json values (parsed JSON — validated here, the config boundary). */ - profile: { provider?: unknown; model?: unknown } - /** The api-gateway yml row's config values (deployment defaults). */ - gateway: { provider?: unknown; model?: unknown } - /** Providers the shipped yml already routes through its static pi-ai row. */ - ymlPiAiProviders: readonly string[] -} - -/** The boot's resolved LLM routing decision. */ -export interface LlmRoute { - /** Effective api-gateway provider. */ - provider: string - /** Pi-ai provider to mount dynamically; undefined when DeepSeek or a yml-routed provider serves the request. */ - dynamicPiAiProvider: string | undefined -} - -/** - * Resolve the boot's LLM route from the layered provider/model sources. - * A non-DeepSeek provider requires a model set at least as explicitly as the - * provider itself (flag/profile) — origin decides, never a comparison against - * any deployment's default model value, so editing the yml default cannot - * silently disarm the guard. Providers the shipped yml pi-ai row already - * routes are NOT mounted again: `LlmService.registerAdapter` rejects - * duplicate routes, so the gateway provider/model patch alone selects them. - * @param input - the layered provider/model sources and the yml pi-ai roster. - * @returns the effective provider and the dynamic pi-ai mount decision. - */ -export function resolveLlmRoute(input: LlmRouteInput): LlmRoute { - const provider = input.cli.provider ?? input.profile.provider ?? input.gateway.provider - if (typeof provider !== 'string' || provider === '') { - throw new Error('dsh: api-gateway provider must be a non-empty string') - } - if (provider !== 'deepseek') { - const providerFromYml = input.cli.provider === undefined && input.profile.provider === undefined - // A yml-set provider trusts its own row pairing; an override must bring - // its model along instead of inheriting the yml default's. - const model = providerFromYml - ? input.gateway.model - : input.cli.model ?? input.profile.model - if (typeof model !== 'string' || model === '') { - throw new Error(`dsh: provider ${provider} requires an explicit model`) - } - } - return { - provider, - dynamicPiAiProvider: provider === 'deepseek' || input.ymlPiAiProviders.includes(provider) - ? undefined - : provider, - } -} - -/** - * Bypass parse of an include yml's top-level entry rows (id → row). Exported - * so tests can pin the shipped tree's real row coupling instead of literals. - * @param configPath - absolute path of the include cordis.yml. - * @returns row map keyed by entry id. - */ -export function parseIncludeYmlRows(configPath: string): Map { - const doc = yaml.load(readFileSync(configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${configPath} is not a top-level entry list`) - const rows = new Map() - for (const row of doc as { id?: string; config?: unknown }[]) { - if (typeof row.id === 'string') rows.set(row.id, row) - } - return rows -} - -/** - * Providers the yml's static pi-ai row routes — the roster {@link resolveLlmRoute} reuses. - * @param rows - parsed include rows. - * @returns provider ids in row order (empty when the row is absent). - */ -export function ymlPiAiProvidersOf(rows: ReadonlyMap): string[] { - const config = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined - return (config?.providers ?? []) - .map(entry => entry.provider) - .filter((value): value is string => typeof value === 'string') -} - /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -198,14 +115,6 @@ export interface AppCLIEntryOptions { port?: number /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ workspaceRoot?: string - /** - * Host default provider override. Providers the shipped yml pi-ai row - * already routes are reused; only a provider absent from that row mounts - * pi-ai dynamically. - */ - provider?: string - /** Host default model override. */ - model?: string /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ trustedHosts?: string[] } @@ -229,7 +138,6 @@ export class AppCLIEntry { lanAddresses: readonly string[] = [] private patches: PatchOptions[] = [] - private piAiProvider: string | undefined constructor(private readonly options: AppCLIEntryOptions) {} @@ -290,17 +198,6 @@ export class AppCLIEntry { if (this.options.host !== undefined) put('webserver', 'host', this.options.host) if (this.options.port !== undefined) put('webserver', 'port', this.options.port) if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) - if (this.options.provider !== undefined) put('api-gateway', 'provider', this.options.provider) - if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model) - - const gatewayConfig = rows.get('api-gateway')?.config as Record | undefined - const route = resolveLlmRoute({ - cli: { provider: this.options.provider, model: this.options.model }, - profile: { provider: profile.provider, model: profile.model }, - gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model }, - ymlPiAiProviders: ymlPiAiProvidersOf(rows), - }) - this.piAiProvider = route.dynamicPiAiProvider // Source 2b: authorities for the /api browser-trust fence (rationale on // resolveLanTrust). @@ -333,12 +230,6 @@ export class AppCLIEntry { ...this.patches.length > 0 ? { patches: this.patches } : {}, }, }) - if (this.piAiProvider !== undefined) { - await ctx.loader.create({ - name: '@deepseek-ai/dsh-llm-pi-ai', - config: { providers: [{ provider: this.piAiProvider }] }, - }) - } if (this.options.dev) { await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) } @@ -373,7 +264,13 @@ export class AppCLIEntry { /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ private parseYmlRows(): Map { - return parseIncludeYmlRows(this.options.configPath) + const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) + const rows = new Map() + for (const row of doc as { id?: string; config?: unknown }[]) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows } /** Profile json under cwd; read-only — never created here, absent = no user config. */ diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b51d2f5fdc..b929dc73f2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -33,7 +33,6 @@ interface HeadlessInvocation { * value fails loud at boot). `port` is `Number`-coerced only because the schema * wants a number, not a string. `dev` mounts the client HMR driver; * `workspaceRoot` is the parent directory for name-created workspaces. - * `provider` and `model` override the host's default model route. */ interface WebInvocation { mode: 'web' @@ -41,8 +40,6 @@ interface WebInvocation { port?: number dev: boolean workspaceRoot?: string - provider?: string - model?: string /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */ trustedHosts?: string[] } @@ -56,8 +53,6 @@ interface WebOptions { port?: string dev?: boolean workspaceRoot?: string - provider?: string - model?: string trustedHost?: string[] } @@ -74,8 +69,6 @@ function resolveWeb(options: WebOptions): WebInvocation { ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, - ...options.provider !== undefined && { provider: options.provider }, - ...options.model !== undefined && { model: options.model }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } } @@ -128,8 +121,6 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') .option('--workspace-root ', 'parent directory for name-created workspaces') - .option('--provider ', 'override the host default provider') - .option('--model ', 'override the host default model') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { // Commander parses the parent (default-surface) options on either side of diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 84b8d066ff..37086cf0d3 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -35,8 +35,6 @@ switch (invocation.mode) { invocation.port, invocation.dev, invocation.workspaceRoot, - invocation.provider, - invocation.model, invocation.trustedHosts, ) break diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index f6dd69de97..69e79ab5d9 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -22,8 +22,6 @@ const LOOPBACK_HOST = '127.0.0.1' * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. - * @param provider - provider override, or `undefined` to keep the profile/config route. - * @param model - model override, or `undefined` to keep the profile/config route. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. */ export async function runWeb( @@ -31,8 +29,6 @@ export async function runWeb( port: number | undefined, dev: boolean, workspaceRoot: string | undefined, - provider: string | undefined, - model: string | undefined, trustedHosts: string[] | undefined, ): Promise { const entry = new AppCLIEntry({ @@ -41,8 +37,6 @@ export async function runWeb( ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, - ...provider !== undefined && { provider }, - ...model !== undefined && { model }, ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index d1d64ad643..29232b94dd 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -35,15 +35,12 @@ describe('parseDshArgs', () => { // at boot); the adapter only coerces the port string to a number. expect(parse([ 'web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w', - '--provider', 'anthropic', '--model', 'claude-opus-4-8', ])).toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', - provider: 'anthropic', - model: 'claude-opus-4-8', }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) @@ -65,6 +62,8 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + expect(exitCode(['web', '--provider', 'anthropic'])).toBe(1) + expect(exitCode(['web', '--model', 'claude-opus-4-8'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/llm-route.spec.ts b/apps/cli/tests/llm-route.spec.ts deleted file mode 100644 index 58edb47973..0000000000 --- a/apps/cli/tests/llm-route.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */ -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parseIncludeYmlRows, resolveLlmRoute, ymlPiAiProvidersOf } from '../src/app-cli-entry.ts' - -/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */ -const SHIPPED = { - gateway: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - ymlPiAiProviders: ['openai', 'anthropic'], -} - -describe('resolveLlmRoute', () => { - it('keeps the DeepSeek default without any dynamic mount', () => { - expect(resolveLlmRoute({ cli: {}, profile: {}, ...SHIPPED })) - .toEqual({ provider: 'deepseek', dynamicPiAiProvider: undefined }) - }) - - it('reuses the yml pi-ai row for providers it already routes (no duplicate adapter)', () => { - expect(resolveLlmRoute({ - cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, ...SHIPPED, - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - }) - - it('mounts pi-ai dynamically only for providers absent from the yml row', () => { - expect(resolveLlmRoute({ - cli: { provider: 'google', model: 'gemini-3-pro' }, profile: {}, ...SHIPPED, - })).toEqual({ provider: 'google', dynamicPiAiProvider: 'google' }) - }) - - it('requires an explicit model wherever the provider override came from, by origin', () => { - // CLI provider with no CLI/profile model: the yml DeepSeek default must not leak in. - expect(() => resolveLlmRoute({ cli: { provider: 'anthropic' }, profile: {}, ...SHIPPED })) - .toThrow(/provider anthropic requires an explicit model/) - // Profile provider paired with a profile model is explicit enough. - expect(resolveLlmRoute({ - cli: {}, profile: { provider: 'openai', model: 'gpt-5' }, ...SHIPPED, - })).toEqual({ provider: 'openai', dynamicPiAiProvider: undefined }) - // Profile provider with only the yml default model: same gap, same refusal. - expect(() => resolveLlmRoute({ cli: {}, profile: { provider: 'openai' }, ...SHIPPED })) - .toThrow(/provider openai requires an explicit model/) - }) - - it('trusts a yml-set non-DeepSeek provider only when its own row carries the model', () => { - expect(resolveLlmRoute({ - cli: {}, profile: {}, - gateway: { provider: 'anthropic', model: 'claude-opus-4-8' }, - ymlPiAiProviders: ['openai', 'anthropic'], - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - expect(() => resolveLlmRoute({ - cli: {}, profile: {}, - gateway: { provider: 'anthropic' }, - ymlPiAiProviders: ['openai', 'anthropic'], - })).toThrow(/provider anthropic requires an explicit model/) - }) - - it('reuses the SHIPPED cordis.yml roster — the coupling that prevents the duplicate-adapter boot failure', () => { - // Parsed from the real file through the production extraction, not a - // literal roster: renaming the `llm-pi-ai` row or its providers field - // must fail here, because composePatches reads exactly these shapes. - const rows = parseIncludeYmlRows(join(import.meta.dirname, '..', 'cordis.yml')) - const roster = ymlPiAiProvidersOf(rows) - expect(roster).toEqual(['openai', 'anthropic']) - const gateway = (rows.get('api-gateway')?.config ?? {}) as { provider?: unknown; model?: unknown } - expect(resolveLlmRoute({ - cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, - gateway, ymlPiAiProviders: roster, - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - }) - - it('fails loud on a missing or empty provider', () => { - expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] })) - .toThrow(/provider must be a non-empty string/) - expect(() => resolveLlmRoute({ cli: { provider: '' }, profile: {}, ...SHIPPED })) - .toThrow(/provider must be a non-empty string/) - }) -}) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 147f36ba75..5b3483dd7a 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 3ceb4f56b672170f7a68532718fdd6e249c7b1bc -README.zh.md: fdb67792f479b6150f3918db825cfaf6efcd12e5 +README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26 +README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 3ceb4f56b6..6f4d8bf15f 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -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. +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 validates `host.describe` 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. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index fdb67792f4..6ae5291aee 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会先通过 `onDescription` 发布经过校验的 `host.describe` 值,再调用 `onConnected`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 21b30e25f9..a3d6d67277 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,4 @@ -import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' +import type { 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,8 +44,6 @@ 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 @@ -144,7 +142,6 @@ export class ConnectionController { } 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/fixture.ts b/packages/client/connection/src/client/fixture.ts index 70c573b587..4d6f5ec861 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1260,20 +1260,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { cwd: '/tmp/fixture', provider: 'fixture', model: 'fx-vision', - activeModel: { - provider: 'fixture', - id: 'fx-vision', - name: 'Fixture Vision', - inputModalities: ['text', 'image'], - outputModalities: ['text', 'image'], - }, - imageLimits: { - maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 10, - maxMessageImageBytes: 20 * 1024 * 1024, - maxImagePixels: 40_000_000, - mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], - }, attachedSessions, }), // Deterministic native pick: the keyless lanes drive the full diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 9bb4965838..26efec982a 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -23,11 +23,9 @@ 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() @@ -36,7 +34,6 @@ 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() } diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index be0888fde4..d26b392072 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -8,7 +8,7 @@ * explicit act of widening what features may do to the sessions domain. */ import type { Context } from 'cordis' -import type { HostDescription, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionBinding, SessionListState, SessionProvideDescriptor, @@ -22,11 +22,6 @@ export interface ISessions { readonly list: ObservableSnapshot /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */ readonly currentProvideInfo: HostObservable - /** - * Read the latest successfully received host capability description. - * @returns host capabilities, or undefined before the first successful handshake. - */ - hostDescription(): HostDescription | undefined /** * Select a session as current. * @param id - session id (must exist in the list; unknown ids fail loud). diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index f2ddb98347..f359eb1eac 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -178,7 +178,6 @@ export function apply(ctx: Context): void { console.error('[web-runtime] history host-frame routing failed:', error) } }, - onDescription: (description) => { sessions.handleDescription(description) }, onConnected: () => { sessions.handleConnected() workspaces.handleConnected() diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 4de2a5d1e5..cfee26f66b 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -17,7 +17,7 @@ */ import type { Context, Fiber } from 'cordis' import type { - HostDescription, IApiClient, RpcError, SessionId, WorkspaceId, + IApiClient, RpcError, SessionId, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, @@ -191,7 +191,6 @@ export class SessionsService implements ISessions { private watched: SessionId | undefined /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ private readonly deferredRemovals = new Set() - private description: HostDescription | undefined /** * @param ctx - client root context (scope fibers mount under it). @@ -231,22 +230,6 @@ export class SessionsService implements ISessions { 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 - } - /** * Register a per-session standard-props provider: every session-scope slot * component receives the contributed members as standard props (`hooks` diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 464daa676a..d5b29f10a9 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -74,12 +74,6 @@ describe('runtime client apply', () => { expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-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 as { hostDescription(): unknown }).hostDescription()).toEqual({ - version: '0', - cwd: '/f', - attachedSessions: 0, - }) bench.sinks?.onConnected?.() }) diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 50988038ca..889b0afe46 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -1,7 +1,6 @@ /** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */ import type { Context } from 'cordis' import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { @@ -197,14 +196,6 @@ export class TestSessions implements ISessions { this.list.subscribe(() => { this.channel.publishCurrent() }) } - /** - * Test runtime has no host handshake unless a fixture explicitly supplies one. - * @returns undefined. - */ - hostDescription(): HostDescription | undefined { - return undefined - } - /** * Add a session from a fixture and (by default) make it current. * @param fixture - identity + snapshot/summary overrides + behavior face. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 6dec592dab..46aa8867e3 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -472,8 +472,6 @@ describe('fixture session face', () => { expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) expect(() => bare.rename()).toThrow(/rename is not stubbed/) - // No host handshake exists in the bench unless a fixture supplies one. - expect(runtime.sessions.hostDescription()).toBeUndefined() await runtime.dispose() }) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9c431c9a6f..d25348c2e4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -191,9 +191,9 @@ export function apply(ctx: Context): void { const shell = inputHub.shell(sessionId) return { keyboard: shell, - addImages: (files, current) => { + addImages: (files) => { try { - const images = conversation.createDraftImages(files, current) + const images = conversation.createDraftImages(files) shell.addImages(images.map(image => image.id)) return null } catch (error: unknown) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index f596ed97f6..11937d2974 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -273,7 +273,7 @@ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */ keyboard: ComposerKeyboard /** Create browser previews and append their ids to the session input state. */ - addImages: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null + addImages: (files: readonly File[]) => string | null /** Release one browser preview and remove its id from the session input state. */ removeImage: (id: string) => void /** Resolve ordered input-state ids to browser-owned draft attachments. */ diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 94afce5a85..d4d162c3d3 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -46,13 +46,9 @@ export interface IConversation { /** * Create runtime-only draft attachments and preview URLs. * @param files - browser-owned image files. - * @param current - images already present in the composer. * @returns ordered descriptors for the input state. */ - createDraftImages( - files: readonly File[], - current?: readonly ComposerAttachment[], - ): readonly ComposerAttachment[] + createDraftImages(files: readonly File[]): readonly ComposerAttachment[] /** * Resolve ordered draft ids to runtime-owned attachments. * @param ids - ordered composer attachment ids. @@ -171,7 +167,6 @@ export class ConversationService extends Service implements IConversation { mode: 'queue' | 'steer', images: readonly File[], ): Promise { - this.validateImages(images, []) const uploaded = await this.serializeImages(images) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode) @@ -181,14 +176,10 @@ export class ConversationService extends Service implements IConversation { /** * Create runtime-only draft attachments and their object URLs. * @param files - browser-owned image files. - * @param current - images already present in the same composer. * @returns ordered attachment descriptors whose ids may enter the input state. */ - createDraftImages( - files: readonly File[], - current: readonly ComposerAttachment[] = [], - ): readonly ComposerAttachment[] { - this.validateImages(files, current) + createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { + for (const file of files) imageMediaType(file.type) return files.map((file) => { const attachment = new BrowserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) @@ -324,40 +315,6 @@ export class ConversationService extends Service implements IConversation { return sessions } - /** Apply host-advertised fast-path checks before any object URL or base64 allocation. */ - private validateImages( - files: readonly File[], - current: readonly ComposerAttachment[], - ): void { - if (files.length === 0 && current.length === 0) return - // Deployment-wide limits only. Model capability is deliberately NOT - // checked here: the handshake's activeModel is the host default, not the - // session's current target (session.selectModel never refreshes it), so a - // client-side modality gate refuses sessions the host would accept and - // vice versa. The host preflight on session.prompt is the authority; its - // rejection renders through the composer error strip. - const description = this.requireSessions().hostDescription() - 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('图片总大小超过单条消息限制') - } - } - /** Convert browser files to the prompt wire's canonical base64 image parts. */ private serializeImages(images: readonly File[]): Promise[0]> { return Promise.all(images.map(async file => ({ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 2abf77110e..e3afcfbec8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -234,7 +234,7 @@ export function InputBar({ .map(item => item.getAsFile()) .filter((file): file is File => file !== null) if (files.length > 0) { - setDropError(addImages(files, attachments)) + setDropError(addImages(files)) } const text = e.clipboardData.getData('text/plain') if (text === '') { @@ -290,7 +290,7 @@ export function InputBar({ if (locked || machineBusy) return const dropped = [...event.dataTransfer.files] if (dropped.length === 0) return - setDropError(addImages(dropped, attachments)) + setDropError(addImages(dropped)) } const closePreview = useCallback(() => { setPreview(null) }, []) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index b371298e1e..5a5ea3dd2b 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -49,7 +49,7 @@ interface BenchOptions { leftItems?: React.ReactNode rightItems?: React.ReactNode attachments?: readonly ComposerAttachment[] - addImages?: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null + addImages?: (files: readonly File[]) => string | null } /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ @@ -464,7 +464,7 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addImages).toHaveBeenCalledWith([image], []) + expect(addImages).toHaveBeenCalledWith([image]) expect(shell.snapshot.draft).toBe('同时粘贴的文字') const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' }) @@ -494,7 +494,7 @@ describe('image draft rail', () => { expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(card, { dataTransfer })).toBe(false) expect(view.queryByRole('status')).toBeNull() - expect(addImages).toHaveBeenCalledWith([image], []) + expect(addImages).toHaveBeenCalledWith([image]) }) it('ignores unsupported dropped files and refuses drops while locked', () => { @@ -507,7 +507,7 @@ describe('image draft rail', () => { dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' }, }) expect(view.getByText(/不支持的图片格式/)).toBeTruthy() - expect(addImages).toHaveBeenCalledWith([documentFile], []) + expect(addImages).toHaveBeenCalledWith([documentFile]) const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' }) const locked = bench({ disabled: true, addImages }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index a73b119ac4..5f8d77e335 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -69,6 +69,31 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('checks media type before preview allocation and leaves deployment limits to the host', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockImplementation(file => `blob:${(file as File).name}`) + try { + const files = Array.from( + { length: 11 }, + (_, index) => new File([Uint8Array.of(index)], `${index}.png`, { type: 'image/png' }), + ) + expect(b.root.createDraftImages(files)).toHaveLength(11) + expect(created).toHaveBeenCalledTimes(11) + + const beforeRejectedBatch = created.mock.calls.length + expect(() => { + b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), + ]) + }).toThrow('不支持的图片格式:image/svg+xml') + expect(created).toHaveBeenCalledTimes(beforeRejectedBatch) + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('releases in-flight send images when the scope dies before the failure lands', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1') diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 053f5abf24..1f56a3b5ca 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1419,24 +1419,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, host: { - async describe(request) { - const activeModel = (await ctx.llm.listModels(defaults.provider)) - .find(model => model.id === defaults.model) + describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. - return ok(request, { + return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project // must match where an unspecified-cwd session actually lands. cwd: defaults.cwd, provider: defaults.provider, model: defaults.model, - ...activeModel === undefined ? {} : { activeModel }, - imageLimits: { - ...ctx.attachments.imageLimits, - mediaTypes: [...ctx.attachments.imageLimits.mediaTypes], - }, attachedSessions: ctx.agents.list().length, - }) + })) }, async pickDirectory(request, signal) { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e9120c9b44..15084b5d07 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,14 +3,9 @@ */ import { z } from 'zod' -import type { ModelModality } from '@deepseek-ai/dsh-llm' import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import { imageMediaTypeSchema } from './sessions.schema.ts' - -/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */ -const modalitySchema = z.string() as unknown as z.ZodType /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> @@ -21,21 +16,6 @@ export const hostDescribeValueSchema = z.object({ cwd: z.string(), provider: z.string().optional(), model: z.string().optional(), - activeModel: z.object({ - provider: z.string(), - id: z.string(), - name: z.string(), - description: z.string().optional(), - inputModalities: z.array(modalitySchema).optional(), - outputModalities: z.array(modalitySchema).optional(), - }).optional(), - imageLimits: z.object({ - maxImageBytes: z.number().int().positive(), - maxImagesPerMessage: z.number().int().positive(), - maxMessageImageBytes: z.number().int().positive(), - maxImagePixels: z.number().int().positive(), - mediaTypes: z.array(imageMediaTypeSchema), - }).optional(), attachedSessions: z.number().int().nonnegative(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 1b53d6caa8..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -4,8 +4,6 @@ */ import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types' /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { @@ -49,10 +47,6 @@ export interface HostApi { cwd: string provider?: string model?: string - /** Catalog entry for the active route; absent means its capabilities are unknown. */ - activeModel?: LlmModelInfo - /** Resolved authoritative image-upload limits. */ - imageLimits?: ImageAttachmentLimits attachedSessions: number }>> diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 80a46af8cf..244cb8bb8e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -259,40 +259,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) - it('round-trips a declaration-merged model modality through host.describe', async () => { - const c = client(fakeApi({ - hostDescription: { - version: 'v', - cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, - attachedSessions: 0, - }, - })) - - const response = await c.host.describe({}) - expect(response.result).toEqual({ - ok: true, - value: { - version: 'v', - cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, - attachedSessions: 0, - }, - }) - }) - it('round-trips the native picker without the default unary timeout', async () => { const api = fakeApi() api.host.pickDirectory = async (request) => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index da3d9a89e5..18b2372787 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -242,32 +242,17 @@ describe('sessions domain schemas', () => { }) describe('host domain schemas', () => { - it('validates describe request/value and preserves merge-extensible modalities', () => { + it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', - activeModel: { - provider: 'p', - id: 'm', - name: 'Model', - inputModalities: ['text', 'audio'], - outputModalities: ['text', 'audio'], - }, attachedSessions: 2, }) expect(value.attachedSessions).toBe(2) - expect(value.activeModel?.inputModalities).toEqual(['text', 'audio']) - expect(value.activeModel?.outputModalities).toEqual(['text', 'audio']) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ - version: '1', - cwd: '/x', - activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] }, - attachedSessions: 0, - })).toThrow() }) it('validates the browse listing/creation payloads', () => { From 6312e43a11d029081be2539e02f1f3465d41b5a7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:58:35 +0800 Subject: [PATCH 18/45] fix: preserve multi-image prompt batches --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 4 +- ...-image-input-and-durable-attachments.zh.md | 4 +- .../2026-07-29-narrow-web-image-input-v1.md | 35 --------- ...2026-07-29-narrow-web-image-input-v1.zh.md | 35 --------- ...-29-simplify-web-image-input-v1.i18n.yaml} | 6 +- .../2026-07-29-simplify-web-image-input-v1.md | 37 ++++++++++ ...26-07-29-simplify-web-image-input-v1.zh.md | 37 ++++++++++ apps/web/tests/image-display.snapshot.ts | 17 ++--- docs/config-catalog.md | 6 +- docs/cordis-catalog/services.md | 7 ++ .../core-data-structures/attachment.i18n.yaml | 4 +- docs/core-data-structures/attachment.md | 4 +- docs/core-data-structures/attachment.zh.md | 4 +- .../attachment/attachment-local/src/index.ts | 20 +++++- .../attachment/attachment-local/src/store.ts | 9 +++ .../attachment-local/tests/index.spec.ts | 22 ++++++ .../attachment-local/tests/store.spec.ts | 2 + .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 7 ++ packages/attachment/attachment/src/types.ts | 2 + .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/src/index.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 2 +- .../ui-conversation/src/client/service.ts | 11 ++- .../tests/service-orchestration.spec.ts | 60 +++++++++++----- .../cordis/tool-cordis/src/api-catalog.ts | 4 ++ packages/host/apiproxy/src/api-proxy.ts | 39 ++++++---- packages/host/apiproxy/src/api/host.schema.ts | 2 + .../apiproxy/tests/api-proxy-models.spec.ts | 71 +++++++++++++++---- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 6 ++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 6 ++ scripts/test-invariants.ts | 6 ++ 35 files changed, 336 insertions(+), 149 deletions(-) delete mode 100644 .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md rename .agents/notes/implemented/simplification/{2026-07-29-narrow-web-image-input-v1.i18n.yaml => 2026-07-29-simplify-web-image-input-v1.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md create mode 100644 .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md diff --git a/.agents/notes/implemented/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 index 4a36c145fd..fcaca7a4c7 100644 --- a/.agents/notes/implemented/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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: ab1bd449f065346bc327984eacc85f71d4fd5a28 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: d51f9135352ba09b2cd2a083ade6b62f4a276216 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 134e3cf6137abec41398d7f4ded9b838e197a5fc +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: f0c16d10c57a499b605fffead9f35eb2f6149004 diff --git a/.agents/notes/implemented/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 index ab1bd449f0..134e3cf613 100644 --- a/.agents/notes/implemented/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 @@ -112,7 +112,7 @@ type PromptInputPart = } ``` -Base64 crosses JSON-RPC once and is discarded after persistence. Version one accepts at most one image per prompt. The host validates canonical base64, individual bytes, magic-byte MIME, intrinsic dimensions, and intrinsic pixel count while durably saving that image. Only after the save succeeds does it call the agent with normalized text and a durable image block. A failure appends no user event and exposes no attachment path or raw bytes. +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: it validates the complete batch through the seam's storage-free `validateImage` before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. 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 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. @@ -138,7 +138,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts at most one PNG, JPEG, WebP, or GIF per prompt. SVG and remote URLs are excluded. Default deployment limits are 5 MiB and 40 million intrinsic pixels per image; they are validated backend configuration and projected to the client for fast-path guidance, while host validation remains authoritative. The client connection carrier independently caps buffered API request bodies from the single-image byte limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts up to 10 ordered PNG, JPEG, WebP, or GIF images per prompt by default. SVG and remote URLs are excluded. Default deployment limits are 5 MiB and 40 million intrinsic pixels per image plus 20 MiB aggregate image bytes per prompt; all are validated backend configuration and projected to the client for fast-path guidance, while host validation remains authoritative. The client connection carrier independently caps buffered API request bodies from the aggregate image-byte limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. 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. diff --git a/.agents/notes/implemented/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 index d51f913535..f0c16d10c5 100644 --- a/.agents/notes/implemented/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 @@ -112,7 +112,7 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。第一版每条提示词最多接受一张图片。宿主在持久保存该图片时,会校验规范 base64、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和固有像素数。只有保存成功后,宿主才会用规范化文本和一个持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数:它会在保存任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用对象。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 @@ -138,7 +138,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 限制与信任边界 -第一版每条提示词最多接受一张 PNG、JPEG、WebP 或 GIF 图片。不接受 SVG 和远程 URL。默认部署限制为每张图片 5 MiB 和 4,000 万个固有像素;这些限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引,而宿主校验仍是权威结果。客户端连接载体根据单张图片字节上限加上 base64 和请求封装的膨胀量,独立限制 API 请求体的缓冲大小;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版默认每条提示词最多接受 10 张有序的 PNG、JPEG、WebP 或 GIF 图片。不接受 SVG 和远程 URL。默认部署限制为每张图片 5 MiB 和 4,000 万个固有像素,外加每条提示词 20 MiB 图片总字节数;这些限制都属于经过校验的后端配置,并会投影给客户端以提供快速路径指引,而宿主校验仍是权威结果。客户端连接载体根据图片总字节数上限加上 base64 和请求封装的膨胀量,独立限制 API 请求体的缓冲大小;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md deleted file mode 100644 index 5a42c7acea..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Narrow Web image input version one - -Status: implemented - -English | [中文](2026-07-29-narrow-web-image-input-v1.zh.md) - -## Problem - -The first durable Web image-input slice also introduced speculative surfaces for multiple-image transactions, arbitrary CLI provider mounting, output-modality discovery, alternative text, and provider-neutral visual token pricing. None was required to paste or drop one image, persist it before its message event, replay it through a visual adapter, or render it from authorized history. Keeping those surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain. - -## Decision - -Version one accepts at most one image in a submitted prompt. The client gives immediate feedback, the host enforces the invariant, and the attachment seam validates and commits that one object. Deployment configuration retains per-image byte and pixel limits; request buffering derives from the per-image byte limit. There is no batch prevalidation, aggregate byte limit, image-count setting, transaction layer, or rollback protocol. - -The CLI only patches the selected provider and model. The boot composition must already register the route, as it does for the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not inspect the yml provider roster or dynamically mount an adapter. - -Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes. - -The attachment seam exposes its limits plus `saveImage` and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries. - -## Alternatives considered - -**Keep multiple images and add transaction or rollback machinery.** Without garbage collection, a partially persisted batch needs an ownership or reclamation design. One image satisfies the initial user path without creating that lifecycle. - -**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, batch validation, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract. - -**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source. - -**Mount any CLI-selected provider dynamically.** Configuration already owns plugin composition and credentials. Making selection also mutate composition duplicates that responsibility and requires parsing the config tree outside the loader. - -## Consequences - -The initial feature has fewer public fields, lifecycle operations, configuration knobs, and route-assembly branches. A prompt needing multiple images is rejected and must wait for an explicit multi-image persistence design. A provider absent from the composition cannot be selected solely with CLI flags. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact. - -Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape. diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md deleted file mode 100644 index 0459074d01..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 收窄 Web 图片输入第一版 - -Status: implemented - -[English](2026-07-29-narrow-web-image-input-v1.md) | 中文 - -## 问题 - -首个持久化 Web 图片输入切片还引入了多张图片事务、由 CLI(命令行界面)挂载任意提供方、输出模态发现、替代文本以及与提供方无关的视觉 token 定价等推测性表面。要实现粘贴或拖放一张图片、在其消息事件之前将其持久化、通过视觉适配器回放它,或从经授权的历史记录中渲染它,并不需要上述任何表面。保留这些表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。 - -## 决策 - -第一版在每条提交的提示词中最多接受一张图片。客户端立即提供反馈,宿主强制执行该不变量,附件服务边界则校验并提交这一个对象。部署配置保留单张图片的字节和像素限制;请求缓冲上限由单张图片的字节限制派生。不设批量预校验、总字节限制、图片数量设置、事务层或回滚协议。 - -CLI 只修改选定的提供方和模型。启动组合必须已经注册该路由,随项目交付的 DeepSeek、OpenAI 和 Anthropic 路由正是如此;CLI 不会检查 yml 提供方清单,也不会动态挂载适配器。 - -确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。 - -附件服务边界公开其限制以及 `saveImage` 和 `readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。 - -## 曾考虑的替代方案 - -**保留多张图片,并添加事务或回滚机制。** 没有垃圾回收时,部分持久化的批次需要一套所有权或回收设计。一张图片即可满足首个用户路径,而不引入该生命周期。 - -**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本、批量校验和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。 - -**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。 - -**动态挂载 CLI 选择的任意提供方。** 配置已经负责插件组合和凭据。让选择操作同时改变组合会造成职责重复,并要求在加载器之外解析配置树。 - -## 后果 - -初始功能具有更少的公开字段、生命周期操作、配置项和路由组装分支。需要多张图片的提示词会被拒绝,必须等待明确的多张图片持久化设计。未加入组合的提供方无法仅凭 CLI 标志选择。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。 - -重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml similarity index 56% rename from .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml rename to .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml index 6255482e78..915431b238 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md -2026-07-29-narrow-web-image-input-v1.md: 5a42c7acea778f8d1ff00e30bde0f849de12eb6b -2026-07-29-narrow-web-image-input-v1.zh.md: 0459074d0145c0d43008d32196ac6577eca958f3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md +2026-07-29-simplify-web-image-input-v1.md: 1262f1f42ef0b338c0aa7d29e479d4a599cc17a4 +2026-07-29-simplify-web-image-input-v1.zh.md: 60d9c47be1ebd9f259b44aaa6658e3582dfef2d4 diff --git a/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md new file mode 100644 index 0000000000..1262f1f42e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md @@ -0,0 +1,37 @@ +# Agent Note: Simplify Web image input version one + +Status: implemented + +English | [中文](2026-07-29-simplify-web-image-input-v1.zh.md) + +## Problem + +The first durable Web image-input slice introduced required ordered multi-image intake alongside speculative surfaces for arbitrary CLI provider mounting, output-modality discovery, alternative text, provider-neutral visual token pricing, and browser lifecycle APIs with no cross-package consumer. Keeping the speculative surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain. + +## Decision + +Version one accepts ordered image batches bounded by configurable per-message count and aggregate-byte limits plus per-image byte and pixel limits. The client gives immediate feedback, while the host authoritatively decodes the complete batch, checks its bounds, validates every image without storage writes, and only then saves every image while preserving submitted order in the resulting durable blocks. Request buffering derives from the aggregate image-byte limit. This preserves validation atomicity without adding a batch transaction or rollback protocol. + +The CLI only patches the selected provider and model. The boot composition must already register the route, as it does for the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not inspect the yml provider roster or dynamically mount an adapter. + +Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes. + +The attachment seam exposes its limits plus storage-free `validateImage`, `saveImage`, and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries. + +## Alternatives considered + +**Accept only one image.** Comparing or combining several images is a current product requirement. Count and aggregate-byte bounds keep that path finite without reducing it to a single image. + +**Add a storage transaction or rollback protocol.** Storage-free validation prevents malformed later members from leaving earlier valid members unreferenced. Stronger all-or-nothing storage across independent content-addressed objects would require ownership or reclamation semantics that the current product path does not need. + +**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract. + +**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source. + +**Mount any CLI-selected provider dynamically.** Configuration already owns plugin composition and credentials. Making selection also mutate composition duplicates that responsibility and requires parsing the config tree outside the loader. + +## Consequences + +The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, and route-assembly branches. A provider absent from the composition cannot be selected solely with CLI flags. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact. + +Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape. diff --git a/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md new file mode 100644 index 0000000000..60d9c47be1 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 简化 Web 图片输入第一版 + +Status: implemented + +[English](2026-07-29-simplify-web-image-input-v1.md) | 中文 + +## 问题 + +首个持久化 Web 图片输入切片在引入按顺序接收多张图片的必需能力时,也引入了由 CLI(命令行界面)挂载任意提供方、输出模态发现、替代文本、提供方无关的视觉 token 定价,以及没有跨包(package)消费方的浏览器生命周期 API 等推测性表面。保留这些推测性表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。 + +## 决策 + +第一版接受有序图片批次,并以可配置的每条消息图片数量与图片总字节数上限,以及单张图片字节数与像素上限约束批次。客户端立即提供反馈,而宿主会以权威方式解码完整批次、检查各项上限、在不写入存储的情况下校验每张图片,之后才保存每张图片,并在生成的持久图片块中保留提交顺序。请求缓冲上限由图片总字节数上限派生。这样无需添加批次事务或回滚协议,就能保持校验的原子性。 + +CLI 只修改选定的提供方和模型。启动组合必须已经注册该路由,随项目交付的 DeepSeek、OpenAI 和 Anthropic 路由正是如此;CLI 不会检查 yml 提供方清单,也不会动态挂载适配器。 + +确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。 + +附件服务边界公开其限制、不触碰存储的 `validateImage`,以及 `saveImage` 和 `readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。 + +## 曾考虑的替代方案 + +**只接受一张图片。** 比较或组合多张图片是当前产品要求。图片数量和总字节数上限使这条路径保持有界,而无需将其缩减为单张图片。 + +**添加存储事务或回滚协议。** 不触碰存储的校验能防止后面的畸形成员使前面有效的成员成为无引用对象。若要在独立的内容寻址对象之间实现更强的全有或全无存储保证,就需要当前产品路径并不需要的所有权或回收语义。 + +**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。 + +**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。 + +**动态挂载 CLI 选择的任意提供方。** 配置已经负责插件组合和凭据。让选择操作同时改变组合会造成职责重复,并要求在加载器之外解析配置树。 + +## 后果 + +该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作和路由组装分支。未加入组合的提供方无法仅凭 CLI 标志选择。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。 + +重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 1c35a0b818..ed74b317cc 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -5,8 +5,7 @@ // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized // sessions.attachment route, the double-click ImageLightbox, and the composer -// intake chain (paste → thumbnail rail → one-image limit → image-only send -// enablement → remove). +// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove). import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -162,7 +161,7 @@ it('renders the history image pair through the authorized attachment route and o }) }) -it('accepts a pasted image into the composer rail and removes it', async () => { +it('accepts pasted images into the composer rail in order and removes them', async () => { boot('?fixture=empty') await screen.findByPlaceholderText('Choose a workspace to start', {}, { timeout: 10_000 }) @@ -211,12 +210,14 @@ it('accepts a pasted image into the composer rail and removes it', async () => { getData: () => '', }, }) - expect(await screen.findByText('每条消息最多添加 1 张图片')).toBeTruthy() - expect(rail.querySelectorAll('img')).toHaveLength(1) + await waitFor(() => { + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))) + .toEqual(['pasted.png', 'second.png']) + }) - const remove = rail.querySelector('button[aria-label^="移除图片"]') - if (remove === null) throw new Error('remove button missing') - fireEvent.click(remove) + const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')] + if (remove.length !== 2) throw new Error('remove buttons missing') + for (const button of remove) fireEvent.click(button) await waitFor(() => { expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull() }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ff036de79f..f04fc46ceb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -196,12 +196,16 @@ export interface Config { dshHome?: string /** Maximum encoded bytes accepted for one image. */ maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:20`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 99c25ac695..2f75ba7aa6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -260,6 +260,13 @@ Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-ap Immutable binary attachment service. Implementations validate bytes before publishing a reference. ```ts cordis-catalog +/** + * Validate one image without persisting it. + * Batch callers validate every member before saving any member. + * @param input - encoded bytes, declared media type, and optional display name. + */ +abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/docs/core-data-structures/attachment.i18n.yaml b/docs/core-data-structures/attachment.i18n.yaml index a67bd99e1b..ccbe788946 100644 --- a/docs/core-data-structures/attachment.i18n.yaml +++ b/docs/core-data-structures/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/attachment.md -attachment.md: a06142b718dfcdc24ab037987a771869d68b31fa -attachment.zh.md: d41a3bf9cd49c7abb684c2476a1ade6e2d373732 +attachment.md: 566f0198ce6e797e270b63e3cfd255a0025d7135 +attachment.zh.md: 95d755407b3b2ea7ab5b109f2e78edea16ccdd9c diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md index a06142b718..566f0198ce 100644 --- a/docs/core-data-structures/attachment.md +++ b/docs/core-data-structures/attachment.md @@ -39,6 +39,8 @@ interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ interface ImageAttachmentLimits { maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } @@ -67,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so admission rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/attachment.zh.md b/docs/core-data-structures/attachment.zh.md index d41a3bf9cd..95d755407b 100644 --- a/docs/core-data-structures/attachment.zh.md +++ b/docs/core-data-structures/attachment.zh.md @@ -39,6 +39,8 @@ interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ interface ImageAttachmentLimits { maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } @@ -67,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此准入拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 65bfd8a34b..c015887bfc 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -6,13 +6,17 @@ import z from 'schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { readImageFile, saveImageFile } from './store.ts' +import { readImageFile, saveImageFile, validateImageFile } from './store.ts' export { detectImage } from './image.ts' -export { readImageFile, saveImageFile } from './store.ts' +export { readImageFile, saveImageFile, validateImageFile } from './store.ts' /** Default maximum encoded bytes for one image. */ export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Default maximum images in one prompt. */ +export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10 +/** Default maximum aggregate image bytes in one prompt. */ +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024 /** Default maximum intrinsic pixels for one image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 @@ -22,6 +26,10 @@ export interface Config { dshHome?: string /** Maximum encoded bytes accepted for one image. */ maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number } @@ -31,6 +39,8 @@ export class LocalAttachmentStore extends AttachmentStore { static Config: z = z.object({ dshHome: z.string(), maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES), + maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), + maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), }) @@ -43,11 +53,17 @@ export class LocalAttachmentStore extends AttachmentStore { this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) this.imageLimits = Object.freeze({ maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) } + validateImage(input: SaveImageAttachment): void { + validateImageFile(input, this.imageLimits) + } + async saveImage(input: SaveImageAttachment): Promise { return saveImageFile(this.root, input, this.imageLimits) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 18694b1642..b8a5c6aa3c 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -52,6 +52,15 @@ function validateAdmission(metadata: Omit { @@ -13,6 +16,8 @@ describe('local attachment service', () => { 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'], }) @@ -32,4 +37,21 @@ describe('local attachment service', () => { await rm(dshHome, { recursive: true, force: true }) } }) + + it('validates without persisting: a rejected image leaves no storage root behind', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) }) + .toThrow(/Unsupported or malformed image data/) + const valid = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow() + expect(existsSync(service.root)).toBe(false) + } 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 43ba76fda4..9287c702ad 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -28,6 +28,8 @@ const PNG = Uint8Array.from(Buffer.from( const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2048, maxImagePixels: 16, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 3e5a4a2e32..c75c93eb1a 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: b25ef7bcf89b3b85600ed02ab12eb0cdd1117244 -README.zh.md: f45933c2a011978b31a306a1de80d7f031838c8e +README.md: 4f450316294e554396adb9a8454051a08d9befd3 +README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index b25ef7bcf8..4f45031629 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `saveImage` validates and commits one image at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. ## Model Experience diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index f45933c2a0..fe51b0003c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`saveImage` 在提交消息或提交结构化提供方输出时校验并提交一张图片,且发生在发布任何模型可见的会话事件之前。`readImage` 根据已记录的元数据校验内容寻址对象。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。 ## 模型体验 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 000856be78..74e280a70f 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -33,6 +33,13 @@ export abstract class AttachmentStore extends Service { /** Deployment-resolved image policy used by authoritative and fast-path validation. */ abstract readonly imageLimits: ImageAttachmentLimits + /** + * Validate one image without persisting it. + * Batch callers validate every member before saving any member. + * @param input - encoded bytes, declared media type, and optional display name. + */ + abstract validateImage(input: SaveImageAttachment): void + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 754a9db072..88b1dceb52 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -36,6 +36,8 @@ export interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ export interface ImageAttachmentLimits { maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 20abb7f53f..0e5f59a213 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1262,6 +1262,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { model: 'fx-vision', imageLimits: { maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 10, + maxMessageImageBytes: 20 * 1024 * 1024, maxImagePixels: 40_000_000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }, diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 05c4398763..a26b98f779 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -52,7 +52,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) const maxRequestBodyBytes = Math.ceil( - ctx.attachments.imageLimits.maxImageBytes * 4 / 3, + ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3, ) + REQUEST_ENVELOPE_HEADROOM_BYTES const route: WebRoute = { kind: 'prefix', diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 7e2277305e..3e64ef8907 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -23,7 +23,7 @@ function fakeHttpServer(routes: WebRoute[]): Pick attachment.file), ...files] - if (all.length > 1) throw new Error('每条消息最多添加 1 张图片') + 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)) { @@ -296,6 +299,10 @@ export class ConversationService extends Service implements IConversation { if (limits !== undefined && file.size > limits.maxImageBytes) { throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) } + totalBytes += file.size + } + if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { + throw new Error('图片总大小超过单条消息限制') } } diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index a9e1ce525a..9a90bff422 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -29,25 +29,6 @@ async function bench() { } describe('ConversationService', () => { - it('keeps the browser draft to one image', async () => { - const b = await bench() - const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-one') - const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) - try { - const [first] = b.root.createDraftImages([new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' })]) - if (first === undefined) throw new Error('draft attachment missing') - expect(() => b.root.createDraftImages( - [new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' })], - [first], - )).toThrow('每条消息最多添加 1 张图片') - expect(created).toHaveBeenCalledOnce() - } finally { - created.mockRestore() - revoked.mockRestore() - } - await b.runtime.dispose() - }) - it('routes operations through the public Session binding', async () => { const b = await bench() await b.scoped.send('hello', 'steer') @@ -68,6 +49,47 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('accepts ordered batches and preflights their advertised count and aggregate limits', async () => { + const b = await bench() + const described = vi.spyOn(b.runtime.sessions, 'hostDescription').mockReturnValue({ + version: 'test', + cwd: '/tmp', + imageLimits: { + maxImageBytes: 3, + maxImagesPerMessage: 2, + maxMessageImageBytes: 3, + maxImagePixels: 4, + mediaTypes: ['image/png'], + }, + attachedSessions: 1, + }) + const created = vi.spyOn(URL, 'createObjectURL') + .mockReturnValueOnce('blob:first') + .mockReturnValueOnce('blob:second') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const attachments = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }), + ]) + expect(attachments.map(attachment => attachment.file.name)).toEqual(['first.png', 'second.png']) + expect(() => b.root.createDraftImages([ + new File([Uint8Array.of(3)], 'third.png', { type: 'image/png' }), + ], attachments)).toThrow('每条消息最多添加 2 张图片') + const first = attachments[0] + if (first === undefined) throw new Error('first draft attachment missing') + expect(() => b.root.createDraftImages([ + new File([Uint8Array.of(3, 4, 5)], 'large.png', { type: 'image/png' }), + ], [first])).toThrow('图片总大小超过单条消息限制') + expect(created).toHaveBeenCalledTimes(2) + } finally { + await b.runtime.dispose() + described.mockRestore() + created.mockRestore() + revoked.mockRestore() + } + }) + it('releases draft images when the session scope is disposed', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bb61304db7..d5bb3cea22 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -160,6 +160,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'attachments', summary: 'Immutable binary attachment service.', methods: [ + { + signature: 'abstract validateImage(input: SaveImageAttachment): void', + jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c9eae83342..3f65ca3a4b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -79,23 +79,34 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - if (content.filter(part => part.type === 'image').length > 1) { - throw new AttachmentError('A prompt may contain at most one image.', 'TOO_MANY_IMAGES') + const limits = ctx.attachments.imageLimits + if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) { + throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') } - const durable: ContentBlock[] = [] - for (const part of content) { - if (part.type === 'text') { - durable.push({ type: 'text', text: part.text }) - continue - } - const attachment = await ctx.attachments.saveImage({ - data: decodeBase64(part.data), - mediaType: part.mediaType, - ...part.name === undefined ? {} : { name: part.name }, + const prepared = content.map(part => part.type === 'text' + ? part + : { part, data: decodeBase64(part.data) }) + const images = prepared.filter((part): part is Extract => 'data' in part) + const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) + if (totalBytes > limits.maxMessageImageBytes) { + throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } + for (const image of images) { + ctx.attachments.validateImage({ + data: image.data, + mediaType: image.part.mediaType, + ...image.part.name === undefined ? {} : { name: image.part.name }, }) - durable.push({ type: 'image', attachment }) } - return durable + return Promise.all(prepared.map(async (item): Promise => { + if (!('data' in item)) return { type: 'text', text: item.text } + const attachment = await ctx.attachments.saveImage({ + data: item.data, + mediaType: item.part.mediaType, + ...item.part.name === undefined ? {} : { name: item.part.name }, + }) + return { type: 'image', attachment } + })) } /** diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e4ba100f17..6d0b9b9268 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -19,6 +19,8 @@ export const hostDescribeValueSchema = z.object({ model: z.string().optional(), imageLimits: z.object({ maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), maxImagePixels: z.number().int().positive(), mediaTypes: z.array(imageMediaTypeSchema), }).optional(), diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index d265759da7..10b3e96e47 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -4,14 +4,14 @@ * models, and the prompt-assembly boundary for a running selection change. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, - LlmResolvedModelInfo, StreamChunk, + LlmResolvedModelInfo, StreamChunk, UserMessage, } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -118,23 +118,66 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { - it('rejects a second prompt image before attachment persistence', async () => { - const { ctx, sessionId } = await harness() + it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => { + const { ctx, agent, sessionId } = await harness() + const validateImage = vi.fn((_input: { data: Uint8Array }): void => {}) + const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => { + return Promise.resolve({ + attachmentId: `att-${String(input.data[0])}`, + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...(input.name === undefined ? {} : { name: input.name }), + }) + }) + const followup = vi.fn((_message: UserMessage): void => {}) + Object.assign(agent, { followup }) + ctx.provide('attachments', { + imageLimits: { maxImageBytes: 4, maxImagesPerMessage: 2, maxMessageImageBytes: 4, maxImagePixels: 4, mediaTypes: ['image/png'] }, + validateImage, + saveImage, + } as never) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' } - const response = await api.sessions.prompt(request({ + const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' } + const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' } + const accepted = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, - content: [image, image], + content: [first, { type: 'text' as const, text: 'compare' }, second], })) - expect(response.result).toEqual({ - ok: false, - error: { - code: 'attachment-error', - message: 'A prompt may contain at most one image.', - details: { reason: 'TOO_MANY_IMAGES' }, - }, + expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) + expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect(followup.mock.calls[0]?.[0].content).toEqual([ + { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png' } }, + { type: 'text', text: 'compare' }, + { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'second.png' } }, + ]) + + const tooMany = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [first, second, first], + })) + expect(tooMany.result).toMatchObject({ + ok: false, error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, }) + const tooLarge = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [ + { ...first, data: 'AQID' }, + { ...second, data: 'BAUG' }, + ], + })) + expect(tooLarge.result).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'IMAGES_TOO_LARGE' } }, + }) + expect(validateImage).toHaveBeenCalledTimes(2) + expect(saveImage).toHaveBeenCalledTimes(2) + expect(followup).toHaveBeenCalledTimes(1) await ctx.fiber.dispose() }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 1f51486584..272135bef9 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -250,10 +250,16 @@ describe('PiAiAdapter provider routing', () => { class LateAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('not used') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } 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 db5b28bf9f..43bd0833d4 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -68,10 +68,16 @@ async function harness(image?: StoredImageAttachment): Promise { 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], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('e2e attachment fixture is read-only') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index d388f31241..c843f94a5a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -90,10 +90,16 @@ const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invari class TestAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } + validateImage(_input: SaveImageAttachment): void { + throw new Error('test invariant attachment store does not validate images') + } + saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) } From 515d48875e52d75052d98b010daaceea33b2b82f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:58:36 +0800 Subject: [PATCH 19/45] fix: harden Web image admission --- ...07-29-atomic-web-image-admission.i18n.yaml | 6 + .../2026-07-29-atomic-web-image-admission.md | 29 ++ ...026-07-29-atomic-web-image-admission.zh.md | 29 ++ ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 28 +- ...-image-input-and-durable-attachments.zh.md | 32 +- docs/cordis-catalog/services.md | 3 +- .../core-data-structures/attachment.i18n.yaml | 4 +- docs/core-data-structures/attachment.md | 6 +- docs/core-data-structures/attachment.zh.md | 6 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/package.json | 5 +- .../attachment/attachment-local/src/image.ts | 107 ++---- .../attachment/attachment-local/src/index.ts | 4 +- .../attachment/attachment-local/src/store.ts | 29 +- .../attachment-local/tests/image.spec.ts | 113 ++---- .../attachment-local/tests/index.spec.ts | 6 +- .../attachment-local/tests/store.spec.ts | 6 +- packages/attachment/attachment/src/index.ts | 3 +- packages/attachment/attachment/src/types.ts | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- packages/client/ui-conversation/package.json | 2 + .../src/client/contract/slots.ts | 8 +- .../ui-conversation/src/client/index.ts | 2 +- .../src/client/input/contract.ts | 18 +- .../src/client/input/facade.ts | 16 +- .../ui-conversation/src/client/input/hub.ts | 8 +- .../ui-conversation/src/client/service.ts | 23 +- .../ui-conversation/tests/input-bar.spec.tsx | 7 +- .../tests/service-orchestration.spec.ts | 41 ++- packages/client/ui-conversation/tsconfig.json | 3 + .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 219 +++++++----- .../apiproxy/tests/api-proxy-commands.spec.ts | 42 ++- .../apiproxy/tests/api-proxy-models.spec.ts | 141 +++++--- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 5 +- packages/llm/llm-pi-ai/src/context.ts | 32 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 12 +- packages/llm/llm-pi-ai/tests/convert.spec.ts | 61 +++- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 4 +- pnpm-lock.yaml | 331 ++++++++++++++++++ scripts/test-invariants.ts | 4 +- 52 files changed, 999 insertions(+), 444 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml new file mode 100644 index 0000000000..08398b5440 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md +2026-07-29-atomic-web-image-admission.md: 7b2f7aeb43ca1393cdab3abe35916964bc47f0c9 +2026-07-29-atomic-web-image-admission.zh.md: 5a04d2e599744dfe9f92eb64a6c828161c46bf6e diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md new file mode 100644 index 0000000000..7b2f7aeb43 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md @@ -0,0 +1,29 @@ +# Agent Note: Atomic Web image admission + +Status: implemented + +English | [中文](2026-07-29-atomic-web-image-admission.zh.md) + +## Problem + +Image prompt admission and `session.selectModel` each read session modality state across asynchronous model and attachment lookups. Without one ordering boundary, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target, or selection could miss a prompt after inbox dequeue but before its durable message event. Scanning the immutable event log avoided the second race but permanently blocked a text-only selection even after compaction removed the image from current model history. + +## Decision + +Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint. + +The pending-inbox mirror marks a prompt as claimed at dequeue and retains it until the matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the next dequeue or the transition to idle retires the claimed entry; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that mirror plus `Session.deriveMessages()`, which is the current model-visible history after compaction. + +Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly. + +## Alternatives considered + +**Scan every immutable session event.** This catches published images but treats compacted-away content as permanently model-visible, preventing a valid later switch to a text-only route. + +**Retire the pending mirror at inbox dequeue.** Dequeue precedes the durable message append and leaves the exact interval in which model selection can miss both pending and published state. + +**Serialize every prompt and session mutation.** Text-only prompts and unrelated session operations cannot introduce an image requirement. A broader lock would add latency and ownership without closing another modality race. + +## Consequences + +An image prompt and a concurrent model selection have deterministic order, and a text-only target cannot strand an image that has been admitted but not yet published. Selection may wait for an in-flight image admission, while unrelated prompts retain their existing concurrency. Compaction can make a text-only target valid once no pending or derived image remains. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md new file mode 100644 index 0000000000..5a04d2e599 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 图片准入的原子性 + +Status: implemented + +[English](2026-07-29-atomic-web-image-admission.md) | 中文 + +## 问题 + +包含图片的提示词准入与 `session.selectModel` 都会在跨越异步模型查询与附件查询的过程中读取会话模态状态。如果没有统一的顺序边界,包含图片的提示词可能在支持图片的目标上通过校验,并发的选择操作却设置了纯文本目标;选择操作也可能在提示词已从 inbox 出队、但其持久消息事件尚未发布时漏掉该提示词。扫描不可变事件日志可以避免第二种竞态,但即使压缩(compaction)已经从当前模型历史中移除图片,仍会永久阻止选择纯文本目标。 + +## 决策 + +每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。 + +待处理 inbox 镜像会在提示词出队时将其标记为已认领,并保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,下一次出队或转为空闲状态会移除已认领的条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 + +提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。 + +## 曾考虑的替代方案 + +**扫描每个不可变会话事件。** 这能捕获已发布的图片,但会把经压缩移除的内容视为永久对模型可见,从而阻止之后合法切换到纯文本路由。 + +**在 inbox 出队时退役待处理镜像。** 出队早于持久消息追加,因此恰好会留下一个时间区间,让模型选择既看不到待处理状态,也看不到已发布状态。 + +**序列化每个提示词和会话变更。** 纯文本提示词和无关的会话操作无法引入图片要求。更宽的锁会增加延迟与所有权复杂度,却不会再消除任何模态竞态。 + +## 后果 + +包含图片的提示词准入与并发模型选择之间具有确定的先后顺序,纯文本目标无法使已获准入但尚未发布的图片搁浅。模型选择可能等待正在进行的图片准入完成,而无关提示词仍按现有方式并发处理。当没有图片等待发布,且派生历史经过压缩后也不再含图片时,纯文本目标可以变得有效。 diff --git a/.agents/notes/implemented/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 index 4d1bec3b32..04783dff62 100644 --- a/.agents/notes/implemented/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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 7965f661dd232c035d986eead08bad0e61fecaf7 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 07586464874c18cc7121ae6cdee07dec379703b0 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 50ba92ed503126fc26859d7646774a5b25bcc4eb +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 93fff2a550fdcfe013110eab28ddba0a38ec7490 diff --git a/.agents/notes/implemented/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 index 7965f661dd..50ba92ed50 100644 --- a/.agents/notes/implemented/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 @@ -44,7 +44,9 @@ The persistence boundary is message acceptance, not paste: Each session's `InputMachine` state keeps the ordered runtime-only attachment identifiers alongside the live draft. The framework-owned chat store receives only the draft's plain-text persistence mirror, while `ConversationService` owns the corresponding browser-only `File` and object-URL registry: ```ts -export {} +import type { Branded } from '@deepseek-ai/dsh-brand' + +type DraftAttachmentId = Branded<'DraftAttachmentId'> interface ChatStoreState { selection: object | null @@ -54,12 +56,12 @@ interface ChatStoreState { interface InputState { draft: string - imageIds: readonly string[] + imageIds: readonly DraftAttachmentId[] } interface ComposerAttachment { kind: 'image' - id: string + id: DraftAttachmentId file: File previewUrl: string } @@ -67,7 +69,7 @@ interface ComposerAttachment { This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -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, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) 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 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, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. 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. @@ -112,17 +114,17 @@ 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: it validates the complete batch through the seam's storage-free `validateImage` before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure appends no user event and exposes no attachment path or raw bytes. +Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, the declared MIME against a fully decoded raster, intrinsic dimensions, and decoded-pixel count. It awaits the seam's storage-free `validateImage` for every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the host appends no user event, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure 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 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. +`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 rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache. ### Model capabilities and provider behavior Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -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. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +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. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)). Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. -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. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. 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. +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. 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. @@ -140,14 +142,14 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state 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 enforced by the host before persistence. The client connection carrier independently caps buffered API request bodies, deriving the cap from the host attachment service's aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. -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. +Malformed base64, unsupported or mismatched media, truncated image payloads, 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. ### Package and surface changes | Surface | Responsibility | | --- | --- | | `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. | -| `packages/attachment/attachment-local` | Private content-addressed storage, image-header validation, integrity verification, and configuration. | +| `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | | `packages/llm/llm-deepseek` | Reject image content explicitly. | @@ -194,9 +196,9 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog ## Testing - 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, bounded HTTP request bodies, and refusal of a text-only `session.selectModel` once the session log carries an image (an accepted switch would strand every later turn). -- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, and draft/session-scope/application object-URL cleanup; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. -- 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. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races, pending publication, and selection against current derived history after compaction. +- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. +- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively 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 has no certified image-output route; output-provider certification remains outside version one. diff --git a/.agents/notes/implemented/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 index 0758646487..93fff2a550 100644 --- a/.agents/notes/implemented/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 @@ -44,7 +44,9 @@ Status: implemented 每个会话的 `InputMachine` 状态在实时草稿旁保存仅限运行时的有序附件标识符。框架持有的 chat store 只接收草稿的纯文本持久化镜像,`ConversationService` 则持有相应的浏览器专用 `File` 与对象 URL 注册表: ```ts -export {} +import type { Branded } from '@deepseek-ai/dsh-brand' + +type DraftAttachmentId = Branded<'DraftAttachmentId'> interface ChatStoreState { selection: object | null @@ -54,12 +56,12 @@ interface ChatStoreState { interface InputState { draft: string - imageIds: readonly string[] + imageIds: readonly DraftAttachmentId[] } interface ComposerAttachment { kind: 'image' - id: string + id: DraftAttachmentId file: File previewUrl: string } @@ -67,7 +69,7 @@ interface ComposerAttachment { 这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中,每次读取都会校验摘要、媒体类型、字节长度、宽度和高度。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -112,23 +114,23 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数:它会在保存任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用对象。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、声明的 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数。它会在保存任何成员之前,等待服务边界上不触碰存储的 `validateImage` 完成对每个批次成员的校验,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,宿主不会追加用户事件,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 -`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 +`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。 ### 模型能力与提供方行为 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md))。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 -压缩(compaction)会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 +压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 ### 历史渲染与原图预览 @@ -140,14 +142,14 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme 第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据宿主附件服务的图片总量限制,加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 -格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 +格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 ### 包与接口变更 | 接口 | 职责 | | --- | --- | | `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 | -| `packages/attachment/attachment-local` | 私有内容寻址存储、图片头校验、完整性校验和配置。 | +| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | | `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | @@ -177,7 +179,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 在消息与会话日志中内联 base64 -这种方式会在 RPC、事件、历史分页、fork、压缩(compaction)和浏览器存储中复制二进制数据,还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。 +这种方式会在 RPC、事件、历史分页、fork、压缩和浏览器存储中复制二进制数据,还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。 ### 使用浏览器对象 URL、本地路径或提供方 URL 作为规范内容 @@ -194,9 +196,9 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体,以及在会话日志已含图片时拒绝切换到纯文本模型的 `session.selectModel`(接受该切换会让此后每一轮都失败)。 -- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序,以及草稿、会话作用域和应用层级的对象 URL 清理;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 -- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态、待发布状态,以及压缩后依据当前派生历史进行的选择。 +- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 +- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 - 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 71d009fea3..f480d9581f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -264,8 +264,9 @@ Immutable binary attachment service. Implementations validate bytes before publi * Validate one image without persisting it. * Batch callers validate every member before saving any member. * @param input - encoded bytes, declared media type, and optional display name. + * @returns completion after the encoded raster has been fully decoded. */ -abstract validateImage(input: SaveImageAttachment): void +abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/docs/core-data-structures/attachment.i18n.yaml b/docs/core-data-structures/attachment.i18n.yaml index ccbe788946..247079b07f 100644 --- a/docs/core-data-structures/attachment.i18n.yaml +++ b/docs/core-data-structures/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/attachment.md -attachment.md: 566f0198ce6e797e270b63e3cfd255a0025d7135 -attachment.zh.md: 95d755407b3b2ea7ab5b109f2e78edea16ccdd9c +attachment.md: 5423891d2be483364d48d8520a596c52d5f6e66e +attachment.zh.md: d1183b5a984cb5f70fce5aa1b739e280b89c3f47 diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md index 566f0198ce..5423891d2b 100644 --- a/docs/core-data-structures/attachment.md +++ b/docs/core-data-structures/attachment.md @@ -36,7 +36,7 @@ interface ImageAttachmentRef { ``` ```ts type-equiv -/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +/** Deployment-resolved limits used by upload admission and request buffering. */ interface ImageAttachmentLimits { maxImageBytes: number maxImagesPerMessage: number @@ -54,7 +54,7 @@ The reference records intrinsic dimensions and encoded length so clients can lay /** Request to validate and durably commit one image. */ interface SaveImageAttachment { data: Uint8Array - /** Caller-declared media type, checked against magic bytes. */ + /** Caller-declared media type, checked against fully decoded bytes. */ mediaType: ImageMediaType /** Optional browser/provider display name; it is never interpreted as a path. */ name?: string @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so admission rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. diff --git a/docs/core-data-structures/attachment.zh.md b/docs/core-data-structures/attachment.zh.md index 95d755407b..d1183b5a98 100644 --- a/docs/core-data-structures/attachment.zh.md +++ b/docs/core-data-structures/attachment.zh.md @@ -36,7 +36,7 @@ interface ImageAttachmentRef { ``` ```ts type-equiv -/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +/** Deployment-resolved limits used by upload admission and request buffering. */ interface ImageAttachmentLimits { maxImageBytes: number maxImagesPerMessage: number @@ -54,7 +54,7 @@ interface ImageAttachmentLimits { /** Request to validate and durably commit one image. */ interface SaveImageAttachment { data: Uint8Array - /** Caller-declared media type, checked against magic bytes. */ + /** Caller-declared media type, checked against fully decoded bytes. */ mediaType: ImageMediaType /** Optional browser/provider display name; it is never interpreted as a path. */ name?: string @@ -69,4 +69,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此准入拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 3b02e4d79c..77b716173f 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: 38331df827d64c8370d0208f6898971e46010afa -README.zh.md: bc6a471465c7bffc136e8ec2233e200a292f2d1a +README.md: 310120bd1c3573da1e7c60334d5c2712195f3186 +README.zh.md: 9fd3a857eca1c25c90b665735f67d2c27c92334a diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 38331df827..310120bd1c 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -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, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash; 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. +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, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest 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/README.zh.md b/packages/attachment/attachment-local/README.zh.md index bc6a471465..9fd3a857ec 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在;读取过程会重新校验摘要、媒体签名、尺寸和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index b87fd83e79..239063542f 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -20,7 +20,10 @@ "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.6" }, - "dependencies": { "schemastery": "^3.18.0" }, + "dependencies": { + "schemastery": "^3.18.0", + "sharp": "^0.35.3" + }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index 4e7ae5aabb..1ef30358a2 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -1,102 +1,45 @@ -/** Minimal raster header validation used before bytes enter durable storage. */ +/** Raster decoding used before bytes enter durable storage. */ +import sharp from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' -/** Decoded metadata from a supported image header. */ +/** Decoded metadata from a supported image. */ export interface DetectedImage { mediaType: ImageMediaType width: number height: number } -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 new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset) -} - -function u16le(data: Uint8Array, offset: number): number { - return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset, true) -} - -function u24le(data: Uint8Array, offset: number): number { - 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 new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset) -} - -function u32le(data: Uint8Array, offset: number): number { - return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, true) -} - -function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage { - if (width < 1 || height < 1) throw new AttachmentError('Image dimensions must be positive.', 'INVALID_IMAGE') - return { mediaType, width, height } -} - -function jpeg(data: Uint8Array): DetectedImage | null { - if (data[0] !== 0xff || data[1] !== 0xd8) return null - const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) - let offset = 2 - while (offset + 3 < data.length) { - while (data[offset] === 0xff) offset++ - const marker = data[offset] - if (marker === undefined || marker === 0xd9 || marker === 0xda) break - if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { - offset++ - continue - } - const length = u16be(data, offset + 1) - if (length < 2 || offset + 1 + length > data.length) throw new AttachmentError('JPEG data is truncated.', 'INVALID_IMAGE') - if (sof.has(marker)) { - if (length < 7) throw new AttachmentError('JPEG dimensions are truncated.', 'INVALID_IMAGE') - return dimensions(u16be(data, offset + 6), u16be(data, offset + 4), 'image/jpeg') - } - offset += length + 1 - } - throw new AttachmentError('JPEG dimensions are missing.', 'INVALID_IMAGE') +const MEDIA_TYPES: Readonly> = { + png: 'image/png', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', } /** - * Detect a supported raster type and intrinsic dimensions from encoded bytes. + * Decode a supported raster and return its intrinsic metadata. * @param data - complete encoded image bytes. + * @param maxPixels - optional write-time decoded-pixel limit; reads omit it. * @returns verified format and dimensions. */ -export function detectImage(data: Uint8Array): DetectedImage { - if (data.length >= 24 - && data[0] === 0x89 && ascii(data, 1, 'PNG\r\n\u001a\n') && ascii(data, 12, 'IHDR')) { - return dimensions(u32be(data, 16), u32be(data, 20), 'image/png') - } - if (data.length >= 10 && (ascii(data, 0, 'GIF87a') || ascii(data, 0, 'GIF89a'))) { - return dimensions(u16le(data, 6), u16le(data, 8), 'image/gif') - } - const detectedJpeg = jpeg(data) - if (detectedJpeg !== null) return detectedJpeg - if (data.length >= 30 && ascii(data, 0, 'RIFF') && ascii(data, 8, 'WEBP')) { - const declaredLength = u32le(data, 4) + 8 - 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 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') +export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { + try { + const image = sharp(data, { failOn: 'error', limitInputPixels: false }) + const metadata = await image.metadata() + const mediaType = MEDIA_TYPES[metadata.format as string] + if (mediaType === undefined) { + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') } - if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { - return dimensions(u16le(data, 26) & 0x3fff, u16le(data, 28) & 0x3fff, 'image/webp') + const { width, height } = metadata + if (maxPixels !== undefined && width * height > maxPixels) { + throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') } - throw new AttachmentError('WebP dimensions are missing.', 'INVALID_IMAGE') + await image.raw().toBuffer() + return { mediaType, width, height } + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) } - throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') } diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index c015887bfc..7ed4824ef2 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -60,8 +60,8 @@ export class LocalAttachmentStore extends AttachmentStore { }) } - validateImage(input: SaveImageAttachment): void { - validateImageFile(input, this.imageLimits) + async validateImage(input: SaveImageAttachment): Promise { + await validateImageFile(input, this.imageLimits) } async saveImage(input: SaveImageAttachment): Promise { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index b8a5c6aa3c..f489ae315c 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -38,27 +38,28 @@ function ensureReference(ref: ImageAttachmentRef): string { return match[1] } -function inspectMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType']): Omit { +async function inspectMetadata( + data: Uint8Array, + declaredMediaType: ImageAttachmentRef['mediaType'], + maxPixels?: number, +): Promise> { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') - const detected = detectImage(data) + const detected = await detectImage(data, maxPixels) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') 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') - } -} - /** * Run the full admission policy for one image without touching storage. * @param input - encoded bytes and declared metadata. * @param limits - resolved storage policy. + * @returns completion after the encoded raster has been fully decoded. */ -export function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): void { - validateAdmission(inspectMetadata(input.data, input.mediaType), limits) +export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { + if (input.data.byteLength > limits.maxImageBytes) { + throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + } + await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) } /** @@ -112,8 +113,8 @@ async function ensureDurableDirectory(path: string, boundary: string): Promise { - const metadata = inspectMetadata(input.data, input.mediaType) - validateAdmission(metadata, limits) + if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') @@ -187,7 +188,7 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef): Prom 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 = inspectMetadata(data, ref.mediaType) + const metadata = await 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 index 04cbe984fd..ccfe7f62e7 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -1,93 +1,42 @@ +import sharp from 'sharp' import { describe, expect, it } from 'vitest' import { detectImage } from '../src/image.ts' -function bytes(text: string): number[] { - return [...Buffer.from(text, 'ascii')] +async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise { + const image = sharp({ + create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, + }) + return new Uint8Array(await image.toFormat(format).toBuffer()) } -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 }) +describe('raster decoding', () => { + it('decodes every supported format and its intrinsic dimensions', async () => { + for (const [format, mediaType] of [ + ['png', 'image/png'], + ['jpeg', 'image/jpeg'], + ['webp', 'image/webp'], + ['gif', 'image/gif'], + ] as const) { + await expect(detectImage(await raster(format))) + .resolves.toEqual({ mediaType, width: 3, height: 2 }) + } }) - 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('rejects excess decoded pixels before decoding', async () => { + await expect(detectImage(await raster('png'), 5)) + .rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) }) - 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/) + it('rejects malformed bytes and truncated payloads with readable headers', async () => { + await expect(detectImage(Uint8Array.of(1, 2, 3))) + .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const unsupported = await sharp({ + create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).tiff().toBuffer() + await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const complete = await raster('png') + const truncated = complete.subarray(0, 62) + await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 }) + await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) }) }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 22912a2963..7ea71d166b 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -42,13 +42,13 @@ describe('local attachment service', () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) try { const service = new LocalAttachmentStore(new Context(), { dshHome }) - expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) }) - .toThrow(/Unsupported or malformed image data/) + await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' })) + .rejects.toThrow(/Unsupported or malformed image data/) const valid = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', )) - expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow() + await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined() expect(existsSync(service.root)).toBe(false) } 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 9287c702ad..5838b8748c 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' +import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import { readImageFile, saveImageFile } from '../src/store.ts' @@ -122,8 +123,9 @@ describe('local attachment store', () => { data: PNG, mediaType: 'image/png', }, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) - const wide = PNG.slice() - wide.set([0, 0, 0, 5, 0, 0, 0, 5], 16) + const wide = new Uint8Array(await sharp({ + create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).png().toBuffer()) await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 74e280a70f..ebe6ad59c3 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -37,8 +37,9 @@ export abstract class AttachmentStore extends Service { * Validate one image without persisting it. * Batch callers validate every member before saving any member. * @param input - encoded bytes, declared media type, and optional display name. + * @returns completion after the encoded raster has been fully decoded. */ - abstract validateImage(input: SaveImageAttachment): void + abstract validateImage(input: SaveImageAttachment): Promise /** * Validate and durably commit one image before its owning session event is appended. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 88b1dceb52..c443cb8763 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -33,7 +33,7 @@ export interface ImageAttachmentRef { name?: string } -/** Deployment-resolved limits shared by upload consumers and UI preflight. */ +/** Deployment-resolved limits used by upload admission and request buffering. */ export interface ImageAttachmentLimits { maxImageBytes: number maxImagesPerMessage: number @@ -45,7 +45,7 @@ export interface ImageAttachmentLimits { /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { data: Uint8Array - /** Caller-declared media type, checked against magic bytes. */ + /** Caller-declared media type, checked against fully decoded bytes. */ mediaType: ImageMediaType /** Optional browser/provider display name; it is never interpreted as a path. */ name?: string diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 825072d603..e1d3155591 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 74137edccc3ca68c40afd545350ce3811d6ae2e2 -README.zh.md: c974640ba08451d0a666061c6ec46ca88d66a85e +README.md: ad64511120e3d4308ab03bb45de21b7d577b4335 +README.zh.md: 69926a282dea3f13564fe97e75d0c7c937b86335 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 74137edccc..ad64511120 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,9 +22,9 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -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. +Image drafts keep only ordered `DraftAttachmentId` values in that store. `ConversationService` owns the corresponding browser `File` and object URLs, rejects unsupported declared image media types before allocating previews, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. A historical read that completes after its rendered session or the service is disposed rejects before allocating an object URL. 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` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) 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). +`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) 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 limited to `apply`/`inject` and contract types; concrete services, implementation components (skeleton, chat rows), and the store factory stay internal. Same-package tests import those internals through `./src/*`. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c974640ba0..69926a282d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -22,9 +22,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 -图片草稿在该 store 中只保留有序的运行时 id。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,在分配前应用最新的宿主能力与上传限制快照,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。 +图片草稿在该 store 中只保留有序的 `DraftAttachmentId` 值。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,会在分配预览前拒绝声明媒体类型不受支持的图片,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。一项历史读取如果在其所渲染的会话卸载或该服务释放后才完成,会在分配对象 URL 前被拒绝。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。 -`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 +`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层仅限 `apply`/`inject` 与契约类型;具体服务、实现组件(骨架、聊天行)和 store factory 均保持内部状态。同包测试通过 `./src/*` 导入这些内部实现。 ## 模型体验 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 08c1db5736..d765d8310e 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -40,6 +40,7 @@ }, "peerDependencies": { "@deepseek-ai/dsh-attachment": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -51,6 +52,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 11937d2974..c66e53378b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -6,14 +6,14 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts' +import type { ComposerKeyboard, DraftAttachmentId, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' /** Browser-owned image that has not crossed the durable host boundary. */ export interface ComposerAttachment { kind: 'image' - id: string + id: DraftAttachmentId file: File previewUrl: string } @@ -275,9 +275,9 @@ export interface ComposerBarInjected { /** Create browser previews and append their ids to the session input state. */ addImages: (files: readonly File[]) => string | null /** Release one browser preview and remove its id from the session input state. */ - removeImage: (id: string) => void + removeImage: (id: DraftAttachmentId) => void /** Resolve ordered input-state ids to browser-owned draft attachments. */ - draftImages: (ids: readonly string[]) => readonly ComposerAttachment[] + draftImages: (ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[] /** Cancel the in-flight turn. */ stop: () => void /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index dca423ff4e..1963dcd72e 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -4,8 +4,8 @@ * owns their slot assembly. */ export { apply, inject } from './apply.ts' -export { ConversationService } from './service.ts' export type { IConversation } from './service.ts' +export type { DraftAttachmentId } from './input/contract.ts' export type { CallId, ChatStoreState, SelectionTarget, ViewTab, diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 45ec747f5c..a125348b48 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -6,11 +6,15 @@ * (machine.ts) is package-private and never exported. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' +/** Browser-runtime identity of one unsent image draft. */ +export type DraftAttachmentId = Branded<'DraftAttachmentId'> + /** * The scoped-event application verbs: the hub's bail listeners call these, * and the boolean answer IS the event's bail value (true ⟺ the machine @@ -28,11 +32,11 @@ export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly string[]): void + addImages(ids: readonly DraftAttachmentId[]): void /** Remove one browser-owned draft attachment id. */ - removeImage(id: string): void + removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ - pruneImages(ids: readonly string[]): void + pruneImages(ids: readonly DraftAttachmentId[]): void /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ submit(mode?: 'queue' | 'steer'): void /** @@ -65,11 +69,11 @@ export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly string[]): void + addImages(ids: readonly DraftAttachmentId[]): void /** Remove one browser-owned draft attachment id. */ - removeImage(id: string): void + removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ - pruneImages(ids: readonly string[]): void + pruneImages(ids: readonly DraftAttachmentId[]): void /** Enter submission (adjudication / claim transaction / default sink inside). */ submit(mode?: 'queue' | 'steer'): void } @@ -198,7 +202,7 @@ export interface InputMachineOptions { export interface InputState { readonly draft: string /** Ordered runtime-only image ids; bytes and object URLs stay in ConversationService. */ - readonly imageIds: readonly string[] + readonly imageIds: readonly DraftAttachmentId[] /** Monotonic draft revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 7e3057d8ab..2d566d7632 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -13,7 +13,7 @@ import type { ReferenceInsert, SlashController, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { - EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, + DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' import { InputMachine } from './machine.ts' @@ -39,7 +39,7 @@ export interface SessionInputDeps { /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly string[]): void + defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly DraftAttachmentId[]): void } /** Guard tier from the machine phase. */ @@ -79,7 +79,7 @@ export class SessionInputShell implements SessionInput { private readonly core = new InputMachine({ now: () => Date.now() }) private noticeSeq = 0 private lastDraft = '' - private imageIds: readonly string[] = [] + private imageIds: readonly DraftAttachmentId[] = [] private disposed = false /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ private mirrorFn: ((text: string) => void) | undefined @@ -102,14 +102,14 @@ export class SessionInputShell implements SessionInput { } /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly string[]): void { + addImages(ids: readonly DraftAttachmentId[]): void { if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return this.imageIds = [...this.imageIds, ...ids] this.publish() } /** Remove one browser-owned draft attachment id. */ - removeImage(id: string): void { + removeImage(id: DraftAttachmentId): void { const next = this.imageIds.filter(candidate => candidate !== id) if (next.length === this.imageIds.length) return this.imageIds = next @@ -120,7 +120,7 @@ export class SessionInputShell implements SessionInput { * Drop ids whose browser objects no longer exist. * @param available - ids that still resolve through the browser attachment registry. */ - pruneImages(available: readonly string[]): void { + pruneImages(available: readonly DraftAttachmentId[]): void { const keep = new Set(available) const next = this.imageIds.filter(id => keep.has(id)) if (next.length === this.imageIds.length) return @@ -132,7 +132,7 @@ export class SessionInputShell implements SessionInput { * Restore a failed attempt's ids before any images added after submission. * @param ids - ordered identifiers captured by the failed attempt. */ - restoreImages(ids: readonly string[]): void { + restoreImages(ids: readonly DraftAttachmentId[]): void { const current = new Set(this.imageIds) this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] this.publish() @@ -144,7 +144,7 @@ export class SessionInputShell implements SessionInput { * (the command path gets the same discipline from submit-settled success). * @param imageIds - identifiers included in the committed attempt. */ - commitSend(imageIds: readonly string[]): void { + commitSend(imageIds: readonly DraftAttachmentId[]): void { const submitted = new Set(imageIds) this.imageIds = this.imageIds.filter(id => !submitted.has(id)) this.run(this.core.dispatch({ type: 'send-committed' })) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index e0568ce70f..6f1c68a12d 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -11,7 +11,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' import { queueReadFaceOf } from '../queue/store.ts' -import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' +import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' @@ -26,9 +26,9 @@ interface ConversationAttachmentFace { session: SessionFace, text: string, mode: 'queue' | 'steer', - imageIds: readonly string[], + imageIds: readonly DraftAttachmentId[], ): Promise - releaseDraftImage(id: string): void + releaseDraftImage(id: DraftAttachmentId): void } /** Session-addressed input facade registry (InputService face + composer-layer extras). */ @@ -138,7 +138,7 @@ export class InputHub implements InputService { session: SessionFace, text: string, mode: 'queue' | 'steer', - imageIds: readonly string[], + imageIds: readonly DraftAttachmentId[], ): void { if (text === '' && imageIds.length === 0) return const shell = this.shells.get(session.sessionId) diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 81caad16b6..1f2b1e1edb 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -15,7 +15,7 @@ import type { Context } from 'cordis' import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' -import type { InputService } from './input/contract.ts' +import type { DraftAttachmentId, InputService } from './input/contract.ts' /** * The outward conversation face (`ctx.conversation`): the scope-addressed @@ -46,7 +46,7 @@ export interface IConversation { /** Create one browser-only draft descriptor; only its id enters input state. */ function browserDraftAttachment(file: File): ComposerAttachment { - return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file } + return { kind: 'image', id: crypto.randomUUID() as DraftAttachmentId, previewUrl: URL.createObjectURL(file), file } } interface ImageUrlEntry { @@ -59,10 +59,11 @@ interface ImageUrlEntry { export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ readonly input: InputService - private readonly draftAttachments = new Map() + private readonly draftAttachments = new Map() private readonly imageUrls = new Map() private readonly imageGenerations = new Map() private readonly createdImageUrls = new Set() + private disposed = false /** * @param ctx - owning root context (the plugin apply context; the service @@ -74,6 +75,7 @@ export class ConversationService extends Service implements IConversation { super(ctx, 'conversation') this.input = config.input ctx.effect(() => () => { + this.disposed = true for (const url of this.createdImageUrls) URL.revokeObjectURL(url) this.createdImageUrls.clear() this.draftAttachments.clear() @@ -107,7 +109,7 @@ export class ConversationService extends Service implements IConversation { session: SessionFace, text: string, mode: 'queue' | 'steer', - imageIds: readonly string[], + imageIds: readonly DraftAttachmentId[], ): Promise { const attachments = this.draftImages(imageIds) if (attachments.length !== imageIds.length) { @@ -149,7 +151,7 @@ export class ConversationService extends Service implements IConversation { * @param ids - ordered ids from the per-session input state. * @returns attachments still available in this browser runtime. */ - draftImages(ids: readonly string[]): readonly ComposerAttachment[] { + draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { const attachments: ComposerAttachment[] = [] for (const id of ids) { const attachment = this.draftAttachments.get(id) @@ -162,7 +164,7 @@ export class ConversationService extends Service implements IConversation { * Release one draft attachment preview. * @param id - draft-local attachment id. */ - releaseDraftImage(id: string): void { + releaseDraftImage(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return this.draftAttachments.delete(id) @@ -185,6 +187,7 @@ export class ConversationService extends Service implements IConversation { * @returns a browser URL for inline and original-size display. */ resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed')) const key = `${sessionId}:${attachment.attachmentId}` const cached = this.imageUrls.get(key) if (cached !== undefined) return cached.pending @@ -194,6 +197,10 @@ export class ConversationService extends Service implements IConversation { const pending = session.readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed') + if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { + throw new Error('historical image scope was released before loading completed') + } if (typeof URL.createObjectURL !== 'function') { return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` } @@ -201,10 +208,6 @@ export class ConversationService extends Service implements IConversation { 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 }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 5a5ea3dd2b..4afc2dc81c 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -13,6 +13,7 @@ import { SessionInputShell } from '../src/client/input/facade.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import type { ComposerAttachment } from '../src/client/contract/slots.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' afterEach(cleanup) @@ -78,7 +79,7 @@ function bench(over?: BenchOptions) { promptError: over?.promptError ?? null, })) const stop = vi.fn() - const removeImage = vi.fn((id: string) => { shell.removeImage(id) }) + const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) }) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, owner }) @@ -523,7 +524,7 @@ describe('image draft rail', () => { 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 = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' } + const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] }) const send = view.getByRole('button', { name: 'Send message' }) as HTMLButtonElement expect(send.disabled).toBe(false) @@ -536,7 +537,7 @@ describe('image draft rail', () => { it('opens the original preview on double-click and closes it with Escape', () => { 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 attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } const { view } = bench({ attachments: [attachment] }) fireEvent.doubleClick(view.getByTitle('双击查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 5f8d77e335..fc3a23f7dd 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -6,17 +6,19 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import { InputHub } from '../src/client/input/hub.ts' +import { ConversationService } from '../src/client/service.ts' -async function bench() { +async function bench(readAttachment?: SessionFace['readAttachment']) { const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) await runtime.sessions.add({ id: 's1', - session: { prompt, cancel, loadOlder }, + session: { prompt, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. @@ -25,7 +27,7 @@ async function bench() { await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, hub, root, scoped, prompt, cancel, loadOlder } + return { runtime, fiber, hub, root, scoped, prompt, cancel, loadOlder } } describe('ConversationService', () => { @@ -122,6 +124,37 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('does not publish a historical image URL after disposal', async () => { + let resolveRead!: (result: Awaited>) => void + const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise>>( + (resolve) => { resolveRead = resolve }, + )) + const b = await bench(readAttachment) + const created = vi.spyOn(URL, 'createObjectURL') + const sessionId = b.runtime.sessions.behavior('s1').sessionId + const attachment = { + attachmentId: AttachmentId('image-1'), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } as const + const pending = b.root.resolveImage(sessionId, attachment) + await b.fiber.dispose() + await expect(b.root.resolveImage(sessionId, attachment)).rejects.toThrow('service is disposed') + resolveRead({ + ok: true, + value: { + attachment, + data: Uint8Array.of(1), + }, + }) + await expect(pending).rejects.toThrow('service was disposed before loading completed') + expect(created).not.toHaveBeenCalled() + created.mockRestore() + await b.runtime.dispose() + }) + it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index b809c605b1..74fd6f0da7 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b6cfbf882a..06e7036e21 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -161,8 +161,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Immutable binary attachment service.', methods: [ { - signature: 'abstract validateImage(input: SaveImageAttachment): void', - jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', + signature: 'abstract validateImage(input: SaveImageAttachment): Promise', + jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns completion after the encoded raster has been fully decoded.\n */', }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 18202ed036..4571d984b6 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 -README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 +README.md: 693b2c26a9ec1e7ea31030a3028f05706adbfc3b +README.zh.md: b1766d901d9ed5743cbc766166bfb310c565ef5f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1f0daedc54..693b2c26a9 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Selection is serialized with image-bearing prompt admission and rejects a text-only target while an image is pending publication or remains in the current derived history; an image removed by compaction no longer blocks selection. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index abf5417cdb..b1766d901d 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,7 @@ 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行;当图片正等待发布或仍存在于当前派生历史中时,会拒绝选择纯文本目标;被压缩(compaction)移除的图片不再阻止选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c34093edbc..a7f56df1f7 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -92,30 +92,29 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') } for (const image of images) { - ctx.attachments.validateImage({ + await ctx.attachments.validateImage({ data: image.data, mediaType: image.part.mediaType, ...image.part.name === undefined ? {} : { name: image.part.name }, }) } - return Promise.all(prepared.map(async (item): Promise => { - if (!('data' in item)) return { type: 'text', text: item.text } + const blocks: ContentBlock[] = [] + for (const item of prepared) { + if (!('data' in item)) { + blocks.push({ type: 'text', text: item.text }) + continue + } const attachment = await ctx.attachments.saveImage({ data: item.data, mediaType: item.part.mediaType, ...item.part.name === undefined ? {} : { name: item.part.name }, }) - return { type: 'image', attachment } - })) + blocks.push({ type: 'image', attachment }) + } + return blocks } -/** - * The ONE recursive block walk shared by attachment authorization and the - * model-selection gate (nested tool-result content included). Both consumers - * must agree on what counts as replayed image content — a route added to one - * walker but not the other would silently skip authorization or stranding - * protection — so there is exactly one walker, parameterized by match. - */ +/** Search durable event content for an image reference, including nested tool results. */ function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { if (!Array.isArray(content)) return undefined for (const value of content) { @@ -148,18 +147,15 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b return undefined } -/** True when any block (nested tool-result content included) is an image block. */ -function contentHasImage(content: unknown): boolean { - return imageBlockIn(content, () => true) !== undefined +/** True when typed model content contains an image, including nested tool results. */ +function contentHasImage(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'image' + || (block.type === 'tool-result' && contentHasImage(block.content))) } -/** - * True when the session log already carries image content on any route a - * model request replays (message content, wrapped messages, streamed blocks). - * The log is immutable, so a true here is permanent for the session's life. - */ -function sessionHasImage(events: readonly SessionEvent[]): boolean { - return events.some(event => imageInEvent(event, () => true) !== undefined) +/** True when the current model-visible surface contains an image. */ +function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean { + return messages.some(message => contentHasImage(message.content)) } function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { @@ -564,6 +560,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingQuestions = new Map() const pendingApprovals = new Map() const muxQueues = new Set>>() + const imageAdmissionChains = new WeakMap>() + + /** Serialize model selection with image prompt admission for one agent. */ + function serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { + const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) + imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) + return result + } /** * Install or return the session-local target that prompt assembly snapshots. @@ -619,18 +623,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * Per-session inbox occurrence mirror serving the mux-open queue snapshot * (the same refresh-recovery baseline as pending questions). Each terminal * inbox event retires one matching occurrence, so repeated sends of the same - * identified message remain visible until every occurrence is claimed. + * identified message remain visible until every occurrence is published or + * discarded. Dequeue is not publication: the log append follows it. */ - const queuedMirror = new Map() + const queuedMirror = new Map() ctx.effect(() => { - const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => { - const entries = queuedMirror.get(agent.id) + const retire = (sessionId: SessionId, id: MessageId, placement?: InboxPlacement): void => { + const entries = queuedMirror.get(sessionId) if (entries === undefined) return const index = entries.findIndex(entry => entry.message.id === id && (placement === undefined || entry.steering === (placement === 'steering'))) if (index !== -1) entries.splice(index, 1) - if (entries.length === 0) queuedMirror.delete(agent.id) + if (entries.length === 0) queuedMirror.delete(sessionId) + } + const retireClaimed = (sessionId: SessionId): void => { + const entries = queuedMirror.get(sessionId) + if (entries === undefined) return + const pending = entries.filter(entry => !entry.claimed) + if (pending.length === 0) queuedMirror.delete(sessionId) + else queuedMirror.set(sessionId, pending) } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => { @@ -640,7 +652,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queuedMirror.set(agent.id, entries) } const steering = placement === 'steering' - entries.push({ message, steering }) + entries.push({ message, steering, claimed: false }) broadcast({ type: 'session/queued', sessionId: agent.id, @@ -649,10 +661,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) }), ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => { - retire(agent, message.id, placement) + // A later claim proves any earlier claimed item either published (and + // was retired by session/event) or its admission ended without one. + retireClaimed(agent.id) + const entry = queuedMirror.get(agent.id)?.find(candidate => + candidate.message.id === message.id + && candidate.steering === (placement === 'steering')) + if (entry !== undefined) entry.claimed = true + }), + ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type === 'user/message') { + retire(session.id, event.data.id, 'queued') + } else if (event.type === 'steering/message') { + retire(session.id, (event.data as { message: UserMessage }).message.id, 'steering') + } }), ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => { - for (const message of messages) retire(agent, message.id) + for (const message of messages) retire(agent.id, message.id) + }), + ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { + if (status === 'idle') retireClaimed(agent.id) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) @@ -1133,48 +1161,47 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const { sessionId, provider, model, reasoningEffort } = request.payload const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) - try { - const resolved = await ctx.llm.resolveCallConfig({ - provider, - model, - ...reasoningEffort === undefined - ? {} - : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, - }) - // An image-bearing log replays into every later request, and both - // wire routes reject image content on text-only models — accepting - // this selection would strand the session (every turn fails, no - // in-product recovery). Refuse at the selection boundary instead. - // The pending inbox counts too: a queued image prompt enters the log - // only when claimed, which would happen AFTER this switch landed. - const queuedImage = (queuedMirror.get(sessionId) ?? []) - .some(entry => contentHasImage(entry.message.content)) - if (queuedImage || sessionHasImage(found.agent.session.events)) { - const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model) - if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { - return err(request, { - code: 'model-unavailable', - message: `Model "${resolved.model}" does not accept image input, but this session's history already contains images; select an image-capable model.`, - details: { provider, model }, - }) + return serializeImageAdmission(found.agent, async () => { + try { + const resolved = await ctx.llm.resolveCallConfig({ + provider, + model, + ...reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, + }) + // A current image-bearing surface replays into the next request, + // while a dequeued prompt remains pending until its message event + // publishes. Refuse a text-only route at this shared boundary. + const queuedImage = (queuedMirror.get(sessionId) ?? []) + .some(entry => contentHasImage(entry.message.content)) + if (queuedImage || messagesHaveImage(found.agent.session.deriveMessages())) { + const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model) + if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { + return err(request, { + code: 'model-unavailable', + message: `Model "${resolved.model}" does not accept image input, but this session's history already contains images; select an image-capable model.`, + details: { provider, model }, + }) + } } + const selected: AgentLlmTarget = { + provider: resolved.provider, + model: resolved.model, + ...resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }, + } + targetFor(found.agent).current = selected + return ok(request, { selected: { ...selected } }) + } catch (error: unknown) { + return err(request, { + code: 'model-unavailable', + message: error instanceof Error ? error.message : String(error), + details: { provider, model }, + }) } - const selected: AgentLlmTarget = { - provider: resolved.provider, - model: resolved.model, - ...resolved.reasoningEffort === undefined - ? {} - : { reasoningEffort: resolved.reasoningEffort }, - } - targetFor(found.agent).current = selected - return ok(request, { selected: { ...selected } }) - } catch (error: unknown) { - return err(request, { - code: 'model-unavailable', - message: error instanceof Error ? error.message : String(error), - details: { provider, model }, - }) - } + }) }, async rename(request) { @@ -1214,36 +1241,40 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const agent = found.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } - try { - if (content.some(part => part.type === 'image')) { - const target = targetFor(agent).current - const provider = target.provider - const model = target.model - const modelInfo = await ctx.llm.resolveModelInfo(provider, model) - if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { + const hasImage = content.some(part => part.type === 'image') + const admit = async (): Promise> => { + try { + if (hasImage) { + const target = targetFor(agent).current + const provider = target.provider + const model = target.model + const modelInfo = await ctx.llm.resolveModelInfo(provider, model) + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { + return err(request, { + code: 'attachment-error', + message: `Model "${model}" does not support image input.`, + details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + }) + } + } + const durable = await durablePromptContent(ctx, content) + const message: UserMessage = createUserMessage({ content: durable, source }) + if (mode === 'steer') agent.steer(message) + else agent.followup(message) + } catch (error: unknown) { + if (error instanceof AttachmentError) { return err(request, { code: 'attachment-error', - message: `Model "${model}" does not support image input.`, - details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + message: error.message, + details: { reason: error.code }, }) } + // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached. + return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) } - const durable = await durablePromptContent(ctx, content) - const message: UserMessage = createUserMessage({ content: durable, source }) - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error: unknown) { - if (error instanceof AttachmentError) { - return err(request, { - code: 'attachment-error', - message: error.message, - details: { reason: error.code }, - }) - } - // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached. - return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) + return ok(request, { accepted: true as const }) } - return ok(request, { accepted: true as const }) + return hasImage ? serializeImageAdmission(agent, admit) : admit() }, async attachment(request) { diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index ad70e6b26a..c409eff423 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -302,7 +302,7 @@ describe('session/queued frames', () => { expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames) }) - it('retires mirror entries on their terminal dequeue', async () => { + it('retains each dequeued entry until its durable message publishes', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) @@ -311,15 +311,50 @@ describe('session/queued frames', () => { ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') ctx.emit('agent/inbox/enqueue', agent, steering, 'steering') ctx.emit('agent/inbox/dequeue', agent, queued, 'queued') - ctx.emit('agent/inbox/dequeue', agent, steering, 'steering') + const pendingAbort = new AbortController() + const pending = await collect( + api.events.mux({ rpcId: RpcId('t-mux-dequeued'), payload: {} }, pendingAbort.signal), 3, pendingAbort) + expect(pending.filter(f => f.type === 'session/queued')).toEqual([ + { type: 'session/queued', sessionId: agent.id, message: queued, steering: false }, + { type: 'session/queued', sessionId: agent.id, message: steering, steering: true }, + ]) + + agent.session.append('user/message', queued, { surfaceOp: 'append' }) + ctx.emit('agent/inbox/dequeue', agent, steering, 'steering') + agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' }) const abort = new AbortController() const frames = await collect( api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort) expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0) }) - it('retires the matching placement when one message identity is queued and steering', async () => { + it('retires claimed entries whose admission ends without publication', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const agent = stubAgent(ctx) + const rejected = inboxMessage('m-rejected', 'rejected') + const successor = inboxMessage('m-successor', 'successor') + ctx.emit('agent/inbox/enqueue', agent, rejected, 'queued') + ctx.emit('agent/inbox/dequeue', agent, rejected, 'queued') + ctx.emit('agent/inbox/enqueue', agent, successor, 'queued') + ctx.emit('agent/inbox/dequeue', agent, successor, 'queued') + + const pendingAbort = new AbortController() + const pending = await collect( + api.events.mux({ rpcId: RpcId('t-mux-rejected'), payload: {} }, pendingAbort.signal), 2, pendingAbort) + expect(pending.filter(f => f.type === 'session/queued')).toEqual([ + { type: 'session/queued', sessionId: agent.id, message: successor, steering: false }, + ]) + + ctx.emit('agent/status', agent, 'idle') + const idleAbort = new AbortController() + const idle = await collect( + api.events.mux({ rpcId: RpcId('t-mux-rejected-idle'), payload: {} }, idleAbort.signal), 1, idleAbort) + expect(idle.filter(f => f.type === 'session/queued')).toHaveLength(0) + }) + + it('retires the matching published placement when one message identity is queued and steering', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) @@ -328,6 +363,7 @@ describe('session/queued frames', () => { ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering') ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued') ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering') + agent.session.append('steering/message', { turn: 1, message: repeated }, { surfaceOp: 'append' }) const abort = new AbortController() const frames = await collect( diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 10b3e96e47..450d9cbcf9 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -117,10 +117,26 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false return response.result.value } +function registerTextOnly(ctx: Context): void { + ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) + } + }('Text Only', [])) +} + describe('Web session model selection', () => { it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => { const { ctx, agent, sessionId } = await harness() - const validateImage = vi.fn((_input: { data: Uint8Array }): void => {}) + let secondValidationStarted!: () => void + let releaseSecondValidation!: () => void + const secondStarted = new Promise((resolve) => { secondValidationStarted = resolve }) + const secondReleased = new Promise((resolve) => { releaseSecondValidation = resolve }) + const validateImage = vi.fn(async (input: { data: Uint8Array }): Promise => { + if (input.data[0] !== 2) return + secondValidationStarted() + await secondReleased + }) const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => { return Promise.resolve({ attachmentId: `att-${String(input.data[0])}`, @@ -141,11 +157,15 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' } const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' } - const accepted = await api.sessions.prompt(request({ + const accepting = api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: [first, { type: 'text' as const, text: 'compare' }, second], })) + await secondStarted + expect(saveImage).not.toHaveBeenCalled() + releaseSecondValidation() + const accepted = await accepting expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) @@ -294,13 +314,9 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('refuses a text-only selection once the session log carries an image', async () => { + it('refuses a text-only selection while current derived history carries an image', async () => { const { ctx, sessionId, agent } = await harness() - ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) - } - }('Text Only', [])) + registerTextOnly(ctx) ctx.llm.registerAdapter(['vision'], new class extends CatalogAdapter { override resolveModel(provider: string, model: string): Promise { return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] }) @@ -318,7 +334,7 @@ describe('Web session model selection', () => { content: [{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], } as never, { surfaceOp: 'append' }) - // The log is immutable: a text-only route would fail every later turn. + // The image remains on the current request surface, so a text-only route would fail the next turn. const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', })) @@ -337,31 +353,87 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('refuses a text-only selection while an image prompt is still queued (not yet logged)', async () => { + it('keeps a dequeued image pending until publication, then follows the compacted surface', async () => { const { ctx, sessionId, agent } = await harness() - ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) - } - }('Text Only', [])) + registerTextOnly(ctx) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - // The queued message enters the session log only when claimed — after a - // model switch would already have landed. The pending-inbox mirror must - // therefore gate the switch too. - ctx.emit('agent/inbox/enqueue', agent, { + const queued = { id: 'q-1', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], - } as never, 'queued') - const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) - expect(stranded.result.ok).toBe(false) - // Claiming the message drains the mirror; the log now owns the decision. - ctx.emit('agent/inbox/dequeue', agent, { id: 'q-1' } as never, 'queued') + } as never + ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Dequeue precedes the authoritative append, so it cannot open a switch window. + ctx.emit('agent/inbox/dequeue', agent, queued, 'queued') + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + const imageEvent = agent.session.append('user/message', queued, { surfaceOp: 'append' }) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Publication retires the mirror; once compaction shadows the image, the + // current model-visible surface no longer requires an image-capable route. + agent.session.append('user/message', { + id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' }, + content: [{ type: 'text', text: 'image summarized' }], + } as never, { + surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq }, + sourceEventSeqs: [imageEvent.seq], + }) expect(expectValue(await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain', }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) await ctx.fiber.dispose() }) + it('serializes an image save with a concurrent model selection', async () => { + const { ctx, sessionId, agent } = await harness() + registerTextOnly(ctx) + let saveStarted!: () => void + let releaseSave!: () => void + const started = new Promise((resolve) => { saveStarted = resolve }) + const released = new Promise((resolve) => { releaseSave = resolve }) + const ref = { attachmentId: 'att-race', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + }, + validateImage: () => Promise.resolve(), + saveImage: async () => { + saveStarted() + await released + return ref + }, + } as never) + Object.assign(agent, { + followup(message: UserMessage) { + ctx.emit('agent/inbox/enqueue', agent, message, 'queued') + }, + }) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const prompt = api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' }], + })) + await started + const selection = api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) + expect(await Promise.race([ + selection.then(() => 'settled' as const), + new Promise<'pending'>((resolve) => { setTimeout(() => { resolve('pending') }, 0) }), + ])).toBe('pending') + + releaseSave() + expect((await prompt).result.ok).toBe(true) + expect((await selection).result.ok).toBe(false) + await ctx.fiber.dispose() + }) + it('authorizes an attachment read referenced only from wrapped message content', async () => { const { ctx, sessionId, agent } = await harness() const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 } @@ -369,9 +441,8 @@ describe('Web session model selection', () => { readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }), } as never) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - // The only reference lives inside an assistant/message wrapper — the same - // walk that gates model selection must authorize the read, or a real host - // denies galleries the fixture (with its own authorization mirror) serves. + // The only reference lives inside an assistant/message wrapper; the + // authorization walk must follow that durable event shape. agent.session.append('assistant/message', { turn: 1, step: 0, message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] }, @@ -383,7 +454,7 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => { + it('detects images in wrapped messages and nested tool results on the current surface', async () => { const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } } const cases: { label: string; append: (agent: Agent) => void }[] = [ { @@ -394,14 +465,6 @@ describe('Web session model selection', () => { } as never, { surfaceOp: 'append' }) }, }, - { - label: 'streamed assistant block', - append: (agent) => { - agent.session.append('assistant/chunk', { - turn: 1, step: 0, chunk: { type: 'block-end', index: 0, block: image }, - } as never) - }, - }, { label: 'nested tool-result content', append: (agent) => { @@ -414,11 +477,7 @@ describe('Web session model selection', () => { ] for (const { label, append } of cases) { const { ctx, sessionId, agent } = await harness() - ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { - override resolveModel(provider: string, model: string): Promise { - return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) - } - }('Text Only', [])) + registerTextOnly(ctx) const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) append(agent) const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 3c6c7729c4..394e12a772 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 3cd64b8170ac0f6b6c4316f19bb816c65c223935 -README.zh.md: 478ccb91df996aaf67bd952e95596f4ea65564bc +README.md: 885a2dcbc6fea3c21421d83202941f8251bc3c06 +README.zh.md: d5ea4b80bdc2e0655994667cbd099af7d20aefe9 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 3cd64b8170..885a2dcbc6 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -45,7 +45,7 @@ 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. +Image requests resolve the optional `ctx.attachments` service when the request is dispatched, so Cordis plugin load order does not freeze attachment availability. Image detection and conversion recurse through nested `tool-result` content, so a nested image is neither flattened nor skipped. 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 diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 478ccb91df..d5ea4b80bd 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -45,7 +45,7 @@ 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 -图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。 +图片请求会在请求分发时解析可选的 `ctx.attachments` 服务,因此 Cordis 插件加载顺序不会固化附件可用性。图片检测与转换会递归遍历嵌套的 `tool-result` 内容,因此嵌套图片既不会被展平,也不会被跳过。当该服务或所选模型的图片能力不存在时,视觉请求仍会明确以 `UNSUPPORTED_CONTENT` 失败。 ## 提供方/模型路由与回放 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 2a4b08e607..3b5bb9b9cb 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -33,7 +33,7 @@ import type { import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' -import { toPiContext } from './context.ts' +import { contentHasImage, toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' /** Constructor options for {@link PiAiAdapter}. */ @@ -192,8 +192,7 @@ export class PiAiAdapter extends LlmAdapter { const containsImage = options.messages.some((message) => { // The discriminant is part of same-process message validity and is read before content. void message.role - return message.content.some(block => block.type === 'image' - || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image'))) + return contentHasImage(message.content) }) if (containsImage && !model.input.includes('image')) { throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 90d2176b1c..b1464d7289 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -18,6 +18,23 @@ function flattenText(message: Message): string { .join('') } +/** + * Return whether content contains an image, including nested tool results. + * @param blocks - content to inspect recursively. + * @returns whether any nested block is an image. + */ +export function contentHasImage(blocks: readonly ContentBlock[]): boolean { + return blocks.some(block => block.type === 'image' + || (block.type === 'tool-result' && contentHasImage(block.content))) +} + +/** Flatten text recursively inside one tool result. */ +function toolResultText(blocks: readonly ContentBlock[]): string { + return blocks.map(block => block.type === 'text' + ? block.text + : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') +} + async function userContent( blocks: readonly ContentBlock[], attachments: AttachmentStore, @@ -38,6 +55,14 @@ async function userContent( break } case 'tool-result': + { + const nested = await userContent(block.content, attachments) + if (typeof nested === 'string') { + if (nested.length > 0) content.push({ type: 'text', text: nested }) + } else { + content.push(...nested) + } + } break default: // Other merge-extensible blocks are not user-input vocabulary for pi-ai. @@ -72,8 +97,7 @@ function textOnlyContext(options: GenerateOptions): PiContext { const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { - if (message.content.some(block => block.type === 'image' - || (block.type === 'tool-result' && block.content.some(piece => piece.type === 'image')))) { + if (contentHasImage(message.content)) { throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT') } if (message.role === 'system') { @@ -96,7 +120,7 @@ function textOnlyContext(options: GenerateOptions): PiContext { toolName: toolNames.get(result.toolCallId) ?? 'unknown', content: [{ type: 'text', - text: result.content.filter(block => block.type === 'text').map(block => block.text).join('') || '(no output)', + text: toolResultText(result.content) || '(no output)', }], isError: result.isError ?? false, timestamp: 0, @@ -131,7 +155,7 @@ async function toPiContextWithImages(options: GenerateOptions, attachments: Atta for (const message of options.messages) { if (message.role === 'system') { - if (message.content.some(block => block.type === 'image')) { + if (contentHasImage(message.content)) { throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') } // pi-ai has a single systemPrompt slot; in-history system messages are diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 272135bef9..cde449d34a 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -256,8 +256,8 @@ describe('PiAiAdapter provider routing', () => { mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('not used') + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('not used')) } saveImage(_input: SaveImageAttachment): Promise { @@ -613,8 +613,12 @@ describe('provider profile lifecycle', () => { messages: [createUserMessage({ content: [{ type: 'tool-result', - toolCallId: 'call-image' as never, - content: [{ type: 'image', attachment: IMAGE_REF }], + toolCallId: 'call-outer' as never, + content: [{ + type: 'tool-result', + toolCallId: 'call-inner' as never, + content: [{ type: 'image', attachment: IMAGE_REF }], + }], }], source: { kind: 'plugin', plugin: 'test' }, })], diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index f57cacd13a..f6d55e39da 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -97,6 +97,55 @@ describe('toPiContext', () => { }) }) + it('flattens nested tool-result images into the enclosing result', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 3, + width: 1, + height: 1, + } + const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const context = await toPiContext({ + provider: 'openai', + model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('outer'), + content: [ + { type: 'tool-result', toolCallId: CallId('empty'), content: [] }, + { type: 'text', text: 'before' }, + { type: 'tool-result', toolCallId: CallId('text'), content: [{ type: 'text', text: 'middle' }] }, + { + type: 'tool-result', + toolCallId: CallId('inner'), + content: [ + { type: 'image', attachment }, + { type: 'text', text: 'after' }, + ], + }, + ], + }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }, { readImage } as unknown as AttachmentStore) + + expect(context.messages).toEqual([{ + role: 'toolResult', + toolCallId: 'outer', + toolName: 'unknown', + content: [ + { type: 'text', text: 'before' }, + { type: 'text', text: 'middle' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + { type: 'text', text: 'after' }, + ], + isError: false, + timestamp: 0, + }]) + }) + it('rejects structured image history when no durable resolver is supplied', () => { expect(() => toPiContext({ provider: 'openai', model: 'gpt-4.1', @@ -205,7 +254,15 @@ describe('toPiContext', () => { source: { kind: 'plugin', plugin: 'test' }, }), createUserMessage({ - content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }], + content: [{ + type: 'tool-result', + toolCallId: CallId('c1'), + content: [ + { type: 'text', text: 'Sunny' }, + { type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'text', text: '!' }] }, + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + ], + }], source: { kind: 'plugin', plugin: 'test' }, }), ], @@ -214,7 +271,7 @@ describe('toPiContext', () => { role: 'toolResult', toolCallId: 'c1', toolName: 'get_weather', - content: [{ type: 'text', text: 'Sunny' }], + content: [{ type: 'text', text: 'Sunny!' }], isError: false, timestamp: 0, }) 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 43bd0833d4..fbc39c2dac 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -74,8 +74,8 @@ async function harness(image?: StoredImageAttachment): Promise { mediaTypes: [fixture.ref.mediaType], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('e2e attachment fixture is read-only') + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('e2e attachment fixture is read-only')) } saveImage(_input: SaveImageAttachment): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1515b247c..c135d13219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,6 +708,9 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.0) devDependencies: '@deepseek-ai/dsh-attachment': specifier: workspace:^ @@ -1092,6 +1095,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -6457,6 +6463,9 @@ packages: '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -6996,6 +7005,168 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -10420,6 +10591,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -10434,6 +10610,15 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -11767,6 +11952,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -12079,6 +12269,112 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -15822,6 +16118,8 @@ snapshots: semver@7.8.4: {} + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -15851,6 +16149,39 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.3(@types/node@22.20.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.20.0 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index c843f94a5a..2ee623c606 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -96,8 +96,8 @@ class TestAttachmentStore extends AttachmentStore { mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('test invariant attachment store does not validate images') + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('test invariant attachment store does not validate images')) } saveImage(_input: SaveImageAttachment): Promise { From da039fcb0d653064c9c0b1e74214db543d089e33 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:09:51 +0800 Subject: [PATCH 20/45] test: complete image admission gate coverage --- docs/module-graph.md | 3 ++- packages/attachment/attachment-local/tests/index.spec.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index bcdf0a87e7..cee3f96342 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -374,6 +374,7 @@ flowchart TD pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1043,7 +1044,7 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 7ea71d166b..8aad68d2ff 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -48,6 +48,9 @@ describe('local attachment service', () => { 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', )) + const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 }) + await expect(limited.validateImage({ data: valid, mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined() expect(existsSync(service.root)).toBe(false) } finally { From 0d1250f743b709be080f05757d4705eee8c7b626 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 14:34:08 +0800 Subject: [PATCH 21/45] fix: address ds-review-bot v7 findings on the merged image-input head - gate model selection on steering-placement image carriers from enqueue until their steering/message event publishes; release the gate when an admission ends idle without publication (both behaviorally asserted) - reject session.updateQueue edits carrying non-text blocks at the RPC boundary (queue edits cannot bypass image admission) - extend the durable-directory walk past a first-created DSH_HOME to the deepest pre-existing ancestor - strip Windows-style separators from attachment display names on POSIX - verify attachment reads with a header-only probe (digest already proves the bytes decoded fully at admission); document the read path - make SessionInputShell.addImages refusal observable and keep workspace transfers/composer intake from leaking refused drafts - own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy, pi-ai, compact-basic, and the DeepSeek text-only assertion - drop the redundant canonical-base64 regex and the no-op role read - move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts); document why AttachmentError does not extend HarnessError - document the hard attachments inject in both consumer READMEs --- ...07-29-atomic-web-image-admission.i18n.yaml | 4 +- .../2026-07-29-atomic-web-image-admission.md | 2 +- ...026-07-29-atomic-web-image-admission.zh.md | 2 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 4 +- ...-image-input-and-durable-attachments.zh.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/event-producer-consumer.md | 2 +- .../attachment/attachment-local/src/image.ts | 55 +++++++++++----- .../attachment/attachment-local/src/store.ts | 51 ++++++++++++--- packages/attachment/attachment/src/brand.ts | 15 +++++ packages/attachment/attachment/src/error.ts | 26 ++++++++ packages/attachment/attachment/src/index.ts | 3 +- packages/attachment/attachment/src/types.ts | 31 +-------- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 1 + packages/client/connection/README.zh.md | 1 + .../ui-conversation/src/client/apply.ts | 13 +++- .../src/client/input/contract.ts | 15 +++-- .../src/client/input/facade.ts | 13 ++-- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 20 ++++++ .../tests/terminal-card.spec.tsx | 4 +- .../compact/compact-basic/src/summarizer.ts | 10 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 1 + packages/host/apiproxy/README.zh.md | 1 + packages/host/apiproxy/src/api-proxy.ts | 59 +++++++++++------ .../apiproxy/tests/api-proxy-models.spec.ts | 64 +++++++++++++++++++ packages/llm/llm-deepseek/src/serialize.ts | 9 +-- packages/llm/llm-pi-ai/src/adapter.ts | 9 +-- packages/llm/llm-pi-ai/src/context.ts | 11 +--- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- packages/llm/llm/src/content.ts | 16 +++++ packages/llm/llm/src/index.ts | 1 + 37 files changed, 335 insertions(+), 140 deletions(-) create mode 100644 packages/attachment/attachment/src/brand.ts create mode 100644 packages/attachment/attachment/src/error.ts create mode 100644 packages/llm/llm/src/content.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml index 08398b5440..d5c2e38a49 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md -2026-07-29-atomic-web-image-admission.md: 7b2f7aeb43ca1393cdab3abe35916964bc47f0c9 -2026-07-29-atomic-web-image-admission.zh.md: 5a04d2e599744dfe9f92eb64a6c828161c46bf6e +2026-07-29-atomic-web-image-admission.md: c09d376f101a41994df3a10c22c06da4e59f06f6 +2026-07-29-atomic-web-image-admission.zh.md: 8785f7489b0c433cba43a1747533b1d38aada3d3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md index 7b2f7aeb43..c09d376f10 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md @@ -12,7 +12,7 @@ Image prompt admission and `session.selectModel` each read session modality stat Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint. -The pending-inbox mirror marks a prompt as claimed at dequeue and retains it until the matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the next dequeue or the transition to idle retires the claimed entry; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that mirror plus `Session.deriveMessages()`, which is the current model-visible history after compaction. +The pending-publication set records a queued occurrence at dequeue and a steering occurrence already at enqueue (steering items never enter the queued UI mirror), and retains each until its matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the transition to idle retires the entries; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that set, the queued UI mirror, and `Session.deriveMessages()`, which is the current model-visible history after compaction. Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md index 5a04d2e599..8785f7489b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md @@ -12,7 +12,7 @@ Status: implemented 每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。 -待处理 inbox 镜像会在提示词出队时将其标记为已认领,并保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,下一次出队或转为空闲状态会移除已认领的条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 +待发布集合会在排队条目出队时记录它,而 steering 条目在入队时即被记录(steering 条目从不进入排队 UI 镜像),并各自保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,转为空闲状态会移除这些条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该集合、排队 UI 镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。 diff --git a/.agents/notes/implemented/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 index 04783dff62..dbf7d9ed1f 100644 --- a/.agents/notes/implemented/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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 50ba92ed503126fc26859d7646774a5b25bcc4eb -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 93fff2a550fdcfe013110eab28ddba0a38ec7490 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 1b0e9083e205bb69f8a3ee9e4c073af7864b6ed7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 095420b09ea34e98002480a218f74bc9f8421876 diff --git a/.agents/notes/implemented/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 index 50ba92ed50..1b0e9083e2 100644 --- a/.agents/notes/implemented/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 @@ -122,7 +122,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -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. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)). Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +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. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid, and an admission that ends idle without publication releases the gate. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. 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. @@ -196,7 +196,7 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog ## Testing - 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, bounded HTTP request bodies, image-admission/model-selection races, pending publication, and selection against current derived history after compaction. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction. - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. - Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively 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. diff --git a/.agents/notes/implemented/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 index 93fff2a550..095420b09e 100644 --- a/.agents/notes/implemented/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 @@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md))。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效,而未发布任何事件即转入空闲的准入会释放该门槛。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -196,7 +196,7 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态、待发布状态,以及压缩后依据当前派生历史进行的选择。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 8e16c83c64..63c6e02fc2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -567,7 +567,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:59`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ecb94d05bc..55d0c38c70 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -285,7 +285,7 @@ abstract readImage(ref: ImageAttachmentRef): Promise Types: [ImageAttachmentRef](../core-data-structures/attachment.md) · [SaveImageAttachment](../core-data-structures/attachment.md) · [StoredImageAttachment](../core-data-structures/attachment.md) -Source: [`packages/attachment/attachment/src/index.ts:28`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -852,7 +852,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:192`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:193`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4af0d43cf1..b3513add9a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,7 +31,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index 1ef30358a2..e06bf459df 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -1,6 +1,6 @@ -/** Raster decoding used before bytes enter durable storage. */ +/** Raster inspection: full decode at admission, header-only probe on verified reads. */ -import sharp from 'sharp' +import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' @@ -18,26 +18,47 @@ const MEDIA_TYPES: Readonly> = { gif: 'image/gif', } +async function imageMetadata(image: Sharp): Promise { + const metadata = await image.metadata() + const mediaType = MEDIA_TYPES[metadata.format as string] + if (mediaType === undefined) { + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') + } + return { mediaType, width: metadata.width, height: metadata.height } +} + /** - * Decode a supported raster and return its intrinsic metadata. + * Parse a supported raster's header and return its intrinsic metadata without + * decoding pixels. Digest-verified reads use this: admission already proved + * that these exact bytes decode completely, so the read path only re-derives + * the reference fields instead of paying the full-raster decode again. * @param data - complete encoded image bytes. - * @param maxPixels - optional write-time decoded-pixel limit; reads omit it. * @returns verified format and dimensions. */ -export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { +export async function probeImage(data: Uint8Array): Promise { try { - const image = sharp(data, { failOn: 'error', limitInputPixels: false }) - const metadata = await image.metadata() - const mediaType = MEDIA_TYPES[metadata.format as string] - if (mediaType === undefined) { - throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') - } - const { width, height } = metadata - if (maxPixels !== undefined && width * height > maxPixels) { - throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') - } - await image.raw().toBuffer() - return { mediaType, width, height } + return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false })) + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) + } +} + +/** + * Fully decode a supported raster and return its intrinsic metadata. + * @param data - complete encoded image bytes. + * @param maxPixels - decoded-pixel admission limit. + * @returns verified format and dimensions. + */ +export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { + try { + const image = sharp(data, { failOn: 'error', limitInputPixels: false }) + const detected = await imageMetadata(image) + if (maxPixels !== undefined && detected.width * detected.height > maxPixels) { + throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') + } + await image.raw().toBuffer() + return detected } catch (error) { if (error instanceof AttachmentError) throw error throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index f489ae315c..809cbfe53d 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -2,8 +2,8 @@ import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' -import { basename, dirname, join, resolve } from 'node:path' +import { chmod, link, mkdir, open, readFile, stat, unlink } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' import { AttachmentError, AttachmentId, @@ -14,7 +14,7 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { detectImage } from './image.ts' +import { detectImage, probeImage } from './image.ts' const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ @@ -24,7 +24,11 @@ function digest(data: Uint8Array): string { function displayName(value: string | undefined): string | undefined { if (value === undefined) return undefined - const clean = basename(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) + // Strip both separator styles by hand: a POSIX host treats `\` as an + // ordinary character, so path.basename would keep a Windows client's full + // local path and leak it into the reference and the session log. + const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1) + const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) return clean === '' ? undefined : clean } @@ -79,6 +83,31 @@ async function syncDirectory(path: string): Promise { } } +/** + * Walk up from a preferred boundary to the deepest ancestor that already + * exists. A first save may create DSH_HOME itself (recursive mkdir), and a + * directory this process creates is not durable until its parent entry syncs + * — so only a pre-existing directory may be vouched as the durable stop. + * @param path - preferred absolute boundary. + * @returns `path` when it exists, else its closest existing ancestor. + */ +async function existingBoundary(path: string): Promise { + let level = resolve(path) + while (true) { + try { + await stat(level) + return level + } catch { + // Swallows only the stat probe's failure: a missing (or unreadable) + // level simply moves the boundary up; mkdir later surfaces real errors. + } + const parent = dirname(level) + /* v8 ignore next -- filesystem-root guard: the root directory always exists, so stat returns first. */ + if (parent === level) return level + level = parent + } +} + /** * Create one private directory tree and persist every ancestor entry up to a * caller-vouched durable boundary. The walk deliberately ignores what mkdir @@ -118,10 +147,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') - // The durable boundary is the root's grandparent (DSH_HOME for the + // The preferred boundary is the root's grandparent (DSH_HOME for the // documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be // first-created by a concurrent save, so their entries sync on every path. - const boundary = dirname(dirname(resolve(root))) + // When DSH_HOME itself does not exist yet, the boundary retreats to its + // closest existing ancestor so the first save syncs the new home entry too. + const boundary = await existingBoundary(dirname(dirname(resolve(root)))) await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) @@ -188,8 +219,12 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef): Prom 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 = await inspectMetadata(data, ref.mediaType) - if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) { + // The digest proves these are the exact bytes admission fully decoded, so + // the read path only re-derives the header fields (no raster decode, no + // per-request pixel amplification on history replay). + const metadata = await probeImage(data) + if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes + || metadata.width !== ref.width || metadata.height !== ref.height) { throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') } return { ref, data } diff --git a/packages/attachment/attachment/src/brand.ts b/packages/attachment/attachment/src/brand.ts new file mode 100644 index 0000000000..6df4014f74 --- /dev/null +++ b/packages/attachment/attachment/src/brand.ts @@ -0,0 +1,15 @@ +/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque content-addressed identifier for one immutable attachment object. */ +export type AttachmentId = Branded<'AttachmentId'> + +/** + * Brand a validated storage identifier. + * @param value - backend-produced opaque identifier. + * @returns the branded identifier. + */ +export function AttachmentId(value: string): AttachmentId { + return value as AttachmentId +} diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts new file mode 100644 index 0000000000..827d77f58a --- /dev/null +++ b/packages/attachment/attachment/src/error.ts @@ -0,0 +1,26 @@ +/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */ + +/** + * Stable failures suitable for host RPC error mapping. + * + * Deliberately re-implements the `HarnessError` shape instead of extending it: + * the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this + * package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base + * would create a dependency cycle. Consumers route on `code`, never on the + * prototype chain, so the shapes stay interchangeable at the wire boundary. + */ +export class AttachmentError extends Error { + /** Stable machine-routing failure code. */ + readonly code: string + + /** + * @param message - human-readable failure description without raw bytes or host paths. + * @param code - stable machine-routing code. + * @param options - optional chained cause. + */ + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options) + this.name = 'AttachmentError' + this.code = code + } +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index ebe6ad59c3..9b3b8dd92b 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -8,7 +8,8 @@ import type { StoredImageAttachment, } from './types.ts' -export { AttachmentError, AttachmentId } from './types.ts' +export { AttachmentId } from './brand.ts' +export { AttachmentError } from './error.ts' export type { AttachmentId as AttachmentIdType, ImageAttachmentLimits, diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index c443cb8763..102209553b 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -1,18 +1,8 @@ /** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' +import type { AttachmentId } from './brand.ts' -/** Opaque content-addressed identifier for one immutable attachment object. */ -export type AttachmentId = Branded<'AttachmentId'> - -/** - * Brand a validated storage identifier. - * @param value - backend-produced opaque identifier. - * @returns the branded identifier. - */ -export function AttachmentId(value: string): AttachmentId { - return value as AttachmentId -} +export type { AttachmentId } from './brand.ts' /** Raster image formats accepted by the version-one attachment path. */ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' @@ -56,20 +46,3 @@ export interface StoredImageAttachment { ref: ImageAttachmentRef data: Uint8Array } - -/** Stable failures suitable for host RPC error mapping. */ -export class AttachmentError extends Error { - /** Stable machine-routing failure code. */ - readonly code: string - - /** - * @param message - human-readable failure description without raw bytes or host paths. - * @param code - stable machine-routing code. - * @param options - optional chained cause. - */ - constructor(message: string, code: string, options?: ErrorOptions) { - super(message, options) - this.name = 'AttachmentError' - this.code = code - } -} diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 5b3483dd7a..ccb05f6faa 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26 -README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0 +README.md: 4a4ce324a0482072164d3c4f6badd464bfacfc6f +README.zh.md: 7fb421ae206563a625df5bd53fbcef1f585bd269 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 6f4d8bf15f..4a4ce324a0 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -22,5 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **`attachments` is a hard inject** — the route plugin (and `host-apiproxy`) will not mount until an attachment backend provides `ctx.attachments`, and a composition missing one stalls silently as a cordis inject gap rather than failing loud; text-only deployments therefore still carry the native `sharp` dependency through `attachment-local`. A capability-degraded (image-refusing) mount is deliberate deferred work. - **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open. - **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6ae5291aee..7fb421ae20 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -22,5 +22,6 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust ## 已知限制与暂缓事项 +- **`attachments` 是硬性注入依赖**:路由插件(以及 `host-apiproxy`)在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败;因此纯文本部署也会经由 `attachment-local` 携带原生 `sharp` 依赖。降级为拒绝图片的挂载方式是有意延期的工作。 - **history 的隐式恢复存在争议**:在未附加的会话上打开 history,会在主机侧拉起 agent;纯持久化读取的替代方案记录在 rt-core 协调账本中,P-I 不作改变。该包的消费方会在首次打开时感受到这段延迟。 - **计划移除 `ToolEventView`/`ToolCallView`/`ToolResultView` 的重新导出**:当 toolview 迁移删除主机 `viewFor` 行时,它们会一并移除(呈现属于客户端);在此之前,fixture 保留一份局部 `viewFor` 镜像。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a59bcfd6c5..f157b31619 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -147,8 +147,10 @@ export function apply(ctx: Context): void { next.setDraft(draft) from.setDraft('') } - if (imageIds.length > 0) { - next.addImages(imageIds) + // Transfer only on acceptance: a destination shell mid-submission + // refuses, and the drafts must stay owned (and releasable) by the + // source shell instead of silently leaking their object URLs. + if (imageIds.length > 0 && next.addImages(imageIds)) { for (const id of imageIds) from.removeImage(id) } } @@ -199,7 +201,12 @@ export function apply(ctx: Context): void { addImages: (files) => { try { const images = conversation.createDraftImages(files) - shell.addImages(images.map(image => image.id)) + if (!shell.addImages(images.map(image => image.id))) { + // Refused intake (machineBusy raced a submission): release the + // just-created previews instead of stranding their object URLs. + conversation.releaseDraftImages(images) + return null + } return null } catch (error: unknown) { return error instanceof Error ? error.message : String(error) diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 18215ef41d..69374cac03 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -32,8 +32,12 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void - /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly DraftAttachmentId[]): void + /** + * Append ordered browser-owned draft attachment ids. + * @returns whether the ids were appended; busy admission phases refuse, and + * the caller keeps ownership of refused ids (release or retry them). + */ + addImages(ids: readonly DraftAttachmentId[]): boolean /** Remove one browser-owned draft attachment id. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ @@ -69,8 +73,11 @@ export interface InputService { export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void - /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly DraftAttachmentId[]): void + /** + * Append ordered browser-owned draft attachment ids. + * @returns whether the ids were appended (busy admission phases refuse). + */ + addImages(ids: readonly DraftAttachmentId[]): boolean /** Remove one browser-owned draft attachment id. */ removeImage(id: DraftAttachmentId): void /** Drop ids whose browser objects no longer exist. */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 2d566d7632..0dd7893b71 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session — decision 20). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - addImages: (ids) => { this.addImages(ids) }, + addImages: ids => this.addImages(ids), removeImage: (id) => { this.removeImage(id) }, pruneImages: (ids) => { this.pruneImages(ids) }, submit: (mode) => { this.submit(mode) }, @@ -101,11 +101,16 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) } - /** Append ordered browser-owned draft attachment ids. */ - addImages(ids: readonly DraftAttachmentId[]): void { - if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return + /** + * Append ordered browser-owned draft attachment ids. + * @returns whether the ids were appended (busy admission phases refuse). + */ + addImages(ids: readonly DraftAttachmentId[]): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + if (ids.length === 0) return true this.imageIds = [...this.imageIds, ...ids] this.publish() + return true } /** Remove one browser-owned draft attachment id. */ diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index e220ab7f2e..f21a24b4d7 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -114,7 +114,7 @@ function makeHarness(init?: Partial) { useInput: (() => { throw new Error('unused') }), inputActions: { setDraft: () => {}, - addImages: () => {}, + addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {}, diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 500a687444..0340159851 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -80,7 +80,7 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => {}, + addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {}, @@ -122,7 +122,7 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => {}, + addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {}, diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 4afc2dc81c..acbf35eacc 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -339,6 +339,26 @@ describe('machine pending lock', () => { expect(textarea.readOnly).toBe(true) expect(view.container.querySelector('button[aria-label="Send message"]')!.disabled).toBe(true) }) + + it('addImages reports refusal in busy phases so callers keep draft ownership', () => { + const { shell } = bench() + act(() => { + shell.setDraft('/goal ') + shell.beginCommand( + { + token: '/goal ', + submit: () => new Promise(() => {}), // never settles: stays submitting + }, + { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, + ) + shell.submit('queue') + }) + expect(shell.snapshot.phase).toBe('submitting') + // A refused batch must be observable (the workspace-switch transfer keeps + // the source shell's drafts alive instead of leaking their object URLs). + expect(shell.addImages(['busy-1' as never])).toBe(false) + expect(shell.snapshot.imageIds).toEqual([]) + }) }) describe('decorations', () => { diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 494ae0b498..0a5616bf48 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -395,7 +395,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(sessions)} useWorkspaces={bindSnapshotSelector(workspaces)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} @@ -566,7 +566,7 @@ describe('DetailsPanel Output section', () => { baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => {}, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 1607369fea..d6d3bc8e8b 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 { createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema, } from '@deepseek-ai/dsh-llm' @@ -205,14 +205,8 @@ function finishError(finish: FinishReason): Error | undefined { function summaryText( blocks: readonly ContentBlock[], ): Array> { - if (containsImage(blocks)) { + if (contentHasImage(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/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5609e6db85..7e7720fbeb 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 3badccd4144babc474f52fa44598ddec3c253e65 -README.zh.md: 8ccd5bf95c70c79e968b3ea8d0f6a48c62359ae3 +README.md: 7ee67009c33d9df843e1d5aa71e727c0c798d4a0 +README.zh.md: f7d1503d2947f84dbc21cb74e10678c4e14cb248 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3badccd414..7ee67009c3 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -40,6 +40,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **`attachments` is a hard inject** — the proxy will not mount until an attachment backend provides `ctx.attachments`; a composition missing one stalls silently as a cordis inject gap rather than failing loud (same gap as the connection route; a capability-degraded mount is deferred work). - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8ccd5bf95c..f7d1503d29 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -40,6 +40,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与延期工作 +- **`attachments` 是硬性注入依赖**:代理在附件后端提供 `ctx.attachments` 之前不会挂载;缺少后端的组合会以 cordis 注入缺口的形式静默停滞,而非响亮失败(与 connection 路由是同一缺口;降级挂载属于延期工作)。 - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 617ad9ad5b..4e2ec7bec0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -13,7 +13,7 @@ import type { } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { contentHasImage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' @@ -65,11 +65,11 @@ const DEFAULT_MAX_MESSAGES = 50 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) function decodeBase64(data: string): Uint8Array { - if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) { - throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') - } const decoded = Buffer.from(data, 'base64') - if (decoded.toString('base64') !== data) { + // One canonical-form check: any non-canonical input (whitespace, url-safe + // alphabet, bad padding, truncated groups) fails the exact round-trip, so a + // pre-filter regex over the multi-MiB upload string would be pure overhead. + if (data.length === 0 || decoded.toString('base64') !== data) { throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') } return new Uint8Array(decoded) @@ -147,12 +147,6 @@ function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => b return undefined } -/** True when typed model content contains an image, including nested tool results. */ -function contentHasImage(content: readonly ContentBlock[]): boolean { - return content.some(block => block.type === 'image' - || (block.type === 'tool-result' && contentHasImage(block.content))) -} - /** True when the current model-visible surface contains an image. */ function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean { return messages.some(message => contentHasImage(message.content)) @@ -627,11 +621,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro */ const queuedMirror = new Map() /** - * Claimed-but-unpublished queued occurrences: dequeue is not publication — - * the `user/message` append follows it asynchronously — so an image carrier - * stays a model-selection gate until its durable event lands, its discard - * arrives, or the admission's turn settles idle. Kept apart from the mirror - * so the mux-open snapshot never replays a claimed occurrence as queued. + * Unpublished occurrences that must still gate model selection: a queued + * item from dequeue (claim is not publication — the `user/message` append + * follows asynchronously) and a steering item from enqueue (it never enters + * the queued mirror, and its `steering/message` append is a separate outbox + * hop). Entries retire on their durable event, discard, or idle. Kept apart + * from the mirror so the mux-open snapshot never replays them as queued. */ const pendingPublication = new Map() type UnseenQueueEvent = @@ -692,7 +687,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { - if (item.placement !== 'queued') return + if (item.placement === 'steering') { + // A steering carrier never enters the queued mirror, yet its image + // must gate model selection from enqueue until its steering/message + // event publishes (or the admission ends): the outbox hop between + // steer() and the append is asynchronous, and a text-only switch + // accepted inside it would strand every later turn. + const pending = pendingPublication.get(agent.id) ?? [] + pending.push(item) + pendingPublication.set(agent.id, pending) + return + } const unseen = takeUnseen(agent.id, item.id) if (unseen?.kind === 'terminal') return let entries = queuedMirror.get(agent.id) @@ -726,10 +731,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (retire(agent, item)) publishQueue(agent.id) }), ctx.on('session/event', (session: Session, event: SessionEvent) => { - if (event.type !== 'user/message') return + const id = event.type === 'user/message' + ? event.data.id + : event.type === 'steering/message' + ? (event.data as { message: UserMessage }).message.id + : undefined + if (id === undefined) return const pending = pendingPublication.get(session.id) if (pending === undefined) return - const index = pending.findIndex(entry => entry.message.id === (event.data).id) + const placement = event.type === 'user/message' ? 'queued' : 'steering' + const index = pending.findIndex(entry => entry.message.id === id && entry.placement === placement) if (index === -1) return pending.splice(index, 1) if (pending.length === 0) pendingPublication.delete(session.id) @@ -1385,6 +1396,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro updateQueue(request) { const { sessionId, itemId, action } = request.payload + // Queue edits bypass durablePromptContent (no admission, no durable + // reference, no model-capability recheck), so only text blocks may be + // written through this boundary; image intake is prompt-only. + if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) { + return Promise.resolve(err(request, { + code: 'attachment-error', + message: 'queue edits accept text content only', + details: { reason: 'QUEUE_EDIT_NON_TEXT' }, + })) + } const agent = ctx.agents.get(sessionId) if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') { return Promise.resolve(err(request, { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 6e4302b770..4cf5591d4d 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -387,6 +387,70 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('gates selection on a steering image from enqueue until its event publishes', async () => { + const { ctx, sessionId, agent } = await harness() + registerTextOnly(ctx) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const steering = { + id: 's-1', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: { attachmentId: 'att-s', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], + } as never + const steeringItem = { id: 'i-s-1', message: steering, placement: 'steering' } as never + // A steering carrier never enters the queued mirror, yet the outbox hop + // between steer() and its append must not open a text-only switch window. + ctx.emit('agent/inbox/enqueue', agent, steeringItem) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + ctx.emit('agent/inbox/dequeue', agent, steeringItem) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Publication hands the gate over to the durable surface. + agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' }) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + await ctx.fiber.dispose() + }) + + it('re-opens selection when an admission ends idle without publication', async () => { + const { ctx, sessionId, agent } = await harness() + registerTextOnly(ctx) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const rejected = { + id: 'r-1', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: { attachmentId: 'att-r', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], + } as never + const rejectedItem = { id: 'i-r-1', message: rejected, placement: 'queued' } as never + ctx.emit('agent/inbox/enqueue', agent, rejectedItem) + ctx.emit('agent/inbox/dequeue', agent, rejectedItem) + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + + // Idle proves the admission ended without publication; nothing durable + // requires an image route, so the text-only switch must be accepted again. + ctx.emit('agent/status', agent, 'idle') + expect(expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) + await ctx.fiber.dispose() + }) + + it('rejects a queue edit that injects unadmitted image content', async () => { + const { ctx, sessionId, agent } = await harness() + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + Object.assign(agent, { updateInbox: () => 'applied' }) + const denied = await api.sessions.updateQueue(request({ + sessionId, + itemId: 'i-x' as never, + action: { + kind: 'edit' as const, + content: [{ type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }] as never, + }, + })) + expect(denied.result).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'QUEUE_EDIT_NON_TEXT' } }, + }) + await ctx.fiber.dispose() + }) + it('serializes an image save with a concurrent model selection', async () => { const { ctx, sessionId, agent } = await harness() registerTextOnly(ctx) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index d7db2749c1..c05214ced8 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -7,7 +7,7 @@ * @module dsh-llm-deepseek/serialize */ -import { LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -62,11 +62,8 @@ function flattenText(blocks: ContentBlock[]): string { /** Reject core image content before any text-flattening path can silently erase it. */ function assertTextOnly(blocks: readonly ContentBlock[]): void { - for (const block of blocks) { - if (block.type === 'image') { - throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') - } - if (block.type === 'tool-result') assertTextOnly(block.content) + if (contentHasImage(blocks)) { + throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') } } diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 3b5bb9b9cb..0239273c63 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -33,7 +33,8 @@ import type { import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' -import { contentHasImage, toPiContext } from './context.ts' +import { contentHasImage } from '@deepseek-ai/dsh-llm' +import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' /** Constructor options for {@link PiAiAdapter}. */ @@ -189,11 +190,7 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const containsImage = options.messages.some((message) => { - // The discriminant is part of same-process message validity and is read before content. - void message.role - return contentHasImage(message.content) - }) + const containsImage = options.messages.some(message => contentHasImage(message.content)) if (containsImage && !model.input.includes('image')) { throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') } diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index b1464d7289..678820510e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' @@ -18,15 +18,6 @@ function flattenText(message: Message): string { .join('') } -/** - * Return whether content contains an image, including nested tool results. - * @param blocks - content to inspect recursively. - * @returns whether any nested block is an image. - */ -export function contentHasImage(blocks: readonly ContentBlock[]): boolean { - return blocks.some(block => block.type === 'image' - || (block.type === 'tool-result' && contentHasImage(block.content))) -} /** Flatten text recursively inside one tool result. */ function toolResultText(blocks: readonly ContentBlock[]): string { diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cde449d34a..32888476f6 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -638,7 +638,7 @@ describe('provider profile lifecycle', () => { describe('abort wiring', () => { it('preserves an unknown pre-dispatch adapter Error exactly', async () => { const original = new Error('SDK context conversion exploded') - const message = Object.defineProperty({}, 'role', { + const message = Object.defineProperty({}, 'content', { get() { throw original }, }) const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] }) @@ -656,7 +656,7 @@ describe('abort wiring', () => { it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => { const controller = new AbortController() const original = new Error('conversion lost its caller') - const message = Object.defineProperty({}, 'role', { + const message = Object.defineProperty({}, 'content', { get() { controller.abort('caller cancelled during conversion') throw original diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts new file mode 100644 index 0000000000..19b760a02a --- /dev/null +++ b/packages/llm/llm/src/content.ts @@ -0,0 +1,16 @@ +/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */ + +import type { ContentBlock } from './types.ts' + +/** + * True when typed model content contains an image block, walking nested + * tool-result content. This is the one recursive image walk shared by every + * image policy (capability gating, text-only serialization, compaction + * survey), so a consumer cannot silently diverge on nesting depth. + * @param content - typed model content blocks. + * @returns whether any nested block is an image. + */ +export function contentHasImage(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'image' + || (block.type === 'tool-result' && contentHasImage(block.content))) +} diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 95c7214ee5..b95d966177 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -31,6 +31,7 @@ export * from './brand.ts' export * from './never.ts' export * from './error.ts' export * from './types.ts' +export * from './content.ts' export * from './message.ts' export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' From 4a7d4b812d2ac0e827b6081701e909fe70a25594 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 15:04:34 +0800 Subject: [PATCH 22/45] test: cover probeImage error wrapping and the retreating durable boundary --- .../attachment/attachment-local/tests/image.spec.ts | 11 ++++++++++- .../attachment/attachment-local/tests/store.spec.ts | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index ccfe7f62e7..72a258e2bd 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -1,6 +1,6 @@ import sharp from 'sharp' import { describe, expect, it } from 'vitest' -import { detectImage } from '../src/image.ts' +import { detectImage, probeImage } from '../src/image.ts' async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise { const image = sharp({ @@ -39,4 +39,13 @@ describe('raster decoding', () => { await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 }) await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) }) + + it('probes malformed bytes and unsupported formats into the same stable error', async () => { + await expect(probeImage(Uint8Array.of(1, 2, 3))) + .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const unsupported = await sharp({ + create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).tiff().toBuffer() + await expect(probeImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 5838b8748c..159c4e20b8 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -77,6 +77,14 @@ describe('local attachment store', () => { ]) }) + it('retreats the durable boundary to the closest existing ancestor when the home directory does not exist yet', async () => { + const storageRoot = join(await root(), 'home', 'attachments', 'v1') + + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + it('publishes one private content-addressed object and deduplicates equal bytes', async () => { const storageRoot = await root() const first = await saveImageFile(storageRoot, { From 44d7dc7a737676f5f2539da93fb88824bb79454e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 17:17:25 +0800 Subject: [PATCH 23/45] fix(web): keep image intake inert without a session --- .../client/ui-conversation/src/client/skeleton/InputBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9b6323d40d..5e6dde5cbe 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -293,7 +293,7 @@ export function InputBar({ const onDragOver = (event: DragEvent): void => { if (!event.dataTransfer.types.includes('Files')) return event.preventDefault() - event.dataTransfer.dropEffect = locked || machineBusy ? 'none' : 'copy' + event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy' } const onDragLeave = (event: DragEvent): void => { @@ -307,7 +307,7 @@ export function InputBar({ event.preventDefault() dragDepthRef.current = 0 setDragActive(false) - if (locked || machineBusy) return + if (locked || machineBusy || addImages === undefined) return const dropped = [...event.dataTransfer.files] if (dropped.length === 0) return setDropError(addImages(dropped)) From 310087582321b08baaac0a2324d08dbdb47f8923 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 19:45:41 +0800 Subject: [PATCH 24/45] test(web): remove stale host description override --- packages/host/apiproxy/tests/fetch-carrier.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 5bc389e81c..29a93ba79b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,7 +1,6 @@ import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' -import type { ResponseValue } from '../src/api/rpc-map.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' @@ -12,7 +11,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[] hostFrames: HostFrame[] crashOn: string - hostDescription: ResponseValue<'host.describe'> }> = {}): ApiProxy { const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] @@ -98,7 +96,7 @@ function fakeApi(overrides: Partial<{ rpcId: request.rpcId, result: { ok: true, - value: overrides.hostDescription ?? { version: 'v', cwd: '/w', attachedSessions: 0 }, + value: { version: 'v', cwd: '/w', attachedSessions: 0 }, }, } }, From bbff6670009cb926bbad85578b8cdc9a595041e2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 19:49:33 +0800 Subject: [PATCH 25/45] test(apiproxy): drop the dead hostDescription fake override 363db89ba removed the client-side modality gate and the tests that fed host.describe a scripted activeModel, but left the fakeApi override field, its ResponseValue import, and the ?? fallback behind. Nothing passes hostDescription, so the left arm was unreachable. --- .../host/apiproxy/tests/fetch-carrier.spec.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 5bc389e81c..66c419688e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,19 +1,13 @@ import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' -import type { ResponseValue } from '../src/api/rpc-map.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' /** Minimal in-memory ApiProxy: echoes rpcIds, scripts one frame per stream. */ -function fakeApi(overrides: Partial<{ - muxFrames: MuxFrame[] - hostFrames: HostFrame[] - crashOn: string - hostDescription: ResponseValue<'host.describe'> -}> = {}): ApiProxy { +function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFrame[]; crashOn: string }> = {}): ApiProxy { const muxFrames = overrides.muxFrames ?? [{ type: 'session/subscribed', sessionId: 's1' as never, lastSeq: -1 }] const hostFrames = overrides.hostFrames ?? [{ type: 'host/session-removed', sessionId: 's1' as never }] async function * stream(frames: F[], signal: AbortSignal): AsyncGenerator> { @@ -94,13 +88,7 @@ function fakeApi(overrides: Partial<{ }, host: { async describe(request) { - return { - rpcId: request.rpcId, - result: { - ok: true, - value: overrides.hostDescription ?? { version: 'v', cwd: '/w', attachedSessions: 0 }, - }, - } + return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } }, async pickDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } From 82df6dcc0b4d62b8f1ff4e0f8e3d473b20b05a2b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 20:33:11 +0800 Subject: [PATCH 26/45] fix(cli): avoid duplicate attachment backend --- apps/cli/config/web.cordis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 70ff2915bc..3d7fe06baf 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -80,9 +80,6 @@ # `dshClient` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: - - id: attachment-local - name: '@deepseek-ai/dsh-attachment-local' - - id: session-projection name: '@deepseek-ai/dsh-session-projection' From 8b00482d7ca816e8de9f9e2fff1e30e2f70c39d6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 17:15:08 +0800 Subject: [PATCH 27/45] fix(web): harden multimodal draft and storage lifecycle --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 8 +-- ...-image-input-and-durable-attachments.zh.md | 8 +-- docs/config-catalog.md | 8 ++- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/store.ts | 54 ++++++++----------- .../attachment-local/tests/store.spec.ts | 22 ++++++-- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/index.ts | 18 ++++++- .../client/connection/tests/node-half.spec.ts | 16 +++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 25 +++++---- .../src/client/chat/AssistantMarkdown.tsx | 9 ++-- .../src/client/chat/MessageImage.tsx | 21 ++++---- .../src/client/chat/MessageItem.tsx | 11 ++-- .../ui-conversation/src/client/input/hub.ts | 7 +-- .../ui-conversation/src/client/locales.ts | 28 ++++++++++ .../ui-conversation/src/client/service.ts | 15 +++++- .../src/client/skeleton/ImageLightbox.tsx | 12 +++-- .../src/client/skeleton/InputBar.tsx | 19 ++++--- .../tests/apply-inject.spec.tsx | 41 +++++++++++++- .../tests/message-image.spec.tsx | 17 ++++-- .../tests/service-orchestration.spec.ts | 40 +++++++++++--- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 12 +++-- .../apiproxy/tests/api-proxy-models.spec.ts | 5 ++ 34 files changed, 303 insertions(+), 129 deletions(-) diff --git a/.agents/notes/implemented/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 index dbf7d9ed1f..797625bc2c 100644 --- a/.agents/notes/implemented/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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 1b0e9083e205bb69f8a3ee9e4c073af7864b6ed7 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 095420b09ea34e98002480a218f74bc9f8421876 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a0b109d4151207f89d38125eda2535c73e43057 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 4a3917ea2dae5252b4512c7fa707ef20cca8a7fd diff --git a/.agents/notes/implemented/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 index 1b0e9083e2..6a0b109d41 100644 --- a/.agents/notes/implemented/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 @@ -67,9 +67,9 @@ interface ComposerAttachment { } ``` -This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. +This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A Workspace switch moves a mixed text-and-image draft only when the destination shell accepts the complete image batch; refusal leaves both parts with the source. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -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, atomically published, and made durable with a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. +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. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. 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. @@ -122,7 +122,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -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. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history; compaction can remove old images and make a later text-only selection valid, and an admission that ends idle without publication releases the gate. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +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. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. 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. @@ -140,7 +140,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### 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 enforced by the host before persistence. The client connection carrier independently caps buffered API request bodies, deriving the cap from the host attachment service's aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +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 enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (32 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated image payloads, 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. diff --git a/.agents/notes/implemented/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 index 095420b09e..4a3917ea2d 100644 --- a/.agents/notes/implemented/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 @@ -67,9 +67,9 @@ interface ComposerAttachment { } ``` -这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 +这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时,只有目标外壳接受完整图片批次,图文混合草稿才会移动;拒绝时,文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。服务先写入并同步临时文件,再以原子方式发布,并对发布目录执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标;压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效,而未发布任何事件即转入空闲的准入会释放该门槛。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -140,7 +140,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据宿主附件服务的图片总量限制,加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 32 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2534ce4940..22d2576ff7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -313,10 +313,16 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] + /** + * Maximum buffered JSON body for every `/api` request. This carrier policy + * is independent of image limits but must be large enough for the configured + * aggregate image bytes after base64 and envelope expansion. + */ + maxRequestBodyBytes?: number } ``` -Source: [`packages/client/connection/src/index.ts:24`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:26`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 77b716173f..daa65c2d38 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: 310120bd1c3573da1e7c60334d5c2712195f3186 -README.zh.md: 9fd3a857eca1c25c90b665735f67d2c27c92334a +README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f +README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 310120bd1c..80001b29b3 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -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, an atomic exclusive hard-link publish, and a directory sync on the publication directories (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest 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/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 9fd3a857ec..c3b95ace06 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布目录执行同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。 diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 809cbfe53d..bd33e4c8e3 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -2,8 +2,8 @@ import { createHash, randomUUID } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, link, mkdir, open, readFile, stat, unlink } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' +import { dirname, join, parse, resolve } from 'node:path' import { AttachmentError, AttachmentId, @@ -17,6 +17,7 @@ import type { import { detectImage, probeImage } from './image.ts' const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ +const durableHomes = new Set() function digest(data: Uint8Array): string { return createHash('sha256').update(data).digest('hex') @@ -83,31 +84,6 @@ async function syncDirectory(path: string): Promise { } } -/** - * Walk up from a preferred boundary to the deepest ancestor that already - * exists. A first save may create DSH_HOME itself (recursive mkdir), and a - * directory this process creates is not durable until its parent entry syncs - * — so only a pre-existing directory may be vouched as the durable stop. - * @param path - preferred absolute boundary. - * @returns `path` when it exists, else its closest existing ancestor. - */ -async function existingBoundary(path: string): Promise { - let level = resolve(path) - while (true) { - try { - await stat(level) - return level - } catch { - // Swallows only the stat probe's failure: a missing (or unreadable) - // level simply moves the boundary up; mkdir later surfaces real errors. - } - const parent = dirname(level) - /* v8 ignore next -- filesystem-root guard: the root directory always exists, so stat returns first. */ - if (parent === level) return level - level = parent - } -} - /** * Create one private directory tree and persist every ancestor entry up to a * caller-vouched durable boundary. The walk deliberately ignores what mkdir @@ -134,6 +110,20 @@ async function ensureDurableDirectory(path: string, boundary: string): Promise { + const home = resolve(path) + if (!durableHomes.has(home)) { + await ensureDurableDirectory(home, parse(home).root) + durableHomes.add(home) + } + return home +} + /** * Save and verify immutable image bytes below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. @@ -147,12 +137,10 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') - // The preferred boundary is the root's grandparent (DSH_HOME for the - // documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be - // first-created by a concurrent save, so their entries sync on every path. - // When DSH_HOME itself does not exist yet, the boundary retreats to its - // closest existing ancestor so the first save syncs the new home entry too. - const boundary = await existingBoundary(dirname(dirname(resolve(root)))) + // Establish DSH_HOME itself against the filesystem root once per process. + // Every process performs that proof independently, so observing a directory + // another process created can never be mistaken for durable publication. + const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 159c4e20b8..2332bfe942 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { constants } from 'node:fs' import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, parse, resolve } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' @@ -43,6 +43,17 @@ async function root(): Promise { return join(value, 'attachments', 'v1') } +function parentChainToRoot(path: string): string[] { + const parents: string[] = [] + let level = resolve(path) + const root = parse(level).root + while (level !== root) { + level = dirname(level) + parents.push(level) + } + return parents +} + afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) }) @@ -58,10 +69,11 @@ describe('local attachment store', () => { await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) - // Every level between each created directory and the vouched boundary - // syncs unconditionally — "already existed" is not "already durable" - // when a concurrent first save may have created but not yet synced it. + // Each process first proves DSH_HOME durable all the way to the filesystem + // root; existence alone cannot vouch for a concurrent creator's fsync. + // Later directory creation can then stop at that process-proven boundary. expect(fsControl.syncedDirectories).toEqual([ + ...parentChainToRoot(base), // bucket chain: every parent entry between the bucket and the boundary. objects, storageRoot, @@ -77,7 +89,7 @@ describe('local attachment store', () => { ]) }) - it('retreats the durable boundary to the closest existing ancestor when the home directory does not exist yet', async () => { + it('creates and persists a missing nested home directory against the filesystem root', async () => { const storageRoot = join(await root(), 'home', 'attachments', 'v1') const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 14c1c7fd94..b2a87f4885 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: 8b4a8fddc2fdc48873a4f93c99fce62a3dd17766 -README.zh.md: cac04f5735f30498a301fb1a70748f8d7426dfbf +README.md: 8e8cb23cf56745116142d786ad712d7230f4f2c5 +README.zh.md: dc2416d8b4dafa06f715e60f97e6dcd6d7fc29d9 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 8b4a8fddc2..8e8cb23cf5 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -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 validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. 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 validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. Its independent `maxRequestBodyBytes` config caps every buffered API request (32 MiB default) and must be large enough for the configured aggregate image payload after base64 and envelope expansion; changing image policy does not silently redefine the carrier cap for text and other methods. 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. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index cac04f5735..dc2416d8b4 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。其独立的 `maxRequestBodyBytes` 配置会限制每个缓冲 API 请求(默认 32 MiB),并且必须足以容纳配置的图片总载荷经 base64 和请求封装膨胀后的大小;修改图片策略不会静默地为文本和其他方法重定义载体上限。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 48eca36020..e0fa386057 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -16,6 +16,8 @@ export const name = 'client-connection' /** Headroom for RPC JSON fields around the aggregate base64 image payload. */ const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024 +/** Independent default carrier cap; deployments may raise it for larger valid non-image RPCs. */ +const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024 /** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy', 'attachments'] @@ -31,10 +33,17 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] + /** + * Maximum buffered JSON body for every `/api` request. This carrier policy + * is independent of image limits but must be large enough for the configured + * aggregate image bytes after base64 and envelope expansion. + */ + maxRequestBodyBytes?: number } export const Config: z = z.object({ trustedHosts: z.array(String).default([]), + maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES), }) /** @@ -80,9 +89,16 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) - const maxRequestBodyBytes = Math.ceil( + const requiredImageBodyBytes = Math.ceil( ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3, ) + REQUEST_ENVELOPE_HEADROOM_BYTES + const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES + if (maxRequestBodyBytes < requiredImageBodyBytes) { + throw new Error( + `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ` + + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`, + ) + } const route: WebRoute = { kind: 'prefix', path: API_PATH, diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 98842779c2..3a292d39ad 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -51,7 +51,10 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { +async function mounted(config?: { + trustedHosts?: string[] + maxRequestBodyBytes?: number +}): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) @@ -96,6 +99,17 @@ describe('connection node half', () => { expect(routes).toHaveLength(0) }) + it('fails loud when the independent carrier cap cannot hold the configured image batch', () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + ctx.provide('attachments', fakeAttachments()) + expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) }) + .toThrow(/must be at least .* aggregate image limit/) + expect(routes).toHaveLength(0) + }) + it('refuses an untrusted Host on any /api path before the bridge runs', async () => { const { routes, dispose } = await mounted() const { response, state } = fakeResponse() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 12fe2cb835..0e5dfd698c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: b87d562d9faed9beeb7881f1cf037ce75a046dde -README.zh.md: c2dbec5987fef987d5d89627259a1769ee0a29f3 +README.md: 119984642fb668b6b07dd1eddc5a58e0681906c9 +README.zh.md: 3aed3fdfa0cf5a46780289df96bd975b357835a0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b87d562d9f..119984642f 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store; a mixed text-and-image draft moves only when the destination accepts the complete image batch, otherwise both parts stay with the source. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c2dbec5987..3aed3fdfa0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store;只有目标接受完整图片批次,图文混合草稿才会移动,否则文本和图片都留在来源端。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3a0b3ce2e2..c21362ac69 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -13,7 +13,7 @@ import type { import type { InputNotice } from './input/contract.ts' import { resolveToolPath } from './contract/tool-call-model.ts' import { createChatStore } from './stores.ts' -import { ConversationService } from './service.ts' +import { ConversationService, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' @@ -158,15 +158,17 @@ export function apply(ctx: Context): void { const draft = from.snapshot.draft const imageIds = from.snapshot.imageIds const next = inputHub.shell(nextId) - if (draft !== '') { - next.setDraft(draft) - from.setDraft('') - } // Transfer only on acceptance: a destination shell mid-submission - // refuses, and the drafts must stay owned (and releasable) by the - // source shell instead of silently leaking their object URLs. - if (imageIds.length > 0 && next.addImages(imageIds)) { - for (const id of imageIds) from.removeImage(id) + // refuses the whole mixed draft, which remains owned (and releasable) + // by the source shell instead of splitting text from its images. + if (imageIds.length === 0 || next.addImages(imageIds)) { + if (draft !== '') { + next.setDraft(draft) + from.setDraft('') + } + if (imageIds.length > 0) { + for (const id of imageIds) from.removeImage(id) + } } } sessions.open(nextId) @@ -242,6 +244,11 @@ export function apply(ctx: Context): void { } return null } catch (error: unknown) { + if (error instanceof UnsupportedImageMediaTypeError) { + return t('image.unsupportedType', { + type: error.mediaType || t('image.unknownType'), + }) + } return error instanceof Error ? error.message : String(error) } }, diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index ea6a2f4292..5c44f89819 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -71,8 +71,9 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, loadImage = unavailableImage, time, seq, onFork, t, + blocks, streaming, interrupted, loadImage, time, seq, onFork, t, }: AssistantMarkdownProps) { + const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) @@ -95,7 +96,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) case 'reasoning': return - case 'image': return + case 'image': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return ( @@ -123,7 +124,3 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
) }) - -function unavailableImage(): Promise { - return Promise.reject(new Error('图片读取服务不可用')) -} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx index e3bba25b8f..3f22ff72b8 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { ImageLightbox } from '../skeleton/ImageLightbox.tsx' import css from './MessageImage.module.css' @@ -7,9 +8,10 @@ import css from './MessageImage.module.css' export type ImageLoader = (attachment: ImageAttachmentRef) => Promise /** Compact history renderer with retryable loading and double-click original preview. */ -export function MessageImage({ attachment, load }: { +export function MessageImage({ attachment, load, t }: { attachment: ImageAttachmentRef load: ImageLoader + t: ChatViewSlotProps['t'] }) { const [src, setSrc] = useState(null) const [error, setError] = useState(false) @@ -33,36 +35,37 @@ export function MessageImage({ attachment, load }: { return () => { live = false } }, [attachment, load]) - const label = attachment.name ?? '图片' - if (error) return + const label = attachment.name ?? t('image.label') + if (error) return return ( <> - {open && src !== null && } + {open && src !== null && } ) } /** Wrapping image group shared by user and assistant history. */ -export function ImageGallery({ images, load, align }: { +export function ImageGallery({ images, load, align, t }: { images: readonly { attachment: ImageAttachmentRef }[] load: ImageLoader align: 'start' | 'end' + t: ChatViewSlotProps['t'] }) { if (images.length === 0) return null return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 35ee8fcfd5..80dbbc75ee 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -152,8 +152,9 @@ function projectUserText(text: string): ReactNode { } export const MessageItem = memo(function MessageItem({ - node, loadImage = unavailableImage, retryActive = false, onFork, t, + node, loadImage, retryActive = false, onFork, t, }: MessageItemProps) { + const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': { @@ -161,7 +162,7 @@ export const MessageItem = memo(function MessageItem({ return (
- + {(text !== '' || rest.length > 0) &&
{projectUserText(text)} {rest.map((block, i) => ( @@ -186,7 +187,7 @@ export const MessageItem = memo(function MessageItem({ return (
- +
{t('message.steering')} {projectUserText(text)} @@ -212,7 +213,3 @@ export const MessageItem = memo(function MessageItem({ ) } }) - -function unavailableImage(): Promise { - return Promise.reject(new Error('图片读取服务不可用')) -} diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 92733ccbcc..b040a27b1a 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -162,12 +162,7 @@ export class InputHub implements InputService { // see them — release the drafts here instead of resurrecting them onto // a dead instance where they would leak for the page lifetime. if (this.shells.get(session.sessionId) === shell) { - if (shell?.snapshot.imageIds.length === 0) { - shell.restoreImages(imageIds) - } else { - const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const id of imageIds) conversation?.releaseDraftImage(id) - } + shell?.restoreImages(imageIds) if (shell?.snapshot.draft === '') shell.setDraft(text) return } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 1a9c7a1f34..cde430dd83 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,6 +23,20 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', + 'image.dropHint': '松开以添加图片', + 'image.pending': '待发送图片', + 'image.openOriginal': '双击查看原图', + 'image.openOriginalLabel': '{label},双击查看原图', + 'image.remove': '移除图片 {name}', + 'image.original': '原图', + 'image.label': '图片', + 'image.loadFailed': '图片加载失败,点击重试', + 'image.loading': '图片加载中…', + 'image.preview': '原图预览', + 'image.closePreview': '关闭原图预览', + 'image.serviceUnavailable': '图片读取服务不可用', + 'image.unsupportedType': '不支持的图片格式:{type}', + 'image.unknownType': '未知格式', 'access.confirm.title': '确认启用 Full access?', 'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', 'access.confirm.acknowledge': '我已了解风险,并愿意继续', @@ -117,6 +131,20 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', + 'image.dropHint': 'Drop to add images', + 'image.pending': 'Pending images', + 'image.openOriginal': 'Double-click to view original', + 'image.openOriginalLabel': '{label}, double-click to view original', + 'image.remove': 'Remove image {name}', + 'image.original': 'Original image', + 'image.label': 'Image', + 'image.loadFailed': 'Image failed to load; click to retry', + 'image.loading': 'Loading image…', + 'image.preview': 'Original image preview', + 'image.closePreview': 'Close original image preview', + 'image.serviceUnavailable': 'Image loading service unavailable', + 'image.unsupportedType': 'Unsupported image format: {type}', + 'image.unknownType': 'unknown format', 'access.confirm.title': 'Enable Full access?', 'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', 'access.confirm.acknowledge': 'I understand the risks and want to continue', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 3551f9252a..ee305a9ff1 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -63,6 +63,19 @@ interface ImageUrlEntry { readonly pending: Promise } +/** Unsupported browser-declared image type, localized by the UI boundary. */ +export class UnsupportedImageMediaTypeError extends Error { + /** Browser-declared MIME value, possibly empty. */ + readonly mediaType: string + + /** @param mediaType - Browser-declared MIME value, possibly empty. */ + constructor(mediaType: string) { + super(`unsupported image media type: ${mediaType || '(empty)'}`) + this.name = 'UnsupportedImageMediaTypeError' + this.mediaType = mediaType + } +} + /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ @@ -310,7 +323,7 @@ function imageMediaType(value: string): ImageMediaType { case 'image/gif': return value default: - throw new Error(`不支持的图片格式:${value || '未知格式'}`) + throw new UnsupportedImageMediaTypeError(value) } } diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx index 21c7f9799b..43cbc441a5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx @@ -1,8 +1,14 @@ import { useEffect, useRef } from 'react' +import type { ChatViewSlotProps } from '../contract/slots.ts' import css from './ImageLightbox.module.css' /** Document-level original-image preview opened by an explicit double-click. */ -export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) { +export function ImageLightbox({ src, alt, onClose, t }: { + src: string + alt: string + onClose: () => void + t: ChatViewSlotProps['t'] +}) { const closeRef = useRef(null) const restoreRef = useRef(null) @@ -24,11 +30,11 @@ export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; className={css.backdrop} role="dialog" aria-modal="true" - aria-label="原图预览" + aria-label={t('image.preview')} onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }} > {alt} - +
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 1f16e51112..b5dba5df55 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -478,25 +478,25 @@ export function InputBar({ onDragLeave={onDragLeave} onDrop={onDrop} > - {dragActive &&
松开以添加图片
} + {dragActive &&
{t('image.dropHint')}
} {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {attachments.length > 0 && ( -
+
{attachments.map(attachment => (
- {preview !== null && } + {preview !== null && ( + + )} {footer}
) diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 9806850db2..c9e6957f36 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -23,6 +23,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' import type { createChatStore } from '../src/client/stores.ts' const ROOT = 'root-1' as SessionId @@ -96,11 +97,12 @@ async function bench() { const inputSurface = (id: SessionId) => { const info = runtime.sessions.provideInfo(id)! const state = info.hooks['input'] as { - getSnapshot: () => { draft: string } + getSnapshot: () => { draft: string; imageIds: readonly DraftAttachmentId[] } subscribe: (fn: () => void) => () => void } const actions = info.props['inputActions'] as { setDraft: (text: string) => void + addImages: (ids: readonly DraftAttachmentId[]) => boolean submit: (mode?: 'queue' | 'steer') => void } return { state, actions } @@ -108,7 +110,7 @@ async function bench() { return { runtime, feature, slots: runtime.slots, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface, - sessionFake, layoutFake, + sessionFake, layoutFake, locale, } } @@ -288,6 +290,41 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) + it('keeps a mixed draft together when the destination refuses its images', async () => { + const b = await bench() + const OTHER = 'mixed-target' as SessionId + await b.runtime.sessions.add({ id: OTHER }, { current: false }) + const source = b.inputSurface(ROOT) + const destination = b.inputSurface(OTHER) + const imageId = 'draft-mixed' as DraftAttachmentId + source.actions.setDraft('carry together') + source.actions.addImages([imageId]) + const destinationShell = b.composerSurface(OTHER).keyboard as unknown as { + addImages: (ids: readonly DraftAttachmentId[]) => boolean + } + vi.spyOn(destinationShell, 'addImages').mockReturnValue(false) + b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER)) + + await b.residentSurface(ROOT).selectWorkspace('workspace-mixed' as never) + + expect(source.state.getSnapshot()).toMatchObject({ + draft: 'carry together', + imageIds: [imageId], + }) + expect(destination.state.getSnapshot()).toMatchObject({ draft: '', imageIds: [] }) + await b.runtime.dispose() + }) + + it('localizes browser image-type rejection through the active conversation locale', async () => { + const b = await bench() + b.locale.setLocale('en') + const error = b.composerSurface(ROOT).addImages?.([ + new File([Uint8Array.of(1)], 'vector.svg', { type: 'image/svg+xml' }), + ]) + expect(error).toBe('Unsupported image format: image/svg+xml') + await b.runtime.dispose() + }) + it('scopedConversation fails loud when the session resolves no scope', async () => { const b = await bench() // The chat-view inject resolves the scoped conversation service at inject diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx index 0b521a0f2a..6da4d42f12 100644 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -7,11 +7,12 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { MessageImage } from '../src/client/chat/MessageImage.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) const attachment = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -25,7 +26,7 @@ const attachment = { describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -41,13 +42,23 @@ describe('MessageImage', () => { const load = vi.fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) expect(load).toHaveBeenCalledTimes(2) }) + it('renders image controls from the active English dictionary', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png, double-click to view original' }) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + fireEvent.doubleClick(frame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() + }) + it('keeps assistant images at their original position between text blocks', async () => { const view = render( { expect(created).toHaveBeenCalledTimes(11) const beforeRejectedBatch = created.mock.calls.length - expect(() => { - b.root.createDraftImages([ - new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), - new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), - ]) - }).toThrow('不支持的图片格式:image/svg+xml') + expect(() => b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), + ])) + .toThrow(UnsupportedImageMediaTypeError) expect(created).toHaveBeenCalledTimes(beforeRejectedBatch) } finally { created.mockRestore() @@ -127,6 +126,33 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('restores failed-send images before images added while the request was in flight', async () => { + const b = await bench() + const first = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), + ])[0] + const second = b.root.createDraftImages([ + new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }), + ])[0] + if (first === undefined || second === undefined) throw new Error('draft attachment missing') + const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId) + const request = Promise.withResolvers<{ ok: true; value: { accepted: true } }>() + b.prompt.mockReturnValueOnce(request.promise) + + shell.addImages([first.id]) + shell.setDraft('describe') + shell.submit('queue') + expect(shell.addImages([second.id])).toBe(true) + expect(shell.snapshot.imageIds).toEqual([second.id]) + + request.reject(new Error('transport died')) + await vi.waitFor(() => { + expect(shell.snapshot.imageIds).toEqual([first.id, second.id]) + }) + expect(b.root.draftImages(shell.snapshot.imageIds)).toEqual([first, second]) + await b.runtime.dispose() + }) + it('does not publish a historical image URL after disposal', async () => { let resolveRead!: (result: Awaited>) => void const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise>>( diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e5100fe954..cac8106935 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 09f0f53bec81dd559c8da9c94a6b5459019b4ba2 -README.zh.md: bba5efaa0d9c9486b7367a6c8db069d10b18ffb5 +README.md: 5367795aeb7655f9323ab94d71803049ee452cb0 +README.zh.md: c1507f9c25595d83a63326127779c6240f9cc8a7 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 09f0f53bec..5367795aeb 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -20,7 +20,7 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Selection is serialized with image-bearing prompt admission and rejects a text-only target while an image is pending publication or remains in the current derived history; an image removed by compaction no longer blocks selection. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. -Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. +Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. Image-bearing carriers separately gate text-only model selection until publication or discard: idle without publication retires a claimed queued carrier, but steering retained in the agent outbox remains gated across a failed turn. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index bba5efaa0d..c1507f9c25 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -20,7 +20,7 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。模型选择与包含图片的提示词准入串行执行;当图片正等待发布或仍存在于当前派生历史中时,会拒绝选择纯文本目标;被压缩(compaction)移除的图片不再阻止选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 -待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。含图片的载体会另行约束纯文本模型选择,直到发布或丢弃:未发布即转入空闲时,已认领的 queued 载体会被退役;但保留在 agent outbox 中的 steering 即使跨越失败轮次也仍受门槛约束。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fb878ffd77..db3872b0f8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -877,9 +877,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (changed) publishQueue(agent.id) }), ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { - // Idle proves every claimed admission either published (retired by its - // session event) or ended without one; drop the stale gate carriers. - if (status === 'idle') pendingPublication.delete(agent.id) + if (status !== 'idle') return + const pending = pendingPublication.get(agent.id) + if (pending === undefined) return + // A claimed queued prompt disappears when admission ends without + // publication. Steering instead remains staged in the agent outbox + // across a failed turn, so retain it until steering/message or discard. + const kept = pending.filter(item => item.placement === 'steering') + if (kept.length === 0) pendingPublication.delete(agent.id) + else pendingPublication.set(agent.id, kept) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c05ef5aea2..c41a4e2a58 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -404,6 +404,11 @@ describe('Web session model selection', () => { ctx.emit('agent/inbox/dequeue', agent, steeringItem) expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + // A failed turn returns idle while leaving steering staged in the outbox; + // only publication or discard may retire this carrier. + ctx.emit('agent/status', agent, 'idle') + expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) + // Publication hands the gate over to the durable surface. agent.session.append('steering/message', { turn: 1, message: steering }, { surfaceOp: 'append' }) expect((await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))).result.ok).toBe(false) From 76d20727ab37dcd745030a4cd87094c9e3e1c124 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:44:18 +0800 Subject: [PATCH 28/45] test(web): pin image snapshot locale --- apps/web/tests/image-display.snapshot.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 5689147170..2093b23214 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -55,6 +55,9 @@ let unmount: (() => void) | undefined beforeEach(() => { localStorage.clear() + // Chinese pinned before boot so the localized role/text locators stay + // deterministic across runner browser languages. + localStorage.setItem('dsh.locale', 'zh') document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => From b3b0844444bafb644714a6b2054576ade592712c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:49:33 +0800 Subject: [PATCH 29/45] test(web): follow composed workspace picker --- apps/web/tests/image-display.snapshot.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 2093b23214..c552817424 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -165,17 +165,12 @@ it('renders the history image pair through the authorized attachment route and o }) it('accepts pasted images into the composer rail in order and removes them', async () => { - boot('?fixture=empty') + boot() await screen.findByPlaceholderText('选择一个工作区开始', {}, { timeout: 10_000 }) fireEvent.click(screen.getAllByRole('button', { name: '选择工作区' }) .find(el => el.getAttribute('aria-haspopup') === 'menu')!) - fireEvent.click(await screen.findByRole('menuitem', { name: '新建工作区' })) - const dialog = await screen.findByRole('dialog', { name: '新建工作区' }) - fireEvent.change(within(dialog).getByRole('textbox', { name: '新工作区名称' }), { - target: { value: 'image-input' }, - }) - fireEvent.click(within(dialog).getByRole('button', { name: '创建工作区' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'fixture' })) // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. From ff7917094444491d7c1738fdfad05fdbcb04daea Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 18:58:03 +0800 Subject: [PATCH 30/45] refactor(web): share user content stack --- .../src/client/chat/MessageItem.tsx | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index c4d69da7ce..135bd43062 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -153,6 +153,29 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +function UserContentStack({ parts, imageLoader, steering = false, t, truncated }: { + parts: ReturnType + imageLoader: ImageLoader + steering?: boolean + t: ChatViewSlotProps['t'] + truncated: (total: number) => string +}) { + const { text, images, rest } = parts + const showBubble = steering || text !== '' || rest.length > 0 + return ( +
+ + {showBubble &&
+ {steering && {t('message.steering')}} + {projectUserText(text)} + {rest.map((block, i) => ( + + ))} +
} +
+ ) +} + export const MessageItem = memo(function MessageItem({ node, loadImage, retryActive = false, onFork, t, }: MessageItemProps) { @@ -160,20 +183,12 @@ export const MessageItem = memo(function MessageItem({ const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': { - const { text, images, rest } = contentParts(node.content) + const parts = contentParts(node.content) return (
-
- - {(text !== '' || rest.length > 0) &&
- {projectUserText(text)} - {rest.map((block, i) => ( - - ))} -
} -
+ { onFork(node.seq) }} @@ -184,19 +199,10 @@ export const MessageItem = memo(function MessageItem({ ) } case 'steering': { - const { text, images, rest } = contentParts(node.content) + const parts = contentParts(node.content) return (
-
- -
- {t('message.steering')} - {projectUserText(text)} - {rest.map((block, i) => ( - - ))} -
-
+
) } From cc88018cd40c0bc7a6d7979ed86eb65fdd77682b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 19:30:40 +0800 Subject: [PATCH 31/45] test(web): wait for seeded search inventory --- apps/web/tests/navigation-panes.e2e.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 87e19a6915..60b4596f1f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -98,6 +98,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search')) + // The search input mounts before the asynchronous session inventory. + // Wait for the seeded row so hydration cannot overwrite the query. + await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 }) const search = page.getByPlaceholder('Search name, keywords', { exact: false }) // The cold row has not been opened, so only the persisted log can satisfy // this query. First search lazily reconciles the SQLite content index. From a7e43d4346647546ea0f489566f130e45495d27e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:26:50 +0800 Subject: [PATCH 32/45] refactor: drop the create-by-name workspace route The Web picker collapsed onto the directory flow (see the one-route-to-add-a-workspace Agent Note), leaving workspace.create({ name }) with no product consumer. Delete the whole feed line: the wire schema's name member and WorkspaceApi spelling, the gateway's workspaceRoot config/default and the mkdir branch, the client seam that carried the name (WorkspaceCreateInput, WorkspacesService.create, intentName), the dsh web --workspace-root flag, and the fixture's name handling. workspace-name-conflict stays as workspace.rename's duplicate-title error. --- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 2 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/args.ts | 8 +- apps/cli/src/web.ts | 10 +-- apps/cli/tests/args.spec.ts | 4 +- docs/config-catalog.md | 6 +- packages/bundle/web-app/cordis.patch.yml | 2 +- .../client/connection/src/client/fixture.ts | 9 +- .../client/connection/tests/fixture.spec.ts | 21 ++--- .../runtime/src/client/contract/workspaces.ts | 6 +- .../runtime/src/client/workspaces/manager.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 6 +- .../src/client/workspaces/workspace.ts | 3 +- .../runtime/tests/workspaces-service.spec.ts | 6 +- .../client/test-runtime/src/workspaces.ts | 11 ++- .../test-runtime/tests/runtime.spec.tsx | 6 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 74 +--------------- .../host/apiproxy/src/api/workspace.schema.ts | 10 +-- packages/host/apiproxy/src/api/workspace.ts | 19 ++--- packages/host/apiproxy/src/index.ts | 12 +-- .../apiproxy/tests/api-proxy-approval.spec.ts | 4 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 ++--- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 2 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 1 - .../apiproxy/tests/api-proxy-models.spec.ts | 4 +- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 10 +-- .../tests/api-proxy-workspace.spec.ts | 85 +++++++++---------- .../apiproxy/tests/client-handler.spec.ts | 4 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 6 +- .../todo/tool-todo/tests/projection.spec.ts | 2 +- 44 files changed, 145 insertions(+), 252 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 1c0cc5644d..691511d76b 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 5d002265b5eb1178bb1dbc7bd17f8b364d9b9856 -2026-07-31-one-route-to-add-a-workspace.zh.md: 0a59d3a505eb921b4ec980abaefedfcad8a3c294 +2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 +2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 5d002265b5..853d641e0a 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -27,7 +27,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i ## Wire and CLI residue -The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI. +Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. ## Testing diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 0a59d3a505..6a2884d751 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -27,7 +27,7 @@ Status: implemented ## Wire and CLI residue -Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。 +本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 ## Testing diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..db6a4c6dbe 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: 9adf7e4b238a96c5497c14709ba7ddc08eff13af +README.zh.md: d4f2c607f1b6067c0d936761cb86301950597e1d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..9adf7e4b23 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -37,7 +37,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..d4f2c607f1 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -37,7 +37,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..349a127916 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -40,7 +40,6 @@ interface WebInvocation { host?: string port?: number dev: boolean - workspaceRoot?: string /** Extra authorities for the /api browser-trust fence. */ trustedHosts?: string[] } @@ -62,7 +61,6 @@ interface WebOptions { host?: string port?: string dev?: boolean - workspaceRoot?: string trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean @@ -152,7 +150,6 @@ Examples: .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') @@ -172,8 +169,8 @@ Examples: // dropping them would print a tree that differs from the same // invocation's boot. if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.workspaceRoot !== undefined || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') + || options.trustedHost !== undefined) { + program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') } resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } return @@ -187,7 +184,6 @@ Examples: ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 64051416c4..ca6a53c3e0 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,7 @@ /** * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--workspace-root/ - * --trusted-host`), each flag becoming a patch over the composed profile + * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag + * becoming a patch over the composed profile * tree. All web runtime glue (dist serving, prompt section, URL line) lives * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives * flag patches and the LAN-trust snapshot. @@ -58,7 +58,6 @@ export interface WebFlags { host?: string port?: number dev: boolean - workspaceRoot?: string trustedHosts?: string[] } @@ -82,7 +81,6 @@ function deriveWebFlagPatches( } if (flags.host !== undefined) put('webserver', 'host', flags.host) if (flags.port !== undefined) put('webserver', 'port', flags.port) - if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot) const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) if (trustedHosts.length > 0) { @@ -118,8 +116,8 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean { } /** - * Serve the browser UI from the web profile. Host/port/workspace-root flags - * are passed through only when given (absent, the composed profile values + * Serve the browser UI from the web profile. Host/port flags are passed + * through only when given (absent, the composed profile values * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on * every boot. The URL line is printed by the web-app bundle's runtime row * after Loader settlement. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..66e1a0db40 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -29,8 +29,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) - expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] }) + expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 13ebcc5b32..edb1576689 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -577,18 +577,16 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 624e9e37af..ddb5c97b7b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -4,7 +4,7 @@ # # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--workspace-root/--trusted-host into further patches over these rows +# --dev/--trusted-host into further patches over these rows # (`--dev` inserts the dsh-client-hmr row). # ── surface-specific values the base deliberately omits ───────────────────── diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc2f8c5967..f92a0da870 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2122,15 +2122,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { archivedSessionIds: [...archivedSessionIds], }), create: (request) => { - const { path, name } = request.payload - const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` - const existing = workspaces.find(w => w.path === target) + const { path } = request.payload + const existing = workspaces.find(w => w.path === path) if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) const now = new Date().toISOString() const created: WorkspaceView = { workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path: target, - title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, + path, + title: path.split('/').filter(Boolean).at(-1) ?? path, sessionIds: [], createdAt: now, updatedAt: now, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index f608190936..5b9824fcde 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -546,7 +546,7 @@ describe('createFixtureApi', () => { } })() await new Promise(resolve => setTimeout(resolve, 10)) - const created = await api.workspace.create(req({ name: 'nova' })) + const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.created).toBe(true) expect(created.result.value.workspace).toMatchObject({ @@ -554,16 +554,7 @@ describe('createFixtureApi', () => { }) await consuming expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) - // path spelling falls back to the basename when no title/name rides along. - const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' })) - if (!pathOnly.result.ok) throw new Error('pathOnly failed') - expect(pathOnly.result.value.workspace.title).toBe('base') - // Degenerate spellings reach the impl unfiltered (the fixture carrier has - // no schema gate): both-absent falls back to the bucket dir, and a - // basename-less path serves as its own title. - const bare = await api.workspace.create(req({})) - if (!bare.result.ok) throw new Error('bare failed') - expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' }) + // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') expect(rootPath.result.value.workspace.title).toBe('/') @@ -584,7 +575,7 @@ describe('createFixtureApi', () => { const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) - await api.workspace.create(req({ name: 'occupied' })) + await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) @@ -722,7 +713,7 @@ describe('createFixtureApi', () => { expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) - const made = await api.workspace.create(req({ name: 'nova' })) + const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) @@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) - const workspace = await client.workspace.create({ name: 'via-client' }) + const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') const wsid = workspace.result.value.workspace.workspaceId @@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) const client = new FixtureApiClient() await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await client.workspace.create({ name: 'query-workspace' }) + const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 3e64ef3717..ad896bbdaf 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -27,11 +27,11 @@ export interface IWorkspaces { */ startSession(workspaceId?: WorkspaceId): void /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - create(input: { name: string } | { path: string }): Promise + create(input: { path: string }): Promise /** * Open the Host's native directory picker. * @returns the selected path, or null when the user cancelled. diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ccf0c46fe1..df3aa8fe28 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -120,7 +120,7 @@ export class WorkspaceManager { /** * Create or resolve a real Workspace, then publish its returned snapshot * without waiting for the changed frame. - * @param input - name under workspaceRoot or an existing absolute path. + * @param input - the existing absolute path to adopt. * @returns the wire result. */ async create(input: WorkspaceCreateInput): Promise> { diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 69910fd0c4..c0f71b92db 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces { } /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { const result = await this.manager.create(input) if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts index afa4dd65b6..f6657c7053 100644 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts' import { Notifier } from '../sessions/notifier.ts' /** Host input retained by a local Workspace until materialization succeeds. */ -export type WorkspaceCreateInput = { name: string } | { path: string } +export type WorkspaceCreateInput = { path: string } /** Observable state of a client-local Workspace intent. */ export interface WorkspaceIntentSnapshot { @@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot { } function intentName(input: WorkspaceCreateInput): string { - if ('name' in input) return input.name const trimmed = input.path.replace(/[\\/]+$/, '') return trimmed.split(/[\\/]/).pop() ?? input.path } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 832a1ff71a..dd38a3119c 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -59,7 +59,7 @@ describe('WorkspaceManager', () => { expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) }) - it('creates by name/path, prepends a new row, and folds failures', async () => { + it('creates by path, prepends a new row, and folds failures', async () => { const api = new FakeApiClient() const manager = new WorkspaceManager(api) api.onWorkspaceCreate = payload => Promise.resolve(ok({ @@ -67,8 +67,8 @@ describe('WorkspaceManager', () => { created: true, payload, } as never)) - await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) + await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }]) expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 7e626a3660..9e1061ec8c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -73,18 +73,17 @@ export class TestWorkspaces implements IWorkspaces { /** * Create a Workspace (recorded). The default echoes a view derived from * the input; stub for failure or list-coupled flows. - * @param input - exactly one Host create spelling. + * @param input - the Host create payload. * @returns the created Workspace view. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { this.calls.push({ method: 'create', args: [input] }) const stub = this.stubs.get('create') if (stub !== undefined) return await (stub(input) as Promise) - const title = 'name' in input ? input.name : input.path return { - workspaceId: `ws-${title}` as WorkspaceId, - title, - path: 'path' in input ? input.path : `/${input.name}`, + workspaceId: `ws-${input.path}` as WorkspaceId, + title: input.path, + path: input.path, sessionIds: [], } as unknown as WorkspaceView } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f92d21b4b5..d0b3d75d52 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -559,8 +559,8 @@ describe('workspaces action face', () => { it('records every IWorkspaces verb with inert defaults and honors stubs', async () => { const runtime = await SlotTestRuntime.create() const ws = runtime.workspaces - const created = await ws.create({ name: 'alpha' }) - expect(created.title).toBe('alpha') + const created = await ws.create({ path: '/tmp/alpha' }) + expect(created.title).toBe('/tmp/alpha') const registered = await ws.create({ path: '/tmp/beta' }) expect(registered.path).toBe('/tmp/beta') await expect(ws.pickDirectory()).resolves.toBeNull() @@ -584,7 +584,7 @@ describe('workspaces action face', () => { ws.stub('openPath', () => Promise.resolve()) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) - expect((await ws.create({ name: 'y' })).title).toBe('X') + expect((await ws.create({ path: '/y' })).title).toBe('X') await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..97aad6680c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 1caf9f2ee61fbf36a18b18ff2d1e7e230ee4b7f6 +README.zh.md: c42312bb9972c4c5b02ec4dbc5c7d4402921a9c1 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..1caf9f2ee6 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). ## Contract layer (`/api`) @@ -24,7 +24,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..c42312bb99 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 ## 契约层(`/api`) @@ -24,7 +24,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 19fb0fe8a2..5ad2318945 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' -import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' @@ -333,8 +332,6 @@ export interface ApiProxyDefaults { model: string /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string - /** Parent directory for name-created workspaces. */ - workspaceRoot: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ @@ -668,9 +665,6 @@ class SessionCwdConflict extends Error { } } -/** Host failed before the registry could adopt a name-created directory. */ -class WorkspaceDirectoryCreationError extends Error {} - /** An explicit Host naming operation would duplicate another Workspace title. */ class WorkspaceNameConflictError extends Error { constructor(readonly workspaceName: string) { @@ -1183,29 +1177,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** Resolve or create one path while holding the Host's workspace-create chain. */ - function ensureWorkspace( - path: string, - title: string | undefined, - rejectExistingName = false, - createDirectory = false, - ): Promise<{ workspace: Workspace; created: boolean }> { + function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> { const operation = workspaceCreationChain.then(async () => { - if (rejectExistingName && title !== undefined - && ctx.workspace.list().some(workspace => workspace.title === title)) { - throw new WorkspaceNameConflictError(title) - } - if (createDirectory) { - try { - await mkdir(path, { recursive: true }) - } catch (error: unknown) { - throw new WorkspaceDirectoryCreationError( - `failed to create workspace directory "${path}": ${String(error)}`, - ) - } - } const existing = await ctx.workspace.resolveByPath(path) if (existing !== undefined) return { workspace: existing, created: false } - return { workspace: await ctx.workspace.create(path, title), created: true } + return { workspace: await ctx.workspace.create(path), created: true } }) workspaceCreationChain = operation.then(() => undefined, () => undefined) return operation @@ -2035,54 +2011,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - // Exactly one of path/name arrives (schema refine). Existing-folder - // adoption reuses its canonical path; create-by-name rejects a name - // already present in the registry. - // TODO: the create-by-name branch lost its last product consumer when - // the Web picker collapsed onto the directory flow - // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md). - // Delete it with the wire schema's `name` member, this - // `defaults.workspaceRoot`, the client seam that carried the name - // (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, - // `intentName`'s name branch, the manager's "name under workspaceRoot" - // contract), and the `dsh web --workspace-root` flag plus its apps/cli - // README lines, which exist only to feed it. async create(request) { - const { payload } = request - let path: string - if (payload.name !== undefined) { - const name = payload.name.trim() - if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { - return err(request, { - code: 'workspace-invalid-path', - message: `workspace name must be one non-empty path segment, got "${payload.name}"`, - details: { path: payload.name }, - }) - } - path = join(defaults.workspaceRoot, name) - } else { - path = payload.path as string - } + const { path } = request.payload try { - const name = payload.name?.trim() - const { workspace, created } = await ensureWorkspace( - path, - name, - name !== undefined, - name !== undefined, - ) + const { workspace, created } = await ensureWorkspace(path) return ok(request, { workspace: workspaceView(workspace), created }) } catch (error: unknown) { - if (error instanceof WorkspaceNameConflictError) { - return err(request, { - code: 'workspace-name-conflict', - message: error.message, - details: { name: error.workspaceName }, - }) - } - if (error instanceof WorkspaceDirectoryCreationError) { - return err(request, { code: 'internal', message: error.message, details: {} }) - } // The registry rejects a path that does not resolve to an existing // directory (realpath ENOENT / not-a-directory) — the business // error of the typed-path flow, surfaced as a validation failure. diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 20b3038301..5ad5a0b96b 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({ archivedSessionIds: z.array(sessionIdSchema), }) satisfies z.ZodType>> -/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ +/** workspace.create request payload: the existing directory to adopt. */ export const workspaceCreateRequestSchema = z.object({ - path: z.string().optional(), - name: z.string().optional(), -}).refine( - payload => (payload.path === undefined) !== (payload.name === undefined), - { message: 'workspace.create requires exactly one of path / name' }, -) satisfies z.ZodType>> + path: z.string(), +}) satisfies z.ZodType>> /** workspace.create response value. */ export const workspaceCreateValueSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index d5307e27a8..64feb27f80 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -46,19 +46,14 @@ export interface WorkspaceApi { list(request: RpcRequest<{}>): Promise> /** - * Creates (or idempotently resolves) a workspace. Exactly one of `path` / - * `name` (schema-enforced): `path` registers an EXISTING directory (no - * mkdir — a missing or non-directory path fails with `workspace-invalid-path`); - * `name` is a single path segment the host mkdirs under its default project - * root before registering. Either spelling resolving to a directory already - * owned by a workspace returns that workspace (`created: false`) for the - * existing-folder spelling. Create-by-name rejects an existing title with - * `workspace-name-conflict`; path adoption allows distinct canonical paths - * whose basenames produce the same display title. - * A new name-created workspace uses `name` as both directory name and title; - * a path-created workspace uses the registry's basename title default. + * Creates (or idempotently resolves) a workspace over an EXISTING directory + * (no mkdir — a missing or non-directory path fails with + * `workspace-invalid-path`). A path resolving to a directory already owned + * by a workspace returns that workspace (`created: false`). Adoption allows + * distinct canonical paths whose basenames produce the same display title; + * the registry's basename title default names the new workspace. */ - create(request: RpcRequest<{ path?: string; name?: string }>): + create(request: RpcRequest<{ path: string }>): Promise> /** diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..a649dabccb 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,6 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. */ -import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import type { ApiProxy } from './api/index.ts' @@ -29,20 +28,18 @@ declare module 'cordis' { } } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default - * project directory and the fallback parent for name-created Workspaces. + * project directory. */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ @@ -53,7 +50,6 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - workspaceRoot: z.string(), }) readonly sessions: ApiProxy['sessions'] @@ -71,12 +67,10 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') - const cwd = process.cwd() const api = createApiProxy(ctx, { provider: config.provider, model: config.model, - cwd, - workspaceRoot: resolve(config.workspaceRoot ?? cwd), + cwd: process.cwd(), }) this.sessions = api.sessions this.subagents = api.subagents diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 4833667583..4734d4e457 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4f8637068e..008d35d568 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78a67ef642..14f7905633 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -148,7 +148,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -216,7 +216,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -275,7 +275,7 @@ describe('subagent ownership fence', () => { // instead of answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -316,7 +316,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -362,7 +362,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 1ab33897e3..8b4866eba9 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..e6c799a236 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 83955f2d8b..4ac934e365 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -85,7 +85,6 @@ const api = (ctx: Context) => createApiProxy(ctx, { provider: 'default-provider', model: 'default-model', cwd: '/tmp', - workspaceRoot: '/tmp', }) describe('sessions.fork', () => { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c2dfdae7a7..87ea408bab 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -125,7 +125,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +160,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a1775a8025..c1efc32c97 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index e8eaae813f..c3ae82fe06 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..b3630eafb5 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 57bb05df4f..1160d4dd18 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { provider: 'p', model: 'm', cwd: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index c761484da5..580dfa280c 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -88,7 +88,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', + provider: 'p', model: 'm', cwd: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..0427443841 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index af315ffcd0..b548b36702 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { @@ -102,11 +102,17 @@ async function harness( const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', - cwd: workspaceRoot, - workspaceRoot, + cwd: root, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) - return { api, ctx, storageDomain, workspaceRoot } + return { api, ctx, storageDomain, root } +} + +/** Stage one directory under the harness root for path adoption. */ +function stageDir(root: string, name: string): string { + const path = join(root, name) + mkdirSync(path) + return path } describe('host.pickDirectory', () => { @@ -244,30 +250,26 @@ describe('host.openPath', () => { }) describe('workspace.create', () => { - it('serializes concurrent names and rejects the duplicate', async () => { - const { api, workspaceRoot } = await harness() + it('serializes concurrent creates of one path into a single registration', async () => { + const { api, root } = await harness() + const target = join(root, 'alpha') + mkdirSync(target) const responses = await Promise.all([ - api.workspace.create(request({ name: 'alpha' })), - api.workspace.create(request({ name: 'alpha' })), + api.workspace.create(request({ path: target })), + api.workspace.create(request({ path: target })), ]) - const created = responses.find(response => response.result.ok) - const duplicate = responses.find(response => !response.result.ok) + const values = responses.map(response => expectOk(response)) + const created = values.find(value => value.created) + const resolved = values.find(value => !value.created) - expect(created).toBeDefined() - expect(expectOk(created!)).toMatchObject({ - created: true, - workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' }, - }) - expect(duplicate?.result).toMatchObject({ - ok: false, - error: { code: 'workspace-name-conflict', details: { name: 'alpha' } }, - }) - expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true) + expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } }) + expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) + expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1) }) - it('adopts only existing directories and rejects unsafe names', async () => { - const { api, workspaceRoot } = await harness() - const existing = join(workspaceRoot, 'existing') + it('adopts only existing directories', async () => { + const { api, root } = await harness() + const existing = join(root, 'existing') mkdirSync(existing) const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) @@ -281,21 +283,16 @@ describe('workspace.create', () => { const reopened = expectOk(await api.workspace.create(request({ path: existing }))) expect(reopened.workspace.title).toBe('renamed-existing') - const missing = join(workspaceRoot, 'missing') + const missing = join(root, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) expect(existsSync(missing)).toBe(false) - - for (const name of ['', '.', '..', 'a/b', 'a\\b']) { - const invalid = await api.workspace.create(request({ name })) - expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) - } }) it('adopts different paths that derive the same Workspace title', async () => { - const { api, workspaceRoot } = await harness() - const first = join(workspaceRoot, 'one', 'project') - const second = join(workspaceRoot, 'two', 'project') + const { api, root } = await harness() + const first = join(root, 'one', 'project') + const second = join(root, 'two', 'project') mkdirSync(first, { recursive: true }) mkdirSync(second, { recursive: true }) const firstResult = expectOk(await api.workspace.create(request({ path: first }))) @@ -316,8 +313,8 @@ describe('workspace.create', () => { describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const sessionId = SessionId('session-workspace-preallocated') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -343,8 +340,8 @@ describe('session creation and Workspace membership', () => { }) it('retains a published session when attachment fails and repairs it on retry', async () => { - const { api, ctx } = await harness() - const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const workspace = ctx.workspace.list()[0] if (workspace === undefined) throw new Error('workspace missing from registry') vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) @@ -394,7 +391,7 @@ describe('Host Workspace increments', () => { }) it('streams committed Workspace and Session increments after empty baselines', async () => { - const { api } = await harness() + const { api, root } = await harness() expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) @@ -402,7 +399,7 @@ describe('Host Workspace increments', () => { const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const workspaceIncrement = nextHostFrame(stream) - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace expect(await workspaceIncrement).toMatchObject({ payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, }) @@ -430,7 +427,7 @@ describe('Host Workspace increments', () => { }) it('does not publish a Workspace whose registry-order commit fails', async () => { - const { api, storageDomain } = await harness() + const { api, storageDomain, root } = await harness() const domain = storageDomain.get('workspace') if (domain === undefined) throw new Error('workspace domain is not open') vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) @@ -439,7 +436,7 @@ describe('Host Workspace increments', () => { api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const next = stream.next() - const failed = await api.workspace.create(request({ name: 'ghost' })) + const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') })) expect(failed.result.ok).toBe(false) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) abort.abort() @@ -447,8 +444,8 @@ describe('Host Workspace increments', () => { }) it('deletes the registration, keeps its session and folder, and streams one removal', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace const sessionId = SessionId('session-kept-after-workspace-delete') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -480,8 +477,8 @@ describe('Host Workspace increments', () => { }) it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { - const { api } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const { api, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace const sessionId = SessionId('session-to-archive') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..a11e168f56 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -378,8 +378,8 @@ describe('workspace domain round trip', () => { expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) - it('rejects a create payload violating the exactly-one refine at the handler', async () => { - const response = await client(scriptedApi()).workspace.create({}) + it('rejects a pathless create payload at the handler schema', async () => { + const response = await client(scriptedApi()).workspace.create({} as never) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..4639b5c5ee 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -324,11 +324,9 @@ describe('workspace domain schemas', () => { expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) - it('create requires exactly one of path/name (both refine arms)', () => { + it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') - expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n') - expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/) - expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/) + expect(() => workspaceCreateRequestSchema.parse({})).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index f1932b9955..5bf96a22a1 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, session, From 40ee7f5e27983e313271fa607d138460ab89b6a7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:48:26 +0800 Subject: [PATCH 33/45] docs,test: settle review follow-ups for the create-by-name deletion Rewrite the three sibling Agent Note pairs that still described create-by-name as current (workspace-ui-product-flow, session-list-browsing-and-manual-order, same-basename-workspace-adoption) and the one-route note's own alternative and section title; delete scripts/hero-composer-dom-continuity.mjs, which drove the name dialog removed by the one-route change; mark WorkspaceRegistry.create's now test-only title parameter with a deletion TODO; pin the retired { name } spelling as a schema rejection; align the workspace spec on stageDir and the fixture spec title on path creates. --- ...same-basename-workspace-adoption.i18n.yaml | 4 +- ...-07-31-same-basename-workspace-adoption.md | 4 +- ...-31-same-basename-workspace-adoption.zh.md | 4 +- ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 2 +- ...ssion-list-browsing-and-manual-order.zh.md | 2 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 7 +- ...2026-07-25-workspace-ui-product-flow.zh.md | 7 +- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 4 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 4 +- .../client/connection/tests/fixture.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 6 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + packages/workspace/workspace/src/index.ts | 5 ++ scripts/hero-composer-dom-continuity.mjs | 78 ------------------- 17 files changed, 34 insertions(+), 109 deletions(-) delete mode 100644 scripts/hero-composer-dom-continuity.mjs diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml index 990e7e39bb..e7bdf260be 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md -2026-07-31-same-basename-workspace-adoption.md: ed53804ea64df0d61db16e579c3d65af803dbb97 -2026-07-31-same-basename-workspace-adoption.zh.md: 82cfb7d90afca28f8e666742a758fda0202909f3 +2026-07-31-same-basename-workspace-adoption.md: 1192558632fdc8bd732ea59f0eca5051f49f5d2c +2026-07-31-same-basename-workspace-adoption.zh.md: 9c1f1ffd221936e24300b223ad395e1f44552810 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md index ed53804ea6..1192558632 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md @@ -14,7 +14,7 @@ A Workspace is identified by its stable id and canonical directory path, while i The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation. -Explicit naming remains stricter. `workspace.create({ name })` and `workspace.rename` continue to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). +Explicit naming remains stricter. `workspace.rename` continues to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). The durable schema does not change: Workspace records already store id, path, and title independently, bootstrap can derive equal basenames, and startup validates duplicate paths rather than titles. @@ -30,7 +30,7 @@ Workspace registry and Host API tests create two real directories under differen **Use the full path as every Workspace title.** This removes the collision but makes the primary navigation label unnecessarily long. The full path remains available in the hover detail while the concise basename stays useful. -**Permit collisions from explicit rename and create-by-name operations too.** The registry supports that state, but those operations intentionally ask the user to choose a display name. Retaining their conflict response preserves the existing naming guard without blocking filesystem-selected paths. +**Permit collisions from the explicit rename operation too.** The registry supports that state, but rename intentionally asks the user to choose a display name. Retaining its conflict response preserves the existing naming guard without blocking filesystem-selected paths. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md index 82cfb7d90a..9c1f1ffd22 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md @@ -14,7 +14,7 @@ Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。 -显式命名仍采用更严格的规则。`workspace.create({ name })` 和 `workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 +显式命名仍采用更严格的规则。`workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 持久化 schema 未变:Workspace 记录本就分别存储 id、path 和 title,引导初始化可以派生出相同的 basename,启动校验检查的是重复路径而非重复标题。 @@ -30,7 +30,7 @@ Workspace 注册表与 Host API 测试会在不同父目录下创建两个末级 **将完整路径用作每个 Workspace 的标题。** 这会消除冲突,却使主导航标签不必要地过长。完整路径仍可在悬停详情中查看,而简洁的 basename 仍有价值。 -**也允许显式重命名和按名称创建操作产生重名。** 注册表支持这种状态,但这些操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 +**也允许显式重命名操作产生重名。** 注册表支持这种状态,但该操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index fe955d125d..c8f5bfdce4 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: bd04e7f74c8a4540d68e60ad68965e05de76bce9 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 8ec5f71943a68a70f46fbd4c7702e4556b1892ec +2026-07-25-session-list-browsing-and-manual-order.md: 2894542e7b3b720702c764dbae4112b001e2602c +2026-07-25-session-list-browsing-and-manual-order.zh.md: 81c613a6d2ac738a993f82207ef7c3820cd751f0 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index bd04e7f74c..2894542e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -24,7 +24,7 @@ The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode rend ### workspace.rename -`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with create-by-name, so concurrent explicit naming operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with path adoption and deletion, so concurrent workspace operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. ### Manual order: insertSessionBefore replaces activity pinning diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 8ec5f71943..81c613a6d2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -24,7 +24,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 ### workspace.rename -`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按名称创建共链,并发的显式命名操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按路径收编和删除共链,并发的 Workspace 操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 ### 手动排序:insertSessionBefore 取代活动置顶 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index d8232afa44..c12ab62d53 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 7a6a41e19d2930fbcbf7ba5fc9e6809d96e23166 -2026-07-25-workspace-ui-product-flow.zh.md: a40f374fd794b11bff0de72cbc822fd638cd267c +2026-07-25-workspace-ui-product-flow.md: 9f241562c2d07801b22619c8e1406984bab22aba +2026-07-25-workspace-ui-product-flow.zh.md: 7fca17d32837deede4fb751ca4317582d796e005 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 7a6a41e19d..9f241562c2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -19,13 +19,12 @@ The Host provides the following GUI wiring on the Workspace entity: | RPC | Behavior | | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | -| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). +The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -52,7 +51,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); explicit create-by-name and rename operations retain their duplicate-title checks. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. +A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -108,7 +107,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. -- The UI and Host admit distinct same-basename directories as separate Workspaces, while explicit create-by-name and rename operations reject duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index a40f374fd7..7fca17d328 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -19,13 +19,12 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | RPC | 行为 | | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | -| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 +Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -52,7 +51,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的按名称创建和重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 +新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -108,7 +107,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 -- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的按名称创建和重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 691511d76b..e1a166fd0c 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 -2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 +2026-07-31-one-route-to-add-a-workspace.md: d0a1a820a8eb0a47245d99635b5e1e0448d7eca5 +2026-07-31-one-route-to-add-a-workspace.zh.md: 3486fcdae24a77d3ba82234355ba52d5f43e22d0 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 853d641e0a..d0a1a820a8 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -25,7 +25,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i `WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`. -## Wire and CLI residue +## Wire and CLI follow-up (shipped) Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. @@ -45,7 +45,7 @@ Deleted in the follow-up change this section used to scope: `workspace.create` a **Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now. -**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten. +**Delete the wire's create-by-name branch in the same change.** Rejected for the UI PR: it was backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision was the UI. The deletion shipped as its own follow-up change; the follow-up section above records what it removed. **Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 6a2884d751..3486fcdae2 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -25,7 +25,7 @@ Status: implemented `WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。 -## Wire and CLI residue +## Wire and CLI follow-up (shipped) 本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 @@ -45,7 +45,7 @@ Status: implemented **为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。 -**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。 +**在同一改动中删除 wire 的按名称创建分支。** UI PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而当时紧急的决定是 UI。删除随后作为独立的后续改动落地,上文 follow-up 一节记录了它移除的内容。 **在 e2e scaffold 中经 host 注册 workspace,而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录;只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。 diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 5b9824fcde..6bb6e2e4ab 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -535,7 +535,7 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { const api = createFixtureApi() const abort = new AbortController() const seen: HostFrame[] = [] diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index b548b36702..15b2e47989 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -252,8 +252,7 @@ describe('host.openPath', () => { describe('workspace.create', () => { it('serializes concurrent creates of one path into a single registration', async () => { const { api, root } = await harness() - const target = join(root, 'alpha') - mkdirSync(target) + const target = stageDir(root, 'alpha') const responses = await Promise.all([ api.workspace.create(request({ path: target })), api.workspace.create(request({ path: target })), @@ -269,8 +268,7 @@ describe('workspace.create', () => { it('adopts only existing directories', async () => { const { api, root } = await harness() - const existing = join(root, 'existing') - mkdirSync(existing) + const existing = stageDir(root, 'existing') const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 4639b5c5ee..90cee6e8cd 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -327,6 +327,8 @@ describe('workspace domain schemas', () => { it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') expect(() => workspaceCreateRequestSchema.parse({})).toThrow() + // The retired create-by-name spelling stays a clean schema rejection. + expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 71401862f2..904959c8ec 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -139,6 +139,11 @@ export class WorkspaceRegistry extends Service { * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ + // TODO: `title` lost its last production caller when the gateway's + // create-by-name branch was deleted + // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md); + // drop the parameter with its @param clause and the `create(path, title?)` + // lines in this package's README pair. async create(path: string, title?: string): Promise { const canonical = await realpathNormalize(path) if (!(await stat(canonical)).isDirectory()) { diff --git a/scripts/hero-composer-dom-continuity.mjs b/scripts/hero-composer-dom-continuity.mjs deleted file mode 100644 index ef39052b1b..0000000000 --- a/scripts/hero-composer-dom-continuity.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// Regression drive for the unified hero composer (0729-0357-hero-unify): -// cold start with zero workspaces -> create a workspace -> type. Asserts the -// composer textarea is the SAME DOM node across the disabled->live flip (a -// remount drops the __heroMark marker property) — the session-maybe -// composer.bar contract. -// -// Prereqs: `pnpm run build`, then a fresh server against empty state: -// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \ -// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \ -// --workspace-root $(mktemp -d) -// Run: node scripts/hero-composer-dom-continuity.mjs -// (BASE_URL overrides the target; screenshots land in .artifacts/.) -import { createRequire } from 'node:module' - -// playwright is a devDependency of apps/web only — resolve through its tree. -const require = createRequire(new URL('../apps/web/package.json', import.meta.url)) -const { chromium } = require('playwright') - -const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285' -const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) -page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) }) -page.on('pageerror', err => { console.log('[pageerror]', err.message) }) - -await page.goto(BASE) -await page.waitForSelector('textarea', { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '01-cold-start.png' }) - -const initial = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i }) - return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder })) -}) -console.log('cold-start textareas:', JSON.stringify(initial)) - -// Open the picker and create a workspace by name (typed-input flow). The name -// must be unique per registry; keystrokes go through pressSequentially so the -// dialog's React onChange enables the submit button. -await page.getByRole('button', { name: 'Choose workspace' }).click() -await page.getByText('Create a new workspace').click() -await page.screenshot({ path: SHOTS + '03-create-form.png' }) -const nameBox = page.getByPlaceholder('Workspace name') -await nameBox.click() -const wsName = 'proj-' + Date.now().toString(36) -await nameBox.pressSequentially(wsName, { delay: 30 }) -await page.locator('button:text-is("Create workspace")').click() - -// Wait for the composer to go live (placeholder flips, textarea enabled). -await page.waitForFunction(() => { - const box = document.querySelector('textarea') - return box !== null && !box.disabled -}, { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '04-live.png' }) - -const after = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - return boxes.map(b => ({ - mark: b.__heroMark ?? 'REMOUNTED', - disabled: b.disabled, - placeholder: b.placeholder, - })) -}) -console.log('post-pick textareas:', JSON.stringify(after)) - -// Type into the live composer. -await page.locator('textarea').first().fill('hello from acceptance run') -const typed = await page.evaluate(() => document.querySelector('textarea')?.value) -console.log('typed value:', JSON.stringify(typed)) -await page.screenshot({ path: SHOTS + '05-typed.png' }) - -const survived = after.length === 1 && after[0].mark === 'alive-0' -console.log(survived - ? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)' - : 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after)) -await browser.close() -process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1) From b7fe3de8f184cdcec5ab20eb7205660368e4038a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:54:07 +0800 Subject: [PATCH 34/45] chore(knip): drop the stale playwright ignore for the deleted hero script The root-scripts workspace ignore existed only for scripts/hero-composer-dom-continuity.mjs; apps/web declares its own playwright dependency for the e2e lane. --- knip.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/knip.json b/knip.json index 6dc4b56dcd..fbf456d3b9 100644 --- a/knip.json +++ b/knip.json @@ -27,9 +27,6 @@ "scripts/**/*.ts", "scripts/**/*.mjs", "scripts/**/*.cjs" - ], - "ignoreDependencies": [ - "playwright" ] }, "examples": { From 5ed934935a564eaf5806ee8c102c92ede8fe599d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:00:28 +0800 Subject: [PATCH 35/45] test(llm): follow credential resolver after master merge --- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 90a757410b..3e0bed6c8b 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -236,7 +236,7 @@ describe('PiAiAdapter provider routing', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { - providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } }, + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) await ctx.plugin(LateAttachmentStore) From da05e5f058ff897bbf7e302ede2be97cd6a15605 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:07:54 +0800 Subject: [PATCH 36/45] test(gui): complete merged image input checks --- docs/module-graph.md | 57 ++++++++++++------- .../ui-conversation/tests/input-bar.spec.tsx | 8 +-- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index d963273363..01c601fd6b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -151,6 +151,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_bundle["packages/bundle"] pkg_base["base"] pkg_headless["headless"] @@ -320,9 +324,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -351,12 +354,32 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths + pkg_credentials_local --> pkg_atomic_write + pkg_credentials_local --> pkg_credentials + pkg_credentials_local --> pkg_environment + pkg_credentials_local --> pkg_invariants + pkg_credentials_local --> pkg_paths + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_environment pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_settings pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment pkg_llm_pi_ai --> pkg_credentials pkg_llm_pi_ai --> pkg_environment pkg_llm_pi_ai --> pkg_invariants @@ -373,23 +396,11 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry - pkg_credentials_local --> pkg_atomic_write - pkg_credentials_local --> pkg_credentials - pkg_credentials_local --> pkg_environment - pkg_credentials_local --> pkg_invariants - pkg_credentials_local --> pkg_paths pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write - pkg_settings_local --> pkg_invariants - pkg_settings_local --> pkg_paths - pkg_settings_local --> pkg_settings pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -1044,6 +1055,8 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1188,7 +1201,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -1201,16 +1214,18 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1335,7 +1350,7 @@ flowchart TD | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 8aa3c74436..473585f282 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -501,7 +501,7 @@ describe('running and lock semantics (queue cut 1)', () => { } // Pasted text lands below the fold: scroll down by exactly the overshoot. caretAt(500) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } }) await settle() expect(scroll.scrollTop).toBe(88) // 524 - 436 // Measured on the mirror's own text, at the index the paste left the caret @@ -510,12 +510,12 @@ describe('running and lock semantics (queue cut 1)', () => { expect(measured!.offset).toBe('pasted'.length) // A caret already inside the box does not move it. caretAt(200) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } }) await settle() expect(scroll.scrollTop).toBe(88) // Above the fold (a cut can leave it there): scroll back up. caretAt(60) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } }) await settle() expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60) // A caret straight after a newline has nothing on its line to measure, so @@ -523,7 +523,7 @@ describe('running and lock semantics (queue cut 1)', () => { // chromium reports no client rects at all for the collapsed position. mirror.style.lineHeight = '24px' caretAt(500) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } }) await settle() // The four pastes accumulate at the draft's head, so the caret is at the // end of what they inserted — and the measured index is the newline before it. From ede9020e18f64ffbbb0862703cf00a177da39707 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:20:09 +0800 Subject: [PATCH 37/45] test(gui): stabilize merged snapshot gates --- apps/web/tests/subagent-conversation.e2e.ts | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../src/client/skeleton/InputBar.module.css | 70 ------------------- 3 files changed, 2 insertions(+), 72 deletions(-) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 0049cb2791..99bcac0525 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -461,7 +461,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe(3) expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0) const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) - expect(await hierarchy.getByRole('button').count()).toBe(1) + await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1) await compareOrRefreshGolden( FORK_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 12a88365a6..0a062820e0 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 63c9392cb7..275c210850 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -121,25 +121,6 @@ pointer-events: none; } -.dragActive { - border-color: var(--dsw-alias-state-business-primary); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); -} - -.dropHint { - position: absolute; - z-index: 2; - inset: 4px; - display: grid; - place-items: center; - border-radius: 16px; - background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary)); - color: var(--dsw-alias-state-business-primary); - font-size: 14px; - font-weight: 600; - pointer-events: none; -} - .accessory { display: flex; align-items: center; @@ -198,57 +179,6 @@ cursor: pointer; } -.attachments { - display: flex; - gap: 8px; - min-width: 0; - padding: 12px 12px 0; - overflow-x: auto; - overflow-y: hidden; -} - -.attachment { - position: relative; - flex: 0 0 72px; - width: 72px; - height: 72px; -} - -.thumbnail { - width: 72px; - height: 72px; - padding: 0; - overflow: hidden; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-interactive-bg-hover); - cursor: zoom-in; -} - -.thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.remove { - position: absolute; - top: -6px; - right: -6px; - display: grid; - place-items: center; - width: 22px; - height: 22px; - padding: 0; - border: 1px solid var(--dsw-specific-input-major); - border-radius: 999px; - background: var(--dsw-alias-label-primary); - color: var(--dsw-specific-input-major); - font-size: 16px; - line-height: 1; - cursor: pointer; -} - /* Floating overlay anchor (menu / popupSelect shell): entries position themselves against the card (bottom: 100% + gap); closed entries render null. */ .overlayAnchor { From b77bbb152628ed3335f23a30ac91944468d6dfdf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 23:38:37 +0800 Subject: [PATCH 38/45] docs(packages): keep package index within budget --- packages/README.i18n.yaml | 4 ++-- packages/README.md | 6 +++--- packages/README.zh.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 6c17ddfe4d..6f0a6c6d4a 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: df071b80abff37e0892f0bec444869e33af78de6 -README.zh.md: f3fa2946218c4fe541c115c5cdfb5bf2e6cd4985 +README.md: 5438ff19e1ac6e5c75667b070f0bfa26efa38dd5 +README.zh.md: 83f1424ce07b0f2652392e6e51fbd26a8662a95d diff --git a/packages/README.md b/packages/README.md index df071b80ab..5438ff19e1 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). +npm scope: `@deepseek-ai/dsh-*`; Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Rules: [package](AGENTS.md), [root](../AGENTS.md#conventions). ## Hierarchy @@ -31,13 +31,13 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | -| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, and local content-addressed storage | Product — stable surface | +| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | -| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface | +| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index f3fa294621..83f1424ce0 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 +npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 ## 层级结构 @@ -31,13 +31,13 @@ | [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 | | [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 | | [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 | -| [`attachment/`](attachment/README.md) | 持久附件标识、校验与本地内容寻址存储 | 产品:稳定接口 | +| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定接口 | | [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 | -| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 | +| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查、模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md))、受限仓库插件加载 | 产品:稳定接口 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 | | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 | From b5934b6f7ce7793c149c2d35f06d31a47e2d2e66 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:04:44 +0800 Subject: [PATCH 39/45] cleanup(web): remove the steering interjection caption Steering bubbles render as plain user bubbles; a mid-turn steer is recognizable by its position in the flow. The runtime SteeringMessageNode projection and pending-steering lifecycle are unchanged. Partially supersedes the 2026-08-04 context-source and steer marks note; the new simplification note owns the removal rationale. --- ...b-context-source-and-steer-marks.i18n.yaml | 4 +-- ...8-04-web-context-source-and-steer-marks.md | 5 +-- ...4-web-context-source-and-steer-marks.zh.md | 5 +-- ...ve-steering-interjection-caption.i18n.yaml | 6 ++++ ...eb-remove-steering-interjection-caption.md | 36 +++++++++++++++++++ ...remove-steering-interjection-caption.zh.md | 36 +++++++++++++++++++ .../plan-review/approved.expected.md | 2 +- .../snapshots/steering/mid-steer.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.module.css | 9 ----- .../src/client/chat/MessageItem.tsx | 12 ++----- .../ui-conversation/src/client/locales.ts | 2 -- .../tests/chat-branch-tails.spec.tsx | 3 +- .../ui-conversation/tests/chat-view.spec.tsx | 4 --- 17 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index cdcbcd8d4e..6bc552736e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: d74badedaa9623014dbba10ae68ca680daf75de1 -2026-08-04-web-context-source-and-steer-marks.zh.md: b2fd990f6f868fdf422ba97ddd25d19705041e3a +2026-08-04-web-context-source-and-steer-marks.md: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb +2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index d74badedaa..0285f3ef1d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -37,11 +37,12 @@ The transcript names all three roles a non-prompt message can play — injected ## Testing - `packages/client/runtime` unit coverage pins each source kind, the label fallbacks when a name field is missing, empty, or wrongly typed, the unnamed degradation for a source with no readable kind, and steering reconstruction on reset and live append paths. -- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. -- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, and the roleless header. +- The keyless assembled-Web goldens carry the named header, so the assembled transcript — not only component tests — proves the marks. ## Consequences +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index b2fd990f6f..872ab30d72 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -37,11 +37,12 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 测试 - `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退、来源没有可读 kind 时的无名降级,以及 reset 和实时 append 路径上的 steering 重建。 -- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 -- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存,以及无名时的标题形态。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 ## 后果 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml new file mode 100644 index 0000000000..7194db5780 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md +2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md new file mode 100644 index 0000000000..2c396f5494 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +English | [中文](2026-08-10-web-remove-steering-interjection-caption.zh.md) + +## Problem + +The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) captioned every durable and pending steering bubble with `插话` / `Interjection` so the transcript could say which right-aligned bubble interrupted a running turn. The caption repeats what the flow already shows: a steering bubble sits mid-turn, between the assistant content it interrupted, while a turn-opening prompt sits at a turn boundary. A permanent line of tertiary text above every steer bubble buys no reading a position-aware reader does not already have, and it is the only chrome any user-style bubble carries, so it also breaks the otherwise uniform right-aligned rhythm. + +## Decision + +Steering renders exactly as a user bubble. `UserStyleBubble` has no steering flag, the `message.steering` locale key and the `.steeringMark` style are deleted, and `PendingSteeringBubble` and `UserMessageNodeView` pass only content and actions. A mid-turn steer is recognizable by its position inside the running turn's flow, and by nothing else. + +The runtime distinction is untouched. `SteeringMessageNode` projection from durable `agent/inbox/spliced` history, the `data-pending-steering` attribute, and the pending-to-durable hand-off all remain: the pending lifecycle needs the node identity regardless of presentation, and tests still locate pending bubbles through the attribute. + +This partially supersedes the steering clause of the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md); its context-source and recall naming stays current. The caption has flipped before: the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) removed it while the composer could not steer, and the 2026-08-04 decision reintroduced it after the composer gained a Steer gesture. This removal does not revisit the gesture — steering entry, the Queue dock's steer-send action, and the pending lifecycle keep their owners — it judges only that the transcript need not name the result. + +## Alternatives considered + +**Keep the caption.** It is the status quo and cheap to keep, but it decorates every steer bubble forever to encode a fact the bubble's position already states. Chrome that carries no information a reader lacks is removed, not maintained. + +**Remove the `SteeringMessageNode` distinction too.** The node kind is derived from durable inbox history and drives the pending-to-durable hand-off; it is a replay fact, not presentation. Folding it into `UserMessageNode` would change projection behavior for no UI gain. + +**Distinguish steering with quieter chrome (tint, indent, hover-only label).** Any replacement re-raises the same question with a weaker vocabulary. The distinction the transcript needs is positional and already visible; adding subtler decoration keeps the cost and loses the one virtue the text caption had, being explicit. + +## Testing + +- `packages/client/ui-conversation` jsdom coverage pins the plain bubble: the pending hand-off test locates pending bubbles by `data-pending-steering` and asserts the single-bubble hand-off without any caption, and the MessageItem steering arm asserts copy-without-branch on an uncaptioned bubble. +- The keyless assembled-Web goldens (`steering/mid-steer`, `steering/settled`, `plan-review/approved`) replay the unchanged session fixtures with no caption text. + +## Consequences + +- A replayed transcript no longer names steering: a reader infers a mid-turn interjection from its position inside the turn. That inference is weaker than an explicit label for a reader skimming turn boundaries; the decision accepts this. +- A pending steer bubble is visually identical to an ordinary sent bubble until admission; only its missing clock time differs. +- Reintroducing steering chrome of any form requires a new product decision superseding this note. diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md new file mode 100644 index 0000000000..85d76e977a --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +[English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 + +## Problem + +[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 + +## Decision + +steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 + +运行时的区分保持不变。从持久 `agent/inbox/spliced` 历史投影 `SteeringMessageNode`、`data-pending-steering` 属性、待处理到持久的交接全部保留:待处理生命周期无论呈现如何都需要节点身份,测试也仍通过该属性定位待处理气泡。 + +本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 + +## Alternatives considered + +**保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 + +**连 `SteeringMessageNode` 区分一起删。** 节点类型派生自持久 inbox 历史,驱动待处理到持久的交接;它是回放事实,不是呈现。把它并入 `UserMessageNode` 会改变投影行为,却没有任何 UI 收益。 + +**换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 + +## Testing + +- `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 +- 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 + +## Consequences + +- 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 +- 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 +- 重新引入任何形式的 steering 装饰都需要一个取代本 note 的新产品决策。 diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c1cae54bb5..f6ef729c79 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5f3f24f709..c60e8cff4f 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -21,7 +21,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d598613fa3..bfb72ade1f 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -19,7 +19,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6c197a5107..dc20ef5753 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: b57f88b5a030a6c20c957e26ea32fb125f106ab4 -README.zh.md: a8a8c4814086cad02c5416078f242ec92a7503d7 +README.md: 1def427a0d6c2d27e0adc7fbfa4a1185bc6b0b57 +README.zh.md: d603e8a337f934f8da9f136f0d42814bb1108acc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b57f88b5a0..1def427a0d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a8a8c48140..d603e8a337 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index c6ca35bb2c..6c86b39e4f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,15 +8,6 @@ gap: 6px; } -/* Steering caption above the bubble: mid-turn interjections carry the same - bubble as a turn-opening prompt, so the transcript names which one this is. */ -.steeringMark { - padding-right: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 16px; -} - .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ max-width: min(525px, 82%); diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0d85e85e8..49342537ef 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,7 +1,6 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy IconActions; steering adds the -// interjection caption that names it; branch lives only under assistant -// answers), pending steering (caption + copy only), context injection, +// (right-aligned, with clock + copy IconActions; branch lives only under +// assistant answers), pending steering (copy only), context injection, // compaction marker, retry disclosure, and unknown-surface JSON rows. import { memo, useEffect, useMemo, useState } from 'react' @@ -151,22 +150,19 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, steering = false, t, + content, actions, pending = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean - /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ - steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return (

- {steering && {t('message.steering')}}
{projectUserText(text)} {rest.map((block, i) => )} @@ -190,7 +186,6 @@ export function PendingSteeringBubble({ content, t }: { ( ( { expect(vi.getTimerCount()).toBe(0) }) - it('consumed steering is captioned as an interjection and keeps copy without branch', () => { + it('consumed steering renders as a plain user bubble and keeps copy without branch', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -242,7 +242,6 @@ describe('MessageItem arms', () => { } as never} />, ) - expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6b2072ddfb..4078edfe94 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -459,9 +459,6 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() - // Pending and durable steering carry the same interjection caption, so the - // hand-off does not change what the row says it is. - expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -483,7 +480,6 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() - expect(view.getAllByText('插话')).toHaveLength(1) // Only the durable steering bubble: the turn is still running, so its // assistant narration owns no footer yet, and a steering bubble never // carries a branch action. From 6a72a0873c2d96735c276a74e944946b4e091de9 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:08:35 +0800 Subject: [PATCH 40/45] fix(ci): repair fixture turns and module graph --- apps/web/tests/image-display.snapshot.ts | 4 +- apps/web/tests/todo-row.snapshot.ts | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 211 ++++++++++-------- docs/module-graph.zh.md | 211 ++++++++++-------- .../client/connection/src/client/fixture.ts | 22 +- 6 files changed, 242 insertions(+), 212 deletions(-) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 777cb2236a..6546a79f1b 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Multimodal image surfaces over the BUILT client graph (the code-mode-fixture // idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). -// Opens the fixture history session whose turn 71 carries an image in BOTH a +// Opens the fixture history session whose turn 72 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized // sessions.attachment route, the double-click ImageLightbox, and the composer @@ -12,7 +12,7 @@ import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' installAssembledBootEnv() -/** Open the fixture history session (the alpha log carrying the turn-71 image pair) and wait for its gallery. */ +/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */ async function openFixtureSession(): Promise { const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) const group = (await within(tree).findAllByText('fixture')) diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index c5ded143b3..ff43cee39b 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 61e55cd997..4c48dc33f0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 -module-graph.zh.md: 28185c255ffa18f3ebc02f177d20594af3356164 +module-graph.md: f9ba64410ef97b12f84a7673c6efaccaa3403a9f +module-graph.zh.md: 3f05d7e03907ebebebacfa5b6eec7312717ddf2b diff --git a/docs/module-graph.md b/docs/module-graph.md index a248ed4fcb..f9ba64410e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -125,6 +125,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] end @@ -319,9 +323,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -357,33 +360,16 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_environment - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_environment - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_type_meta - pkg_system_prompt --> pkg_invariants - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants - pkg_skill --> pkg_llm - pkg_web --> pkg_invariants - pkg_web --> pkg_llm + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -412,15 +398,70 @@ flowchart TD pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout + pkg_session --> pkg_brand + pkg_session --> pkg_invariants + pkg_session --> pkg_llm + pkg_session --> pkg_scope + pkg_session --> pkg_type_meta + pkg_system_prompt --> pkg_invariants + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm + pkg_web --> pkg_invariants + pkg_web --> pkg_llm + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write - pkg_settings_local --> pkg_invariants - pkg_settings_local --> pkg_paths - pkg_settings_local --> pkg_settings pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -455,40 +496,24 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -547,10 +572,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -561,16 +582,10 @@ flowchart TD pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs pkg_fs_e2b --> pkg_invariants - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -692,10 +707,6 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -1001,6 +1012,8 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1212,7 +1225,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | @@ -1227,22 +1240,30 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1253,13 +1274,10 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1275,12 +1293,10 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1307,7 +1323,6 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1358,7 +1373,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 28185c255f..3f05d7e039 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -127,6 +127,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] end @@ -321,9 +325,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -359,33 +362,16 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_environment - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_environment - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_type_meta - pkg_system_prompt --> pkg_invariants - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants - pkg_skill --> pkg_llm - pkg_web --> pkg_invariants - pkg_web --> pkg_llm + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -414,15 +400,70 @@ flowchart TD pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths + pkg_settings_local --> pkg_atomic_write + pkg_settings_local --> pkg_invariants + pkg_settings_local --> pkg_paths + pkg_settings_local --> pkg_settings + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout + pkg_session --> pkg_brand + pkg_session --> pkg_invariants + pkg_session --> pkg_llm + pkg_session --> pkg_scope + pkg_session --> pkg_type_meta + pkg_system_prompt --> pkg_invariants + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm + pkg_web --> pkg_invariants + pkg_web --> pkg_llm + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm pkg_sandbox --> pkg_invariants pkg_sandbox --> pkg_llm - pkg_settings_local --> pkg_atomic_write - pkg_settings_local --> pkg_invariants - pkg_settings_local --> pkg_paths - pkg_settings_local --> pkg_settings pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -457,40 +498,24 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -549,10 +574,6 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -563,16 +584,10 @@ flowchart TD pkg_fs_e2b --> pkg_e2b pkg_fs_e2b --> pkg_fs pkg_fs_e2b --> pkg_invariants - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -694,10 +709,6 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -1003,6 +1014,8 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1214,7 +1227,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | @@ -1229,22 +1242,30 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1255,13 +1276,10 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1277,12 +1295,10 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1309,7 +1325,6 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1360,7 +1375,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0f640d10a8..422a812397 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1,7 +1,7 @@ // FixtureApi: standalone UI development without a server. Real contract shape: unary takes // RpcRequest

and returns RpcResponse (echoing the rpcId); streams yield RpcRequest // (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse -// and returns RpcReceipt. fx-alpha carries a hand-built history script (73 turns, pageable); +// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable); // prompt triggers a chunked streaming replay; cancel stops the replay; resident pending // approval/question requests exercise replay and composer takeover with stable rpcIds. @@ -351,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage { } } -/** fx-alpha history script: 73 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), +/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / context. */ function buildAlphaLog(): SessionEvent[] { const events: Record[] = [] @@ -487,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 72: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 73: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -546,20 +546,20 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') - // Turn 71: user and assistant images share one durable fixture object. + // Turn 72: user and assistant images share one durable fixture object. // The todo turn remains last so its standing projection stays visible. - push({ type: 'turn/start', data: { turn: 71 } }) + push({ type: 'turn/start', data: { turn: 72 } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), }) - push({ type: 'step/start', data: { turn: 71, step: 0 } }) + push({ type: 'step/start', data: { turn: 72, step: 0 } }) push({ type: 'assistant/message', surfaceOp: 'append', data: { - turn: 71, + turn: 72, step: 0, message: assistantMessage( [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], @@ -567,11 +567,11 @@ function buildAlphaLog(): SessionEvent[] { ), }, }) - push({ type: 'step/end', data: { turn: 71, step: 0 } }) - push({ type: 'turn/end', data: { turn: 71, reason: { kind: 'completed' } } }) + push({ type: 'step/end', data: { turn: 72, step: 0 } }) + push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } }) const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -1407,7 +1407,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // DeepSeek route so unrelated GUI journeys do not enter first-run setup. ['DEEPSEEK_API_KEY', true], ]) - const nextTurn = new Map([[sid('fx-alpha'), 73]]) + const nextTurn = new Map([[sid('fx-alpha'), 74]]) let nextSession = 1 let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 From a3cf2617a8f7fef6846efc0e486478c4f1b70d97 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:26:37 +0800 Subject: [PATCH 41/45] =?UTF-8?q?docs(notes):=20fix=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20translate=20zh=20note=20headings,=20point=20superse?= =?UTF-8?q?ssion=20at=20the=20Decision,=20pin=20caption=20absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-04-web-context-source-and-steer-marks.i18n.yaml | 4 ++-- .../2026-08-04-web-context-source-and-steer-marks.md | 2 +- ...26-08-04-web-context-source-and-steer-marks.zh.md | 2 +- ...eb-remove-steering-interjection-caption.i18n.yaml | 2 +- ...10-web-remove-steering-interjection-caption.zh.md | 12 ++++++------ .../ui-conversation/tests/chat-branch-tails.spec.tsx | 1 + 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index 6bc552736e..86b48310e7 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb -2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 +2026-08-04-web-context-source-and-steer-marks.md: 01bdca873a847f70b4b8632b961e01e099ae4f04 +2026-08-04-web-context-source-and-steer-marks.zh.md: b6a9cc5692826b402b5a08ec65a5c8fc3c547b6b diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 0285f3ef1d..01bdca873a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -42,7 +42,7 @@ The transcript names all three roles a non-prompt message can play — injected ## Consequences -- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming in the Decision stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index 872ab30d72..b6a9cc5692 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -42,7 +42,7 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 后果 -- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。决策中的上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml index 7194db5780..2f63bbcfd7 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md 2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 -2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 088b36449130f9e8f1be5bec7a3ee4a812b4b6f1 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md index 85d76e977a..088b364491 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -1,14 +1,14 @@ -# Agent Note: Remove the steering interjection caption +# Agent Note: 移除 steering 插话标注 Status: implemented [English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 -## Problem +## 问题 [上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 -## Decision +## 决策 steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 @@ -16,7 +16,7 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 -## Alternatives considered +## 考虑过的替代方案 **保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 @@ -24,12 +24,12 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 **换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 -## Testing +## 测试 - `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 - 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 -## Consequences +## 后果 - 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 - 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index bd84f46ed6..5d1f027923 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -242,6 +242,7 @@ describe('MessageItem arms', () => { } as never} />, ) + expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) From fb37107ca4a16d80a036f549aef62146157e2005 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:48:49 +0800 Subject: [PATCH 42/45] test(attachment): skip POSIX modes on Windows --- packages/attachment/attachment-local/tests/store.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 2332bfe942..bd2adb4c55 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -116,8 +116,10 @@ describe('local attachment store', () => { }) expect(second.attachmentId).toBe(first.attachmentId) 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) + if (process.platform !== 'win32') { + 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)).resolves.toEqual({ ref: first, data: PNG }) }) From 12c971d4b9c7841b5b123d82657aef71d6e66929 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 12:06:01 +0800 Subject: [PATCH 43/45] test(attachment): exclude POSIX fsync on Windows --- packages/attachment/attachment-local/src/store.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index bd33e4c8e3..d77f2be375 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -76,12 +76,14 @@ export async function validateImageFile(input: SaveImageAttachment, limits: Imag async function syncDirectory(path: string): Promise { /* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */ if (process.platform === 'win32') return + /* v8 ignore start -- Windows cannot exercise directory fsync; POSIX behavior tests enforce this peer. */ const handle = await open(path, constants.O_RDONLY) try { await handle.sync() } finally { await handle.close() } + /* v8 ignore stop */ } /** From ec310e60f81599b8b67c28544b047c2aa9c541de Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 13:12:35 +0800 Subject: [PATCH 44/45] test(web): align steer-all snapshots after master sync --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 6 ++++-- apps/web/tests/snapshots/steer-all/settled.expected.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..8b77a77a0c 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -17,10 +19,10 @@ - img - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply." +- text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..0899529a09 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -19,10 +21,10 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img - paragraph: "Got it: BANANA and ORANGE." From 34c5da26b883477cb8ecf99e2d5bc7c30810d165 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:53:15 +0800 Subject: [PATCH 45/45] test: align apiproxy model harness with path-only workspaces --- packages/host/apiproxy/tests/api-proxy-models.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index ff3a775a2b..bdd21128b4 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -156,7 +156,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) const result = await api.sessions.prompt(request({ @@ -203,7 +202,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) const image = { type: 'image' as const, @@ -246,7 +244,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) agent.session.append('agent/inbox/spliced', { target: 'next-turn',