From cb4c11b869f2c9e2bcba89ab6deec146b678be68 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 23 Jul 2026 15:20:47 +0800 Subject: [PATCH 01/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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 dd473870dd388db78079a71edc7756a126ff720d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 20:27:31 +0800 Subject: [PATCH 32/73] fix(web): persist theme preference in settings --- ...host-backed-web-theme-preference.i18n.yaml | 6 + ...-08-06-host-backed-web-theme-preference.md | 39 +++++ ...-06-host-backed-web-theme-preference.zh.md | 39 +++++ apps/web/tests/scaffold.ts | 4 +- apps/web/tests/settings-chrome.e2e.ts | 39 ++++- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 3 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/client/ui-theme/package.json | 7 +- .../ui-theme/src/client/AppearanceRow.tsx | 2 +- packages/client/ui-theme/src/client/index.ts | 117 ++++++++------ .../ui-theme/src/client/settings-store.ts | 2 +- .../ui-theme/src/client/theme-settings.ts | 100 ++++++++++++ packages/client/ui-theme/src/index.ts | 37 ++++- packages/client/ui-theme/src/invariant.ts | 8 +- .../client/ui-theme/src/theme-settings.ts | 22 +++ packages/client/ui-theme/tests/apply.spec.ts | 66 +++++++- packages/client/ui-theme/tests/host.spec.ts | 30 ++++ .../client/ui-theme/tests/invariant.spec.ts | 15 +- .../ui-theme/tests/theme-settings.spec.ts | 149 ++++++++++++++++++ packages/client/ui-theme/tests/theme.spec.ts | 59 +++---- packages/client/ui-theme/tsconfig.json | 6 + 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 | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 32 +++- pnpm-lock.yaml | 9 ++ 30 files changed, 692 insertions(+), 121 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md create mode 100644 packages/client/ui-theme/src/client/theme-settings.ts create mode 100644 packages/client/ui-theme/src/theme-settings.ts create mode 100644 packages/client/ui-theme/tests/host.spec.ts create mode 100644 packages/client/ui-theme/tests/theme-settings.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml new file mode 100644 index 0000000000..7e804aad59 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.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-08-06-host-backed-web-theme-preference.md +2026-08-06-host-backed-web-theme-preference.md: 129132586b0d0ccfdb5b32fdaa1f7178a7176db7 +2026-08-06-host-backed-web-theme-preference.zh.md: 0c2dafff3fc3cec49a2261e31a61ecf99a10f126 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md new file mode 100644 index 0000000000..129132586b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md @@ -0,0 +1,39 @@ +# Agent Note: Persist the Web theme through Host settings + +Status: implemented + +English | [中文](2026-08-06-host-backed-web-theme-preference.zh.md) + +## Problem + +The Web theme preference lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different storage partition and returned to the default system theme even though both processes used the same DSH home. + +The theme is a user-level product preference rather than page-local state. DSH already has a user-settings service with a file-backed provider, a loopback-only configuration wire, and invalidation frames for external edits and other tabs. + +## Decision + +The `@deepseek-ai/dsh-client-ui-theme` Host half registers `ui-theme.preference` with the built-in `light`, `dark`, and `system` values and a `system` default. The local settings provider stores an override in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. + +The loopback client loads that namespace before it provides `ThemeService`, so the initial presenter snapshot reflects the durable preference without relying on an origin cache. `ThemeService.setTheme` still changes the live snapshot synchronously; its persistence callback sends a `settings.mutate` path operation. The controller serializes rapid selections in gesture order, ignores stale settlements, reloads after a rejected latest write, and refetches on `settings/changed` or `connection/reset`. + +The API proxy explicitly exposes `ui-theme` beside `permission` and `ui-onboarding`. Registration alone remains insufficient to cross the configuration boundary. Remote browsers cannot call the privileged settings API and retain only a process-local selection. + +Only the built-in product preferences cross the Host schema. Third-party registered theme ids remain an in-process extension because the Host cannot validate a browser plugin's dynamic registry during startup. + +## Alternatives considered + +**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host-side relay would recreate a settings service around a browser-specific format. + +**Use a cookie without an explicit port.** Cookies would couple preference durability to the served hostname, still split localhost aliases, and introduce HTTP state outside the user-settings ownership model. + +**Mirror Host settings into `localStorage`.** A second authority creates boot and invalidation conflict rules while retaining the origin partition that caused the defect. The Host document is the sole durable source. + +**Expose every registered settings namespace.** Automatic exposure would let an unrelated plugin become remotely configurable by registering with the general settings seam. The API proxy keeps an explicit allowlist. + +## Consequences + +Theme selections follow the DSH user home across reloads, ports, and loopback origins, and direct edits to `settings.yaml` converge through the existing invalidation stream. The settings document contains a readable section such as `ui-theme: { preference: dark }`; no theme value is written to `localStorage`. + +Startup performs one loopback settings read before publishing the theme service. A transient read failure keeps the system default or last good in-process value and reconnect can retry. A write rejection can visibly restore the durable preference after the immediate theme change. + +Unit coverage pins schema registration, ordered writes, stale-response containment, failure recovery, invalidation refresh, and remote memory mode. The real Web settings scenario writes dark through the UI, verifies the YAML document, reloads, and boots a second Host on another port against the same DSH home with an empty theme `localStorage` partition. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md new file mode 100644 index 0000000000..0c2dafff3f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 通过 Host settings 持久化 Web 主题 + +Status: implemented + +[English](2026-08-06-host-backed-web-theme-preference.md) | 中文 + +## 问题 + +Web 主题偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区,并回到默认的系统主题,即使两个进程使用同一个 DSH home。 + +主题是用户级产品偏好,而非页面局部状态。DSH 已有用户 settings 服务及其基于文件的提供方,也已有仅限回环请求的配置协议,并为外部编辑和其他标签页提供失效帧。 + +## 决策 + +`@deepseek-ai/dsh-client-ui-theme` 的 Host half 注册 `ui-theme.preference`,可取内置值 `light`、`dark` 与 `system`,默认值为 `system`。本地 settings 提供方将覆盖值存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。 + +来自回环地址的客户端会在提供 `ThemeService` 之前加载该 namespace,因此初始呈现器快照会反映持久化偏好,无需依赖按 origin 划分的缓存。`ThemeService.setTheme` 仍会同步更新实时快照;它的持久化回调会发送一项 `settings.mutate` 路径操作。控制器按操作顺序串行处理连续快速选择,忽略陈旧操作的结算结果,在最新写入被拒后重新加载持久化值,并在发生 `settings/changed` 或 `connection/reset` 时重新拉取。 + +API 代理会显式暴露 `ui-theme`,与 `permission` 和 `ui-onboarding` 并列。仅注册该设置,仍不足以跨越配置边界。远程浏览器无法调用特权 settings API,其主题选择仅保留在进程内。 + +只有产品内置偏好才会跨越 Host schema。第三方注册的主题 id 仍是进程内扩展,因为 Host 无法在启动期间校验浏览器插件的动态注册表。 + +## 曾考虑的替代方案 + +**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 侧中继会围绕浏览器特有格式重新实现一套 settings 服务。 + +**使用不显式包含端口的 cookie。** Cookie 会将偏好的持久性与提供服务的 hostname 耦合,localhost 的不同 alias 仍会各自分区,还会在用户 settings 的所有权模型之外引入 HTTP 状态。 + +**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会导致启动与失效时需要另外定义冲突规则,同时依然保留造成该缺陷的 origin 分区。Host 侧 settings 文档是唯一的持久化真源。 + +**暴露所有已注册的 settings namespace。** 自动暴露会让与本功能无关的插件仅凭向通用 settings seam 注册,就成为可远程配置的插件。API 代理保留一份显式 allowlist。 + +## 后果 + +主题选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin;直接编辑 `settings.yaml` 所产生的变更也会通过现有失效流收敛。settings 文档包含形如 `ui-theme: { preference: dark }` 的可读分节;不会向 `localStorage` 写入主题值。 + +启动时会在发布主题服务之前执行一次回环 settings 读取。短暂的读取失败会保留系统默认值或上一个正确的进程内值,并可在重连时重试。写入被拒时,界面可能会在主题立即变化后明显恢复为持久化偏好。 + +单元测试覆盖 schema 注册、有序写入、陈旧响应隔离、故障恢复、失效刷新与远程端仅内存模式。真实 Web settings 场景通过 UI 写入 dark,校验 YAML 文档,重新加载,再使用同一个 DSH home 在另一个端口上启动第二个 Host,此时主题 `localStorage` 分区为空。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index dc68cbf67d..bec4afa86e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -185,6 +185,8 @@ export interface LaunchOptions { * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. */ remoteAuthority?: string + /** Reuse an existing harness home so a second Host can verify user settings across origins. */ + harnessHome?: string } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -231,7 +233,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise localStorage dsh.theme +// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) // the Language row (settings-scoped localization + persisted dsh.locale), // the busy-state Enter preference, plus Permission as the persisted default @@ -152,13 +152,13 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('flips the theme through the Appearance cubes and persists across reload', async () => { + it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) - const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => - await page.evaluate(() => ({ + const readState = async (target: Page = page): Promise<{ attr: boolean; token: string; legacy: string | null }> => + await target.evaluate(() => ({ attr: document.body.hasAttribute('data-ds-dark-theme'), token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(), - stored: localStorage.getItem('dsh.theme'), + legacy: localStorage.getItem('dsh.theme'), })) // Pin the OS scheme to light so the default `system` preference resolves // light and the dark flip below is unambiguously the gesture's doing. @@ -172,13 +172,15 @@ describe('web e2e: settings modal and General preferences', () => { const darkCube = dialog.getByRole('button', { name: '深色' }) expect(await darkCube.getAttribute('aria-pressed')).toBe('false') await darkCube.click() - // The full cascade: pressed state, persisted preference, body attribute, + // The full cascade: pressed state, Host-backed preference, body attribute, // alias token flip — all from one real user gesture. await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') const dark = await readState() expect(dark.attr).toBe(true) - expect(dark.stored).toBe('dark') + expect(dark.legacy).toBeNull() expect(dark.token).not.toBe(light.token) + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-theme:\n\s+preference: dark/) await page.keyboard.press('Escape') // Reload: the preference survives boot (restore + presenter initial apply). @@ -189,7 +191,28 @@ describe('web e2e: settings modal and General preferences', () => { await page.emulateMedia({ colorScheme: 'light' }) const reloaded = await readState() expect(reloaded.attr).toBe(true) - expect(reloaded.stored).toBe('dark') + expect(reloaded.legacy).toBeNull() + + // A second live Host binds another ephemeral port but shares the same + // user-settings home. Its fresh origin has no theme localStorage and must + // still render dark before the settings dialog opens. + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.emulateMedia({ colorScheme: 'light' }) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const crossPort = await readState(secondPage) + expect(crossPort.attr).toBe(true) + expect(crossPort.legacy).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } // `system` follows the emulated OS scheme (dark stays dark, light clears). await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 23794b5c9d..59b20a6762 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,14 +62,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | +| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | -| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | +| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ad8ef0b7e..882f30dd07 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -399,6 +399,7 @@ flowchart TD 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_connection pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -1150,7 +1151,7 @@ flowchart TD | [`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-theme`](../packages/client/ui-theme) | `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-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) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`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) | diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 76bcbaf608..04fd1e81c2 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 88e21fe214ec806b101050949690283d811be36d -README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50 +README.md: 32868bcac4313a3badfe92dbf41c84e793f09709 +README.zh.md: a38765b8004826133875c38deeb66128d52ec986 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 88e21fe214..32868bcac4 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8. +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser loads `ui-theme.preference` before providing the service and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema. Contract: api-contracts v3 §8; the [Host-backed preference decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md) owns the persistence boundary. `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index ba781ba89a..a38765b800 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会在提供该服务前加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md)拥有。 `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 1adad710cc..7635da17b8 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -25,6 +25,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale" ], @@ -33,6 +34,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^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", @@ -42,6 +44,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -64,6 +67,8 @@ "watch": "tsdown --watch" }, "dependencies": { - "clsx": "^2.0.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", + "schemastery": "^3.18.0" } } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index a0e04b67a6..e482f5ed2e 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -10,7 +10,7 @@ import { IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { ThemePreference } from './index.ts' +import type { ThemePreference } from '../theme-settings.ts' import type { ThemeKey } from './locales.ts' import type {} from './settings-contract.ts' import type { createAppearanceRowStore } from './settings-store.ts' diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index eb096412f5..497f4a22f1 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -1,12 +1,14 @@ /** * Browser theme registry over the `--dsw-*` token stylesheets. The service - * owns the theme preference (light/dark/system), resolves `system` through + * owns the live theme preference (light/dark/system), resolves `system` through * `prefers-color-scheme`, and publishes immutable snapshots; it never touches - * the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin - * also registers the Appearance preference row into the settings General - * section — the theme feature owns its own settings surface. + * the DOM — ui-layout's presenter consumes the resolved snapshot. The Host + * settings controller loads and stores the preference in the user-settings + * document. The plugin also registers the Appearance preference row into the + * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -14,11 +16,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' +import { ThemeSettingsController } from './theme-settings.ts' import { en, zh, type ThemeKey } from './locales.ts' +import { + DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' +export type { ThemePreferenceTarget } from './theme-settings.ts' +export { ThemeSettingsController } from './theme-settings.ts' export type { ThemeKey } from './locales.ts' +export { + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from '../theme-settings.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' @@ -33,9 +46,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record -/** Theme preference: a concrete theme id or follow-the-OS. */ -export type ThemePreference = 'light' | 'dark' | 'system' - /** One selectable theme: id, dark/light semantics, and alias-token overrides. */ export interface ThemeDefinition { /** Theme id (the setTheme argument for concrete themes). */ @@ -76,12 +86,6 @@ declare module 'cordis' { } } -/** localStorage key holding the persisted theme preference. */ -export const STORAGE_KEY = 'dsh.theme' - -/** Default preference when nothing (or garbage) is persisted. */ -export const DEFAULT_PREFERENCE: ThemePreference = 'system' - const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }), Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }), @@ -103,14 +107,17 @@ export class ThemeService { private revision = 0 private snapshot: ThemeSnapshot private readonly media: MediaQueryList | undefined + private persist: (preference: ThemePreference) => void /** * @param ctx - owning context (change events are emitted on it; the * media-query listener is released through ctx.effect on dispose). + * @param persist - durable write callback for built-in preferences. */ - constructor(ctx: Context) { + constructor(ctx: Context, persist: (preference: ThemePreference) => void = () => {}) { this.ctx = ctx - this.preference = restorePreference() + this.persist = persist + this.preference = DEFAULT_PREFERENCE // Non-browser runs (node e2e booting the client tree) have no matchMedia. this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') this.snapshot = this.buildSnapshot() @@ -136,8 +143,17 @@ export class ThemeService { } /** - * Switch the theme preference — the only preference write entry. Persists - * the preference and emits `theme/change`. + * Bind the owning plugin's durable writer before the service is provided. + * @param persist - callback accepting built-in preference changes. + */ + bindPersistence(persist: (preference: ThemePreference) => void): void { + this.persist = persist + } + + /** + * Switch the theme preference — the only user preference write entry. + * Built-in preferences are persisted and every accepted value emits + * `theme/change`. * @param id - a registered theme id or `system`; unknown ids throw. */ setTheme(id: string): void { @@ -146,7 +162,17 @@ export class ThemeService { } if (this.preference === id) return this.preference = id as ThemePreference - persistPreference(this.preference) + if (isThemePreference(id)) this.persist(id) + this.publish() + } + + /** + * Apply a preference read from Host settings without writing it back. + * @param preference - validated durable preference. + */ + syncPreference(preference: ThemePreference): void { + if (this.preference === preference) return + this.preference = preference this.publish() } @@ -170,7 +196,7 @@ export class ThemeService { this.themes = this.themes.filter(t => t.id !== definition.id) if (this.preference === definition.id) { this.preference = DEFAULT_PREFERENCE - persistPreference(this.preference) + this.persist(this.preference) } this.publish() } @@ -200,32 +226,8 @@ export class ThemeService { } } -/** Read the persisted preference; unknown or unreadable values fall back to the default. */ -function restorePreference(): ThemePreference { - // Non-browser runs (node e2e booting the client tree) have no localStorage. - if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'light' || stored === 'dark' || stored === 'system') return stored - } catch { - // Storage access can throw (privacy mode); the default below covers it. - } - return DEFAULT_PREFERENCE -} - -/** Persist the preference; storage failures are non-fatal (preference resets next boot). */ -function persistPreference(preference: ThemePreference): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(STORAGE_KEY, preference) - } catch { - // Storage access can throw (privacy mode / quota); the preference simply - // does not survive the session. - } -} - -/** Required services: slots + locale (the feature registers its own settings row with localized copy). */ -export const inject = ['slots', 'locale'] +/** Required services: settings transport plus slots/locale for the Appearance row. */ +export const inject = ['slots', 'locale', 'connection'] /** * Client plugin body: provide the theme service and register the @@ -233,10 +235,33 @@ export const inject = ['slots', 'locale'] * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: ClientContext): void { +export async function apply(ctx: ClientContext): Promise { + const connection = ctx.get('connection') as ConnectionHandle const theme = new ThemeService(ctx) + const controller = new ThemeSettingsController( + connection.api, + theme, + connection.isLoopback ? 'host' : 'memory', + ) + theme.bindPersistence((preference) => { void controller.persist(preference) }) + await controller.load() ctx.provide('theme', theme) + ctx.effect(() => { + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { + controller.dispose() + for (const dispose of disposers) dispose() + } + }, 'ui-theme: settings invalidations') + ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts index 256b04a299..e4c76154e5 100644 --- a/packages/client/ui-theme/src/client/settings-store.ts +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -4,7 +4,7 @@ * reads via props.useStore. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { ThemePreference } from './index.ts' +import type { ThemePreference } from '../theme-settings.ts' /** Store state mirrored from the theme snapshot. */ export interface AppearanceRowState { diff --git a/packages/client/ui-theme/src/client/theme-settings.ts b/packages/client/ui-theme/src/client/theme-settings.ts new file mode 100644 index 0000000000..66b332313b --- /dev/null +++ b/packages/client/ui-theme/src/client/theme-settings.ts @@ -0,0 +1,100 @@ +/** Host-backed persistence controller for the browser theme preference. */ + +import type { + IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { + THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference, + type ThemePreference, +} from '../theme-settings.ts' + +/** Preference target implemented by {@link ThemeService}. */ +export interface ThemePreferenceTarget { + /** + * Apply a Host value without writing it back. + * @param preference - validated durable preference. + */ + syncPreference(preference: ThemePreference): void +} + +function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined { + if (typeof view.value !== 'object' || view.value === null) return undefined + const preference = (view.value as Record)[THEME_PREFERENCE_FIELD] + return isThemePreference(preference) ? preference : undefined +} + +/** Coordinates startup reads, ordered writes, and pushed invalidations. */ +export class ThemeSettingsController { + private generation = 0 + private writeTail: Promise = Promise.resolve() + + /** + * @param api - settings wire face. + * @param target - live theme service receiving durable values. + * @param persistence - remote browsers stay process-local because the settings API is loopback-only. + */ + constructor( + private readonly api: Pick, + private readonly target: ThemePreferenceTarget, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} + + /** + * Load the durable preference after earlier writes settle; the latest operation wins. + * @returns nothing; an unavailable or invalid descriptor leaves the last good value active. + */ + async load(): Promise { + const generation = ++this.generation + if (this.persistence === 'memory') return + await this.writeTail + if (generation !== this.generation) return + let response: Awaited['settings']['describe']>> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + // A transport failure leaves the last good in-process theme active. A + // connection/reset or settings/changed notification retries the read. + return + } + if (!response.result.ok || generation !== this.generation) return + const view = response.result.value.namespaces.find( + candidate => candidate.ns === THEME_SETTINGS_NAMESPACE, + ) + if (view === undefined) return + const preference = preferenceOf(view) + if (preference !== undefined) this.target.syncPreference(preference) + } + + /** + * Persist one user selection. Writes are serialized so rapid picks land in + * gesture order; a rejected latest write reloads the durable value. + * @param preference - selected built-in preference. + * @returns nothing after the write or recovery read settles. + */ + async persist(preference: ThemePreference): Promise { + const generation = ++this.generation + if (this.persistence === 'memory') return + const write = this.writeTail.then(async () => { + const response = await this.api.settings.mutate({ + ns: THEME_SETTINGS_NAMESPACE, + ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }], + }) + if (!response.result.ok) throw new Error(response.result.error.message) + if (generation === this.generation) { + const accepted = preferenceOf(response.result.value) + if (accepted !== undefined) this.target.syncPreference(accepted) + } + }) + this.writeTail = write.catch(() => {}) + try { + await write + } catch { + if (generation === this.generation) await this.load() + } + } + + /** Prevent in-flight reads and writes from publishing after plugin disposal. */ + dispose(): void { + this.generation += 1 + } +} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 4777b0eb43..5f746d6d83 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,4 +1,35 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host registration for the browser theme preference. */ -/** Host plugin body — no host-side behavior for the theme plugin. */ -export function apply(): void {} +import type { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from './theme-settings.ts' + +export { + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + type ThemePreference, +} from './theme-settings.ts' + +interface ThemeSettings { + preference: ThemePreference +} + +const ThemeSettingsSchema: z = z.object({ + [THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE), +}) + +/** + * Register the durable theme section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(THEME_SETTINGS_NAMESPACE), + ThemeSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 4ec3296cd6..e15985a9dc 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the theme registry publishes immutable snapshots on - * its own `theme/change` event synchronously with the setter/registry - * mutation in the same service — snapshot/event agreement is asserted - * directly by this package's behavior specs. + * No runtime invariant: the settings seam validates and publishes the durable + * theme section, while the registry emits `theme/change` synchronously with + * its own mutations. Store/registry agreement is covered directly by this + * package's Host, controller, and service behavior specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts new file mode 100644 index 0000000000..e93b3c56e0 --- /dev/null +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -0,0 +1,22 @@ +/** Theme preferences stored in the Host user-settings document. */ + +/** Settings namespace owned by the theme plugin. */ +export const THEME_SETTINGS_NAMESPACE = 'ui-theme' + +/** Field carrying the selected built-in theme preference. */ +export const THEME_PREFERENCE_FIELD = 'preference' + +/** Theme preference persisted by the product Appearance row. */ +export type ThemePreference = 'light' | 'dark' | 'system' + +/** Default preference when the user-settings document has no override. */ +export const DEFAULT_PREFERENCE: ThemePreference = 'system' + +/** + * Narrow one wire or registry value to a persistable preference. + * @param value - value crossing the settings or registry boundary. + * @returns whether the value is a built-in preference. + */ +export function isThemePreference(value: unknown): value is ThemePreference { + return value === 'light' || value === 'dark' || value === 'system' +} diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index a4da553516..350ea0525a 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -2,11 +2,13 @@ * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' +import { + apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE, +} from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -17,12 +19,39 @@ usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' -async function bench() { +async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) - return { ctx, slots: ctx.get('slots') as SlotsService, locale } + let preference = 'system' + const namespace = () => ({ + ns: THEME_SETTINGS_NAMESPACE, + schema: {}, + value: { preference }, + applies: 'live' as const, + secrets: [], + revision: 0, + }) + const describe = vi.fn(() => Promise.resolve({ + rpcId: 'theme-describe' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, + }, + })) + const mutate = vi.fn((request: { ops: { value: string }[] }) => { + preference = request.ops[0]!.value + return Promise.resolve({ + rpcId: 'theme-mutate' as never, + result: { ok: true as const, value: namespace() }, + }) + }) + ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate, + setHostPreference: (next: string) => { preference = next }, + } } /** Stand in for the settings shell: declare the General item slot from root. */ @@ -45,7 +74,7 @@ function faceOf(slots: SlotsService) { describe('ui-theme apply', () => { it('declares the slot and locale services', () => { - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'connection']) }) it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { @@ -84,6 +113,33 @@ describe('ui-theme apply', () => { face.setTheme('system') expect(theme.getTheme().preference).toBe('system') expect(instance.getSnapshot().preference).toBe('system') + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + }) + + it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => { + const b = await bench() + b.setHostPreference('dark') + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + expect(theme.getTheme().preference).toBe('dark') + b.ctx.emit('settings/changed', 'unrelated') + expect(b.describe).toHaveBeenCalledOnce() + b.setHostPreference('light') + b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) + b.setHostPreference('dark') + b.ctx.emit('connection/reset') + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + + const remote = await bench(false) + declareItems(remote.slots) + await remote.ctx.plugin({ inject: [...inject], apply }).await() + const remoteTheme = remote.ctx.get('theme') as ThemeService + remoteTheme.setTheme('dark') + await Promise.resolve() + expect(remote.describe).not.toHaveBeenCalled() + expect(remote.mutate).not.toHaveBeenCalled() }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.spec.ts new file mode 100644 index 0000000000..6cbbd91c27 --- /dev/null +++ b/packages/client/ui-theme/tests/host.spec.ts @@ -0,0 +1,30 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply, +} from '@deepseek-ai/dsh-client-ui-theme' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-theme host', () => { + it('registers, validates, and disposes the durable theme namespace with its fiber', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE }) + await ctx.settings.update(ns, { preference: 'dark' }) + expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' }) + await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 640599ea43..42a2651099 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -15,18 +15,25 @@ describe('invariant companion', () => { await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined() }) - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract + it('node-half waits for an optional settings provider', () => { + nodeApply(new Context()) + expect(true).toBe(true) }) it('client apply provides ctx.theme over the slots/locale edges', async () => { // The feature registers its own Appearance settings row with localized // copy, hence the slots + locale edges. - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'connection']) const ctx = new Context() new SlotsService(ctx) await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() + ctx.provide('connection', { + api: { settings: { describe: () => Promise.resolve({ + rpcId: 'theme-invariant' as never, + result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } }, + }) } }, + isLoopback: true, + } as never) await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tests/theme-settings.spec.ts b/packages/client/ui-theme/tests/theme-settings.spec.ts new file mode 100644 index 0000000000..b2b921a4c2 --- /dev/null +++ b/packages/client/ui-theme/tests/theme-settings.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, ThemeSettingsController, + type ThemePreference, +} from '@deepseek-ai/dsh-client-ui-theme/client' + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `theme-${rpc++}` as never, result: { ok: true, value } } +} + +function view(preference: unknown = 'system'): SettingsNamespaceView { + return { + ns: THEME_SETTINGS_NAMESPACE, + schema: {}, + value: { [THEME_PREFERENCE_FIELD]: preference }, + applies: 'live', + secrets: [], + revision: 0, + } +} + +function described(preference: unknown = 'system') { + return ok({ writable: true, hasDocument: true, namespaces: [view(preference)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +function target() { + const values: ThemePreference[] = [] + return { values, syncPreference: (preference: ThemePreference) => { values.push(preference) } } +} + +describe('ThemeSettingsController', () => { + it('loads a valid Host value and ignores unavailable or malformed namespaces', async () => { + const receiver = target() + const describe = vi.fn() + .mockResolvedValueOnce(described('dark')) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described('sepia')) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [{ ...view(), value: null }] })) + .mockResolvedValueOnce({ + rpcId: 'failed' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'offline', details: {} } }, + }) + .mockRejectedValueOnce(new Error('transport offline')) + const controller = new ThemeSettingsController({ settings: { describe } } as never, receiver) + for (let i = 0; i < 6; i++) await controller.load() + expect(receiver.values).toEqual(['dark']) + }) + + it('persists ordered rapid selections and publishes only the latest settlement', async () => { + const first = deferred>>() + const calls: string[] = [] + const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { + const preference = request.ops[0]!.value + calls.push(preference) + if (preference === 'dark') return first.promise + return ok(view(preference)) + }) + const receiver = target() + const controller = new ThemeSettingsController({ settings: { mutate } } as never, receiver) + const dark = controller.persist('dark') + const light = controller.persist('light') + await Promise.resolve() + expect(calls).toEqual(['dark']) + first.resolve(ok(view('dark'))) + await Promise.all([dark, light]) + expect(calls).toEqual(['dark', 'light']) + expect(receiver.values).toEqual(['light']) + expect(mutate).toHaveBeenNthCalledWith(1, { + ns: THEME_SETTINGS_NAMESPACE, + ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: 'dark' }], + }) + }) + + it('reloads after a rejected latest write and contains stale reads and disposal', async () => { + const stale = deferred>() + const describe = vi.fn() + .mockImplementationOnce(() => stale.promise) + .mockResolvedValueOnce(described('system')) + const mutate = vi.fn().mockResolvedValue({ + rpcId: 'rejected' as never, + result: { ok: false as const, error: { code: 'settings-rejected' as const, message: 'disk full', details: {} } }, + }) + const receiver = target() + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) + const oldLoad = controller.load() + await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) + await controller.persist('dark') + stale.resolve(described('light')) + await oldLoad + expect(receiver.values).toEqual(['system']) + + const disposedRead = deferred>() + describe.mockImplementationOnce(() => disposedRead.promise) + const pending = controller.load() + controller.dispose() + disposedRead.resolve(described('dark')) + await pending + expect(receiver.values).toEqual(['system']) + }) + + it('keeps remote-browser persistence in memory without calling Host settings', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const receiver = target() + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver, 'memory') + await controller.load() + await controller.persist('dark') + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + expect(receiver.values).toEqual([]) + }) + + it('reloads after a thrown write and ignores a malformed success response', async () => { + const receiver = target() + const describe = vi.fn().mockResolvedValue(described('light')) + const mutate = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view('sepia'))) + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) + await controller.persist('dark') + await controller.persist('system') + expect(receiver.values).toEqual(['light']) + }) + + it('lets an explicit refresh supersede a stale rejected write', async () => { + const rejected = deferred() + const receiver = target() + const describe = vi.fn().mockResolvedValue(described('system')) + const mutate = vi.fn().mockReturnValue(rejected.promise) + const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) + const write = controller.persist('dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const refresh = controller.load() + rejected.reject(new Error('stale rejection')) + await Promise.all([write, refresh]) + expect(receiver.values).toEqual(['system']) + expect(describe).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index c853c9fd67..68f0f3c7f8 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,21 +1,22 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' -import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => { +const make = (persist = vi.fn()): { + ctx: Context + theme: ThemeService + events: ThemeSnapshot[] + persist: typeof persist +} => { const ctx = new Context() const events: ThemeSnapshot[] = [] ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) - return { ctx, theme: new ThemeService(ctx), events } + return { ctx, theme: new ThemeService(ctx, persist), events, persist } } describe('ThemeService', () => { - beforeEach(() => { - localStorage.clear() - }) - it('defaults to the system preference resolved against prefers-color-scheme', () => { const { theme } = make() const snapshot = theme.getTheme() @@ -26,12 +27,12 @@ describe('ThemeService', () => { expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) }) - it('setTheme switches, persists, republishes, and keeps DOM untouched', () => { - const { theme, events } = make() + it('setTheme switches, requests persistence, republishes, and keeps DOM untouched', () => { + const { theme, events, persist } = make() theme.setTheme('dark') expect(theme.getTheme().preference).toBe('dark') expect(theme.getTheme().active.colorScheme).toBe('dark') - expect(localStorage.getItem(STORAGE_KEY)).toBe('dark') + expect(persist).toHaveBeenCalledWith('dark') expect(events).toHaveLength(1) expect(events[0]).toBe(theme.getTheme()) // The service never touches presentation state. @@ -39,13 +40,17 @@ describe('ThemeService', () => { // Same-value set is a no-op (no extra event). theme.setTheme('dark') expect(events).toHaveLength(1) + expect(persist).toHaveBeenCalledOnce() }) - it('restores a persisted preference and falls back on garbage', () => { - localStorage.setItem(STORAGE_KEY, 'dark') - expect(make().theme.getTheme().preference).toBe('dark') - localStorage.setItem(STORAGE_KEY, 'sepia') - expect(make().theme.getTheme().preference).toBe('system') + it('syncs a Host preference without writing it back', () => { + const { theme, events, persist } = make() + theme.syncPreference('dark') + expect(theme.getTheme().preference).toBe('dark') + expect(events).toHaveLength(1) + expect(persist).not.toHaveBeenCalled() + theme.syncPreference('dark') + expect(events).toHaveLength(1) }) it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { @@ -56,7 +61,7 @@ describe('ThemeService', () => { }) it('registered themes join the snapshot; disposing the active one resets to default', () => { - const { theme, events } = make() + const { theme, events, persist } = make() const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } }) expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia']) theme.setTheme('sepia') @@ -64,7 +69,10 @@ describe('ThemeService', () => { dispose() expect(theme.getTheme().preference).toBe('system') expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) - expect(localStorage.getItem(STORAGE_KEY)).toBe('system') + // Custom ids are in-process extension themes; only the built-in product + // preferences cross the Host settings schema. + expect(persist).toHaveBeenCalledTimes(1) + expect(persist).toHaveBeenCalledWith('system') // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() @@ -88,16 +96,11 @@ describe('ThemeService', () => { expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) - it('runs without localStorage (node boots): defaults on read, no-op on write', () => { - vi.stubGlobal('localStorage', undefined) - try { - const { theme } = make() - expect(theme.getTheme().preference).toBe('system') - theme.setTheme('dark') - expect(theme.getTheme().preference).toBe('dark') - } finally { - vi.unstubAllGlobals() - } + it('uses a no-op persistence callback when constructed directly', () => { + const ctx = new Context() + const theme = new ThemeService(ctx) + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') }) describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 7d5cc6f235..6b15b210d6 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../connection" + }, { "path": "../locale" }, @@ -23,6 +26,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..5035572ba4 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: c1e818fa8ff52b10e722d9fd450073a6aede85da +README.zh.md: dfac19fa04d6b934c86733cb2a075017740ed37a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..c1e818fa8f 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `permission` and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..dfac19fa04 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f528b2297e..c03d54caab 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -74,7 +74,7 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' const DEFAULT_MAX_MESSAGES = 50 /** Non-model settings namespaces intentionally served to the Web client. */ -const WEB_SETTINGS_NAMESPACES = ['permission'] as const +const WEB_SETTINGS_NAMESPACES = ['permission', 'ui-theme'] as const /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..cc16519f65 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -308,8 +308,8 @@ describe('settings domain', () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane remains opt-in, so a // future internal plugin cannot become remotely configurable just by - // registering; permission and the product onboarding namespace are the - // non-model namespaces intentionally admitted by this surface. + // registering; permission, theme, and the product onboarding namespace + // are the non-model namespaces intentionally admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) @@ -318,15 +318,23 @@ describe('settings domain', () => { }), { base: { defaultPreset: 'read-only' }, }) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), + })) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission']) + expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission', 'ui-theme']) const permission = expectOk(await api.settings.mutate(request({ ns: 'permission', ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], }))) expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) + const theme = expectOk(await api.settings.mutate(request({ + ns: 'ui-theme', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }))) + expect(theme.value).toEqual({ preference: 'dark' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), @@ -340,19 +348,29 @@ describe('settings domain', () => { expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({}) }) - it('serves the product onboarding namespace without invalidating the model catalog', async () => { + it('serves product preference namespaces without invalidating the model catalog', async () => { const ctx = await harness() ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() })) + ctx.settings.register(settingsNamespace('ui-theme'), z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), + })) const api = createApiProxy(ctx, DEFAULTS) expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) - .toEqual(['ui-onboarding']) - const frames = await collectHost(api, ['host/settings-changed'], 1, async () => { + .toEqual(['ui-onboarding', 'ui-theme']) + const frames = await collectHost(api, ['host/settings-changed'], 2, async () => { expectOk(await api.settings.mutate(request({ ns: 'ui-onboarding', ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], }))) + expectOk(await api.settings.mutate(request({ + ns: 'ui-theme', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + }))) }) - expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) + expect(frames).toEqual([ + { type: 'host/settings-changed', ns: 'ui-onboarding' }, + { type: 'host/settings-changed', ns: 'ui-theme' }, + ]) }) it('refuses even a model-provider namespace once its directory entry is gone', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaaed3c423..cffbbaf2bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2130,10 +2130,19 @@ importers: packages/client/ui-theme: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings clsx: specifier: ^2.0.0 version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From 40a4c45e865afe677067771e1d8a9d9b7caa42f8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:03:38 +0800 Subject: [PATCH 33/73] test(ui-layout): provide theme connection seam --- packages/client/ui-layout/tests/apply.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 903591163c..a82ea083e3 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -18,9 +18,10 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) - // Theme now injects ['slots', 'locale'] (it registers its Appearance - // settings row); seat a real locale service so the theme fiber activates. + // Theme registers its Appearance settings row and requires the connection + // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleService(ctx)) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } From a7e43d4346647546ea0f489566f130e45495d27e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:26:50 +0800 Subject: [PATCH 34/73] 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 35/73] 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 36/73] 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 0833b29f25378eaad903021a77b6d1414136ad3a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 16:43:59 +0800 Subject: [PATCH 37/73] fix(web): persist general preferences in host settings --- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...-06-host-backed-web-preferences.i18n.yaml} | 6 +- .../2026-08-06-host-backed-web-preferences.md | 41 +++ ...26-08-06-host-backed-web-preferences.zh.md | 41 +++ ...-08-06-host-backed-web-theme-preference.md | 39 --- ...-06-host-backed-web-theme-preference.zh.md | 39 --- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +- .../2026-07-30-web-queue-steer-action.md | 4 +- .../2026-07-30-web-queue-steer-action.zh.md | 4 +- ...1-browser-derived-initial-locale.i18n.yaml | 4 +- ...26-07-31-browser-derived-initial-locale.md | 8 +- ...07-31-browser-derived-initial-locale.zh.md | 8 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- apps/web/tests/assembled-boot.ts | 49 +++- apps/web/tests/settings-chrome.e2e.ts | 90 +++++-- apps/web/tests/support.ts | 13 +- docs/event-producer-consumer.md | 4 +- packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- packages/client/locale/package.json | 8 +- packages/client/locale/src/client/index.ts | 89 ++++--- packages/client/locale/src/index.ts | 35 ++- packages/client/locale/src/locale-settings.ts | 22 ++ packages/client/locale/tests/apply.spec.ts | 54 +++- packages/client/locale/tests/host.spec.ts | 30 +++ .../client/locale/tests/invariant.spec.ts | 8 +- packages/client/locale/tests/locale.spec.ts | 35 +-- packages/client/locale/tsconfig.json | 3 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 + .../runtime/src/client/settings-preference.ts | 160 ++++++++++++ .../runtime/tests/settings-preference.spec.ts | 237 ++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 9 +- .../ui-conversation/src/client/apply.ts | 14 +- .../client/contract/composer-submission.ts | 9 +- .../src/client/input/submission-policy.ts | 58 ++--- packages/client/ui-conversation/src/index.ts | 37 ++- .../src/submission-settings.ts | 25 ++ .../tests/apply-inject.spec.tsx | 1 + .../tests/assembly-surfaces.spec.tsx | 4 + .../ui-conversation/tests/chat-apply.spec.tsx | 1 + .../tests/chat-code-subcalls.spec.tsx | 1 + .../tests/chat-toolview-slot.spec.tsx | 2 + .../tests/coverage-tails.spec.tsx | 7 +- .../client/ui-conversation/tests/host.spec.ts | 37 +++ .../tests/submission-policy.spec.ts | 50 ++-- packages/client/ui-conversation/tsconfig.json | 6 + .../ui-subagent/tests/browser-plugin.spec.ts | 8 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/client/ui-theme/package.json | 1 - packages/client/ui-theme/src/client/index.ts | 41 +-- .../ui-theme/src/client/theme-settings.ts | 100 -------- packages/client/ui-theme/src/index.ts | 6 +- .../client/ui-theme/src/theme-settings.ts | 7 +- packages/client/ui-theme/tests/apply.spec.ts | 32 ++- .../client/ui-theme/tests/invariant.spec.ts | 4 +- .../ui-theme/tests/theme-settings.spec.ts | 149 ----------- packages/client/ui-theme/tests/theme.spec.ts | 3 +- packages/client/ui-theme/tsconfig.json | 3 - 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 | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 24 +- pnpm-lock.yaml | 25 +- vitest.config.ts | 9 +- 78 files changed, 1153 insertions(+), 615 deletions(-) rename .agents/notes/implemented/bug-fix/{2026-08-06-host-backed-web-theme-preference.i18n.yaml => 2026-08-06-host-backed-web-preferences.i18n.yaml} (55%) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md create mode 100644 packages/client/locale/src/locale-settings.ts create mode 100644 packages/client/locale/tests/host.spec.ts create mode 100644 packages/client/runtime/src/client/settings-preference.ts create mode 100644 packages/client/runtime/tests/settings-preference.spec.ts create mode 100644 packages/client/ui-conversation/src/submission-settings.ts create mode 100644 packages/client/ui-conversation/tests/host.spec.ts delete mode 100644 packages/client/ui-theme/src/client/theme-settings.ts delete mode 100644 packages/client/ui-theme/tests/theme-settings.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index 2efe235e28..f8e6600a47 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c -2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675 +2026-07-30-client-locale-full-rollout.md: 0faf4e0424e037b59b24d32f7fa987ac36497691 +2026-07-30-client-locale-full-rollout.zh.md: 895a2b4e87d2734bad27724f56b3205d81ac755e diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 09baf58760..0faf4e0424 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. -**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 806916aea1..895a2b4e87 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -25,7 +25,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 -**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`(boot 前钉 `dsh.locale=en`),built-boot snapshot 同样钉 en——golden 对语言迁移免疫;settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为初始 locale 跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 +**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot snapshot 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 [settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` 重注册刷新 label"的机制已被本决定取代(thunk + revision 生命周期)。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 7e804aad59..13dd2d5672 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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/bug-fix/2026-08-06-host-backed-web-theme-preference.md -2026-08-06-host-backed-web-theme-preference.md: 129132586b0d0ccfdb5b32fdaa1f7178a7176db7 -2026-08-06-host-backed-web-theme-preference.zh.md: 0c2dafff3fc3cec49a2261e31a61ecf99a10f126 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +2026-08-06-host-backed-web-preferences.md: ee1c0aea360eb1a4b34eadc86c5c3091abc6663a +2026-08-06-host-backed-web-preferences.zh.md: 376e670f9af39f43783a1447498ca2d4c65a49cd diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md new file mode 100644 index 0000000000..ee1c0aea36 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -0,0 +1,41 @@ +# Agent Note: Persist Web user preferences through Host settings + +Status: implemented + +English | [中文](2026-08-06-host-backed-web-preferences.zh.md) + +## Problem + +The Web Appearance, Language, and busy-Enter preferences lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different partition and lost choices even though both processes used the same DSH home. These are user-level product preferences; session selection, drafts, disclosure state, and other transient browser state remain page-local. + +The first theme implementation moved only Appearance to Host settings but awaited its initial RPC before providing `ThemeService`. A slow or unavailable settings request therefore suspended the assembled page. It also subscribed after the read, could miss an invalidation in that window, did not carry namespace revisions on writes, and allowed queued writes from a disposed plugin to reach the Host. + +## Decision + +The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. + +The client runtime provides one `bindSettingsPreference` lifecycle for scalar preferences. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Domain services publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then accept a validated Host value without writing it back. + +User changes update the live service synchronously and queue a `settings.mutate` path operation. The controller serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. + +Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference. + +## Alternatives considered + +**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host relay would recreate the settings service around a browser-specific format. + +**Mirror Host settings into `localStorage`.** A second authority requires boot and invalidation conflict rules while retaining the partition that caused the defect. The Host document is the sole durable source. + +**Await the initial read to avoid a provisional render.** Configuration availability is not a prerequisite for drawing the page. A background read may cause one live convergence, but it keeps failure isolated and preserves the existing browser/system/default fallbacks. + +**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas and decoders keep product policy out of the shared runtime. + +**Move every `localStorage` entry into settings.** Current session, drafts, panel disclosure, trajectory display state, and similar entries are browser-instance state rather than user configuration. Promoting them would synchronize transient navigation state across tabs and ports without a product contract. + +## Consequences + +Appearance, Language, and busy-Enter choices follow the DSH user home across reloads, ports, and loopback origins. Direct edits to `settings.yaml` converge through the existing invalidation stream, while legacy `dsh.theme`, `dsh.locale`, and `dsh.conversation.busyEnter` entries are neither read nor written. + +Boot may briefly show the domain default before the background read settles. A transient read failure keeps that default or the last good in-process value; reconnect retries. A write rejection can visibly restore the durable preference after the immediate local change. + +Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md new file mode 100644 index 0000000000..376e670f9a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 通过 Host settings 持久化 Web 用户偏好 + +Status: implemented + +[English](2026-08-06-host-backed-web-preferences.md) | 中文 + +## 问题 + +Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区并丢失选择,即使两个进程使用同一个 DSH home。这些是用户级产品偏好;会话选择、草稿、折叠展开状态和其他瞬态浏览器状态仍保留在页面内。 + +第一版主题实现只把 Appearance 移入 Host settings,但会在提供 `ThemeService` 之前等待初始 RPC。缓慢或不可用的 settings 请求因而会挂起组装后的页面。该实现还在读取后才建立订阅,可能错过此窗口内的失效通知;它写入时不携带 namespace revision,并且允许已释放插件所排队的写入到达 Host。 + +## 决策 + +各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 + +客户端运行时为标量偏好提供一份 `bindSettingsPreference` 生命周期。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。领域服务会立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后接纳已校验的 Host 值,但不将其写回。 + +用户变更会同步更新实时服务,并将一项 `settings.mutate` 路径操作排入队列。控制器会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,控制器会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 + +远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 + +## 曾考虑的替代方案 + +**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 中继会围绕浏览器特有格式重新实现一套 settings 服务。 + +**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会要求另外定义启动与失效时的冲突规则,同时依然保留造成该缺陷的分区。Host settings 文档是唯一的持久化真源。 + +**等待初始读取,以避免暂定渲染。** 绘制页面不以配置可用为前置条件。后台读取可能引发一次实时收敛,但它会隔离失败,并保留既有的浏览器/系统/默认回落路径。 + +**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema 和解码器,可以避免把产品政策放入共享运行时。 + +**把每个 `localStorage` 条目都移入 settings。** 当前会话、草稿、面板展开状态、trajectory 显示状态和类似条目属于浏览器实例状态,而非用户配置。将它们提升为设置,会在没有产品契约的情况下,跨标签页和端口同步短暂导航状态。 + +## 后果 + +Appearance、Language 和繁忙态 Enter 选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin。直接编辑 `settings.yaml` 所产生的变更会通过现有失效流收敛,而旧的 `dsh.theme`、`dsh.locale` 和 `dsh.conversation.busyEnter` 条目既不会被读取,也不会被写入。 + +启动时可能会在后台读取结算前短暂显示领域默认值。短暂的读取失败会保留该默认值或上一个正确的进程内值;重连时会重试。写入被拒时,界面可能会在本地值立即变化后明显恢复为持久化偏好。 + +聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md deleted file mode 100644 index 129132586b..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Persist the Web theme through Host settings - -Status: implemented - -English | [中文](2026-08-06-host-backed-web-theme-preference.zh.md) - -## Problem - -The Web theme preference lived in browser `localStorage`. Browser storage is scoped to an origin, so reopening `dsh web` on another port selected a different storage partition and returned to the default system theme even though both processes used the same DSH home. - -The theme is a user-level product preference rather than page-local state. DSH already has a user-settings service with a file-backed provider, a loopback-only configuration wire, and invalidation frames for external edits and other tabs. - -## Decision - -The `@deepseek-ai/dsh-client-ui-theme` Host half registers `ui-theme.preference` with the built-in `light`, `dark`, and `system` values and a `system` default. The local settings provider stores an override in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. - -The loopback client loads that namespace before it provides `ThemeService`, so the initial presenter snapshot reflects the durable preference without relying on an origin cache. `ThemeService.setTheme` still changes the live snapshot synchronously; its persistence callback sends a `settings.mutate` path operation. The controller serializes rapid selections in gesture order, ignores stale settlements, reloads after a rejected latest write, and refetches on `settings/changed` or `connection/reset`. - -The API proxy explicitly exposes `ui-theme` beside `permission` and `ui-onboarding`. Registration alone remains insufficient to cross the configuration boundary. Remote browsers cannot call the privileged settings API and retain only a process-local selection. - -Only the built-in product preferences cross the Host schema. Third-party registered theme ids remain an in-process extension because the Host cannot validate a browser plugin's dynamic registry during startup. - -## Alternatives considered - -**Keep `localStorage` and copy values between ports.** One origin cannot enumerate another origin's storage, and a Host-side relay would recreate a settings service around a browser-specific format. - -**Use a cookie without an explicit port.** Cookies would couple preference durability to the served hostname, still split localhost aliases, and introduce HTTP state outside the user-settings ownership model. - -**Mirror Host settings into `localStorage`.** A second authority creates boot and invalidation conflict rules while retaining the origin partition that caused the defect. The Host document is the sole durable source. - -**Expose every registered settings namespace.** Automatic exposure would let an unrelated plugin become remotely configurable by registering with the general settings seam. The API proxy keeps an explicit allowlist. - -## Consequences - -Theme selections follow the DSH user home across reloads, ports, and loopback origins, and direct edits to `settings.yaml` converge through the existing invalidation stream. The settings document contains a readable section such as `ui-theme: { preference: dark }`; no theme value is written to `localStorage`. - -Startup performs one loopback settings read before publishing the theme service. A transient read failure keeps the system default or last good in-process value and reconnect can retry. A write rejection can visibly restore the durable preference after the immediate theme change. - -Unit coverage pins schema registration, ordered writes, stale-response containment, failure recovery, invalidation refresh, and remote memory mode. The real Web settings scenario writes dark through the UI, verifies the YAML document, reloads, and boots a second Host on another port against the same DSH home with an empty theme `localStorage` partition. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md deleted file mode 100644 index 0c2dafff3f..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 通过 Host settings 持久化 Web 主题 - -Status: implemented - -[English](2026-08-06-host-backed-web-theme-preference.md) | 中文 - -## 问题 - -Web 主题偏好原本存在浏览器 `localStorage` 中。浏览器存储以 origin 为作用域,因此换一个端口重新打开 `dsh web` 会选中另一个存储分区,并回到默认的系统主题,即使两个进程使用同一个 DSH home。 - -主题是用户级产品偏好,而非页面局部状态。DSH 已有用户 settings 服务及其基于文件的提供方,也已有仅限回环请求的配置协议,并为外部编辑和其他标签页提供失效帧。 - -## 决策 - -`@deepseek-ai/dsh-client-ui-theme` 的 Host half 注册 `ui-theme.preference`,可取内置值 `light`、`dark` 与 `system`,默认值为 `system`。本地 settings 提供方将覆盖值存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。 - -来自回环地址的客户端会在提供 `ThemeService` 之前加载该 namespace,因此初始呈现器快照会反映持久化偏好,无需依赖按 origin 划分的缓存。`ThemeService.setTheme` 仍会同步更新实时快照;它的持久化回调会发送一项 `settings.mutate` 路径操作。控制器按操作顺序串行处理连续快速选择,忽略陈旧操作的结算结果,在最新写入被拒后重新加载持久化值,并在发生 `settings/changed` 或 `connection/reset` 时重新拉取。 - -API 代理会显式暴露 `ui-theme`,与 `permission` 和 `ui-onboarding` 并列。仅注册该设置,仍不足以跨越配置边界。远程浏览器无法调用特权 settings API,其主题选择仅保留在进程内。 - -只有产品内置偏好才会跨越 Host schema。第三方注册的主题 id 仍是进程内扩展,因为 Host 无法在启动期间校验浏览器插件的动态注册表。 - -## 曾考虑的替代方案 - -**保留 `localStorage`,并在不同端口间复制值。** 一个 origin 无法枚举另一个 origin 的存储,而 Host 侧中继会围绕浏览器特有格式重新实现一套 settings 服务。 - -**使用不显式包含端口的 cookie。** Cookie 会将偏好的持久性与提供服务的 hostname 耦合,localhost 的不同 alias 仍会各自分区,还会在用户 settings 的所有权模型之外引入 HTTP 状态。 - -**将 Host settings 镜像到 `localStorage`。** 第二个权威来源会导致启动与失效时需要另外定义冲突规则,同时依然保留造成该缺陷的 origin 分区。Host 侧 settings 文档是唯一的持久化真源。 - -**暴露所有已注册的 settings namespace。** 自动暴露会让与本功能无关的插件仅凭向通用 settings seam 注册,就成为可远程配置的插件。API 代理保留一份显式 allowlist。 - -## 后果 - -主题选择会跟随 DSH 用户 home,跨越重新加载、端口与回环 origin;直接编辑 `settings.yaml` 所产生的变更也会通过现有失效流收敛。settings 文档包含形如 `ui-theme: { preference: dark }` 的可读分节;不会向 `localStorage` 写入主题值。 - -启动时会在发布主题服务之前执行一次回环 settings 读取。短暂的读取失败会保留系统默认值或上一个正确的进程内值,并可在重连时重试。写入被拒时,界面可能会在主题立即变化后明显恢复为持久化偏好。 - -单元测试覆盖 schema 注册、有序写入、陈旧响应隔离、故障恢复、失效刷新与远程端仅内存模式。真实 Web settings 场景通过 UI 写入 dark,校验 YAML 文档,重新加载,再使用同一个 DSH home 在另一个端口上启动第二个 Host,此时主题 `localStorage` 分区为空。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 624da25dc2..104f49af8a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: b04095b81f499982c8680a2d3627d8e98a70d8ac -2026-07-30-web-queue-steer-action.zh.md: b04902b8a8a0d727b01aa6ba5562e12cc5d36c92 +2026-07-30-web-queue-steer-action.md: 2718c5b3cc95f1ab02db80230ba158d9b5c3b4e6 +2026-07-30-web-queue-steer-action.zh.md: 2abc4747ca85d7059dd8bdd86f4d7c5314f41f24 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index b04095b81f..2718c5b3cc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -20,7 +20,7 @@ Activating the action requests strict current-turn steering for that exact `Inbo The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface. -The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The browser persists the preference, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. +The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The Host settings document persists the preference across Web origins sharing one DSH home, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. ### Agent and lifecycle boundary @@ -38,7 +38,7 @@ The Host's existing `queuedMirror` remains the sole transient inbox authority. I When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending the durable `user/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row. -The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. +The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser submission policy owns the live busy-Enter preference while the Host settings service owns durability; the policy resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions, and the Settings row and InputBar share it without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. ### Verification diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index b04902b8a8..2abc4747ca 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -20,7 +20,7 @@ Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。 -Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。浏览器会持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 +Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。Host settings 文档会在共享同一 DSH home 的 Web origin 之间持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 ### Agent 与生命周期边界 @@ -38,7 +38,7 @@ Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/q AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。 -现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 +现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器提交策略拥有实时繁忙态 Enter 偏好,而 Host settings 服务拥有持久性;该策略只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势,Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 ### 验证 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index d1fb6cb6c7..05e3f4b9e2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 0c49a6bbfec0ab33a5aa3ce53dde0cac747f3816 -2026-07-31-browser-derived-initial-locale.zh.md: c013d24dcd3bb49d176eaddd42ff41dde320ff1f +2026-07-31-browser-derived-initial-locale.md: 3fed32ad46f01ef3f88f3182a1cb21f40031ca1b +2026-07-31-browser-derived-initial-locale.zh.md: d47243fb6c9dc2269e1401454b92a528a0f4476a diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 0c49a6bbfe..3fed32ad46 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -10,15 +10,15 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r ## Decision -**The initial locale resolves through three ordered sources: the persisted preference, then the browser, then `FALLBACK_LOCALE`.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and is the only place the order is expressed; `restorePreference()` now returns `LocaleId | undefined` (an absent, unparseable, or unreachable store reads as *no preference*) so the next source can speak. +**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. **`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. -**An explicit choice is permanent.** `setLocale` persistence is untouched, and the persisted value is consulted first, so a user who picked a language keeps it even when travelling between browser profiles or system languages. Nothing writes the detected locale back to storage: detection is re-derived every boot and stays invisible to the "has the user chosen?" question. +**An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. -**The browser e2e lane now pins the browser language, not just storage.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` keeps pinning `dsh.locale=en`, which still wins over any browser language. `settings-chrome.e2e.ts` gained a scenario opening a second `en-US` page with empty storage and asserting the settings surface comes up English — the assembled-app proof of this feature. +**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature. ## Alternatives considered @@ -33,4 +33,4 @@ The Settings Language row opened every first visit in Chinese: `LocaleService` r - A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. - `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen". - Tests that construct a `LocaleService` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all). -- Detection cost is one array walk per service construction, and no storage write, so boot behavior and the persisted-state surface are unchanged. +- Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index c013d24dcd..d47243fb6c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -10,15 +10,15 @@ Status: implemented ## Decision -**初始 locale 依次经三个来源解析:已持久化的偏好、浏览器、`FALLBACK_LOCALE`。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,是这一顺序的唯一表达处;`restorePreference()` 现在返回 `LocaleId | undefined`(存储项缺失、无法解析或不可访问,一律读作*没有偏好*),后一个来源才有开口的机会。 +**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 **判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 -**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对"用户是否做过选择"这一问题始终不可见。 +**显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 -**浏览器 e2e 车道现在钉住浏览器语言,而不只是存储项。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 仍然钉 `dsh.locale=en`,它依旧压过任何浏览器语言。`settings-chrome.e2e.ts` 新增一个场景:另开一个存储项为空的 `en-US` 页面,断言设置界面以英文呈现——这是本功能在组装后应用中的证据。 +**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。 ## Alternatives considered @@ -33,4 +33,4 @@ Status: implemented - 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 - `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当"用户尚未选择"。 - 在 jsdom 下构造 `LocaleService` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。 -- 探测的代价是每次服务构造遍历一次数组,且不写存储,因此启动行为与持久化状态面均无变化。 +- 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 638e91a016..9373b3fe79 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f +2026-07-24-web-gui-browser-e2e-lane.md: 7bbe584fe75973aa5da22054e1b220538328d153 +2026-07-24-web-gui-browser-e2e-lane.zh.md: f966dd494b64b17f7692a7aa55161ebc98dc393e diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index f8519a9622..7bbe584fe7 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; the scenarios asserting Chinese copy leave storage unset and open a `zh-CN` browser instead, because the client derives its initial locale from `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)), and `settings-chrome.e2e.ts` additionally covers both switch directions and the English-browser default. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home. ### Expected outputs diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 294f3e840e..f966dd494b 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则不预设该存储项,改为开启 `zh-CN` 浏览器,因为客户端的初始 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md)),而 `settings-chrome.e2e.ts` 还额外覆盖双向切换与英文浏览器默认态。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。 ### 预期输出 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 0e168ba9fe..d4244e8495 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -18,11 +18,46 @@ 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-theme', + dir: 'ui-theme', + url: '/plugins/ui-theme.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-locale', + ], + immediately: true, + }, + { + id: '@deepseek-ai/dsh-client-locale', + dir: 'locale', + url: '/plugins/locale.js', + rev: 'fx', + inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime'], + 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', '@deepseek-ai/dsh-client-ui-theme'], + }, { 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-conversation', + dir: 'ui-conversation', + url: '/plugins/ui-conversation.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-locale', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-layout', + ], + }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', @@ -66,7 +101,8 @@ let unmount: (() => void) | undefined export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() - localStorage.setItem('dsh.locale', 'en') + Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true }) + Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }) document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => @@ -84,6 +120,9 @@ export function installAssembledBootEnv(): void { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') + const ownNavigator = navigator as unknown as Record + delete ownNavigator.languages + delete ownNavigator.language vi.unstubAllGlobals() }) } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 919f3b242c..774ceb332f 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -2,9 +2,8 @@ // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> Host settings // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) -// the Language row (settings-scoped localization + persisted dsh.locale), -// the busy-state Enter preference, plus Permission as the persisted default -// for subsequently created sessions. +// the Language row and busy-state Enter preference (both Host-backed), plus +// Permission as the persisted default for subsequently created sessions. // Zero model calls: everything is pure client + persistence state on a blank // frame, so there is no fixture and a stray stream would fail loud on the // open llm seam. @@ -183,19 +182,18 @@ describe('web e2e: settings modal and General preferences', () => { .toMatch(/ui-theme:\n\s+preference: dark/) await page.keyboard.press('Escape') - // Reload: the preference survives boot (restore + presenter initial apply). + // Reload: the preference survives the background Host read + presenter update. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.emulateMedia({ colorScheme: 'light' }) - const reloaded = await readState() - expect(reloaded.attr).toBe(true) - expect(reloaded.legacy).toBeNull() + await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true) + expect((await readState()).legacy).toBeNull() // A second live Host binds another ephemeral port but shares the same - // user-settings home. Its fresh origin has no theme localStorage and must - // still render dark before the settings dialog opens. + // user-settings home. Its fresh origin has no theme localStorage and still + // converges to dark before the settings dialog opens. const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const secondTripwire = watchConsole(secondPage) @@ -204,9 +202,8 @@ describe('web e2e: settings modal and General preferences', () => { await secondPage.emulateMedia({ colorScheme: 'light' }) await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const crossPort = await readState(secondPage) - expect(crossPort.attr).toBe(true) - expect(crossPort.legacy).toBeNull() + await expect.poll(async () => (await readState(secondPage)).attr, { timeout: 5_000 }).toBe(true) + expect((await readState(secondPage)).legacy).toBeNull() expect(secondTripwire.pageErrors).toEqual([]) expect(secondTripwire.warnings).toEqual([]) } finally { @@ -230,7 +227,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('persists the busy-state Enter behavior across reload and restores Queue', async () => { + it('persists the busy-state Enter behavior across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -238,7 +235,9 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '排队发送' }).click() await page.getByRole('menuitem', { name: '插话发送' }).click() await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer') + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-conversation:\n\s+busyEnter: steer/) await page.keyboard.press('Escape') const warningStart = tripwire.warnings.length @@ -248,15 +247,36 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const reloaded = page.getByRole('dialog', { name: '设置' }) await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await secondPage.getByRole('button', { name: '设置', exact: true }).click() + await secondPage.getByRole('dialog', { name: '设置' }) + .getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + expect(await secondPage.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } + await reloaded.getByRole('button', { name: '插话发送' }).click() await page.getByRole('menuitem', { name: '排队发送' }).click() await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue') + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-conversation:\n\s+busyEnter: queue/) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('switches the settings surface language and persists dsh.locale', async () => { + it('persists the settings language across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language')) await page.getByRole('button', { name: '设置', exact: true }).click() const zhDialog = page.getByRole('dialog', { name: '设置' }) @@ -273,7 +293,9 @@ describe('web e2e: settings modal and General preferences', () => { await enDialog.waitFor({ timeout: 10_000 }) expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) - expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('en') + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/locale:\n\s+preference: en/) // Reload keeps English; then restore zh so shared page state (and the // other specs' 设置-anchored selectors + goldens) see the default again. const warningStart = tripwire.warnings.length @@ -282,24 +304,47 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) const enTrigger = page.getByRole('button', { name: 'Settings' }) await enTrigger.waitFor({ timeout: 10_000 }) + + // A Chinese browser on another port still receives the explicit English + // preference from the shared Host settings document. + const second = await launchWebScaffold({ harnessHome: scaffold.harnessHome }) + const secondPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const secondTripwire = watchConsole(secondPage) + try { + expect(second.baseUrl).not.toBe(scaffold.baseUrl) + await secondPage.goto(second.baseUrl, { waitUntil: 'load' }) + await secondPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await secondPage.getByRole('button', { name: 'Settings', exact: true }).click() + await secondPage.getByRole('dialog', { name: 'Settings' }) + .getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + expect(await secondPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + expect(secondTripwire.pageErrors).toEqual([]) + expect(secondTripwire.warnings).toEqual([]) + } finally { + await secondPage.close() + await second.close() + } + await enTrigger.click() await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click() await page.getByRole('menuitem', { name: '中文' }).click() await page.getByRole('dialog', { name: '设置' }).waitFor({ timeout: 10_000 }) - expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBe('zh') + expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/locale:\n\s+preference: zh/) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 90_000) it('opens an English browser in English without any stored preference', async () => { - // A second page under a different browser language: nothing is persisted - // for it, so the settings surface must follow the browser rather than the - // product fallback the shared zh page shows. + // A fresh Host home has no locale preference, so its surface follows the + // browser rather than the product fallback. + const fresh = await launchWebScaffold({}) const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) const enTripwire = watchConsole(enPage) onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language')) try { - await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await enPage.goto(fresh.baseUrl, { waitUntil: 'load' }) await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await enPage.getByRole('button', { name: 'Settings', exact: true }).click() @@ -312,6 +357,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(enTripwire.warnings).toEqual([]) } finally { await enPage.close() + await fresh.close() } }, 90_000) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 1b7b67aab3..40b9be39ca 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -18,18 +18,17 @@ export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) export const ZH_BROWSER_LOCALE = 'zh-CN' /** - * Open the standard browser-test page with English selected before client - * boot. This keeps role locators and goldens deterministic across localized - * component migrations; the scenarios asserting the Chinese surface bypass - * this helper and advertise {@link ZH_BROWSER_LOCALE} instead. + * Open the standard browser-test page advertising English before client boot. + * This keeps role locators and goldens deterministic while leaving the Host + * settings document free to override the provisional browser-derived locale; + * scenarios asserting the Chinese surface advertise + * {@link ZH_BROWSER_LOCALE} instead. * @param browser - Playwright browser owning the page. * @param height - Viewport height; width is fixed to the lane baseline. * @returns the initialized page. */ export async function newEnglishPage(browser: Browser, height = 1000): Promise { - const page = await browser.newPage({ viewport: { width: 1680, height } }) - await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) - return page + return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' }) } /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 59b20a6762..e0c5ec838a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,14 +62,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | +| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | -| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general`, `ui-theme` | +| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index d1ef53207f..3918beb028 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/locale/README.md -README.md: f1efefde4557e1c29c0556f8b670f1534430ab79 -README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291 +README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604 +README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index f1efefde45..5bea46cd4e 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). +Locale plugin: LocaleService — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index a8b5704d28..2333bc7c2b 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。 +locale 插件:LocaleService——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 ## 模型体验 diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 75742a80d0..cfadff76b8 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-locale", - "description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row", + "description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime" ], "platform": "web", @@ -31,6 +32,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -47,6 +49,10 @@ "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, + "dependencies": { + "@deepseek-ai/dsh-settings": "workspace:^", + "schemastery": "^3.18.0" + }, "files": [ "lib/index.js", "lib/invariant.js", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 5d195ee275..ac694b0bce 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -13,7 +13,10 @@ import type { Context } from 'cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { + isLocaleId, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from '../locale-settings.ts' import { en, zh, type CommonKey } from '../locales/index.ts' import { en as settingsEn, zh as settingsZh, type SettingsLocaleKey, @@ -26,6 +29,9 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' +export { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from '../locale-settings.ts' // The translate currency lives in ui-slots (the render machinery synthesizes // the seat); re-exported here so dictionary owners import one package. @@ -44,9 +50,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Locale dictionary: flat key to template string ({name} placeholders). */ export type LocaleDict = Record -/** Locale identifier: the two shipped locales. */ -export type LocaleId = 'zh' | 'en' - /** One selectable locale: id plus its self-described display name. */ export interface LocaleDefinition { /** Locale id (persisted; the setLocale argument). */ @@ -91,9 +94,6 @@ export const COMMON_NS = 'common' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.locale' -/** localStorage key holding the persisted locale id. */ -export const STORAGE_KEY = 'dsh.locale' - /** The two shipped locales. */ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ { id: 'zh', label: '中文' }, @@ -116,15 +116,26 @@ export class LocaleService { private snapshot: LocaleSnapshot private listeners = new Set<() => void>() private readonly ctx: Context + private persist: (id: LocaleId) => void /** * @param ctx - owning context (change events are emitted on it). + * @param persist - durable write callback for explicit locale selections. */ - constructor(ctx: Context) { + constructor(ctx: Context, persist: (id: LocaleId) => void = () => {}) { this.ctx = ctx + this.persist = persist this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 }) } + /** + * Bind the owning plugin's durable writer before the service is provided. + * @param persist - callback accepting explicit locale changes. + */ + bindPersistence(persist: (id: LocaleId) => void): void { + this.persist = persist + } + /** * Read the current immutable locale snapshot. * @returns the current snapshot (stable reference until the next change). @@ -155,16 +166,24 @@ export class LocaleService { } /** - * Switch the active locale — the only preference write entry. Persists the - * id and emits `locale/change`. + * Switch the active locale — the only user preference write entry. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return - persistPreference(match.id) this.publish(match.id, true) + this.persist(match.id) + } + + /** + * Apply an explicit Host preference without writing it back. + * @param id - validated shipped locale. + */ + syncPreference(id: LocaleId): void { + if (this.snapshot.active === id) return + this.publish(id, true) } /** @@ -288,27 +307,11 @@ export class LocaleService { } /** - * The locale a fresh service opens with: an explicit preference the user - * already chose wins over the browser's own language, which in turn wins over - * {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language - * this app does not ship). + * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit + * Host preference may replace this provisional value after plugin activation. */ function resolveInitialLocale(): LocaleId { - return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE -} - -/** Read the persisted locale id; unknown or unreadable values read as no preference. */ -function restorePreference(): LocaleId | undefined { - // Non-browser runs (node e2e booting the client tree) have no localStorage. - if (typeof localStorage === 'undefined') return undefined - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'zh' || stored === 'en') return stored - } catch { - // Storage access can throw (privacy mode); an unreadable store simply - // records no preference, and the browser language decides instead. - } - return undefined + return detectBrowserLocale() ?? FALLBACK_LOCALE } /** @@ -325,8 +328,7 @@ function detectBrowserLocale(): LocaleId | undefined { /* oxlint-disable-next-line typescript/no-unnecessary-condition -- * The DOM lib types `languages` as always present; embedders and older * WebViews ship a Navigator without it, and spreading undefined would - * throw at boot. Same environment-boundary distrust as the localStorage - * guards below. */ + * throw at boot. */ for (const tag of [...(navigator.languages ?? []), navigator.language]) { const primary = tag.toLowerCase().split('-')[0] const match = LOCALES.find(locale => locale.id === primary) @@ -335,19 +337,8 @@ function detectBrowserLocale(): LocaleId | undefined { return undefined } -/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ -function persistPreference(id: LocaleId): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(STORAGE_KEY, id) - } catch { - // Storage access can throw (privacy mode / quota); the preference simply - // does not survive the session. - } -} - -/** Required services: the slot registry (the feature registers its own settings row). */ -export const inject = ['slots'] +/** Required services: slot registration plus the settings transport. */ +export const inject = ['slots', 'connection'] /** * Client plugin body: provide the locale service with base dictionaries and @@ -357,8 +348,16 @@ export const inject = ['slots'] */ export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) + const browserLocale = locale.getLocale().active locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) + const controller = bindSettingsPreference(ctx, { + namespace: LOCALE_SETTINGS_NAMESPACE, + field: LOCALE_PREFERENCE_FIELD, + decode: value => isLocaleId(value) ? value : browserLocale, + sync: (id) => { locale.syncPreference(id) }, + }) + locale.bindPersistence((id) => { void controller.persist(id) }) ctx.provide('locale', locale) // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it // so the render machinery can synthesize the `t` standard seat. diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index c220373932..09afbef04e 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,4 +1,33 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host registration for the browser locale preference. */ -/** Host plugin body — no host-side behavior for the locale plugin. */ -export function apply(): void {} +import type { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from './locale-settings.ts' + +export { + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, +} from './locale-settings.ts' + +interface LocaleSettings { + preference?: LocaleId +} + +const LocaleSettingsSchema: z = z.object({ + [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), +}) + +/** + * Register the durable locale section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(LOCALE_SETTINGS_NAMESPACE), + LocaleSettingsSchema, + ) + }) +} diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts new file mode 100644 index 0000000000..dd1ad39339 --- /dev/null +++ b/packages/client/locale/src/locale-settings.ts @@ -0,0 +1,22 @@ +/** Locale preference stored in the Host user-settings document. */ + +/** Settings namespace owned by the locale plugin. */ +export const LOCALE_SETTINGS_NAMESPACE = 'locale' + +/** Field carrying an explicit locale selection; absence delegates to the browser. */ +export const LOCALE_PREFERENCE_FIELD = 'preference' + +/** Locale identifiers shipped by the browser client. */ +export const LOCALE_IDS = ['zh', 'en'] as const + +/** Shipped locale identifier. */ +export type LocaleId = typeof LOCALE_IDS[number] + +/** + * Narrow one settings-wire value to a shipped locale. + * @param value - value crossing the settings boundary. + * @returns whether the value names a shipped locale. + */ +export function isLocaleId(value: unknown): value is LocaleId { + return LOCALE_IDS.some(locale => locale === value) +} diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index a3007f8c78..2bd424a974 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -4,7 +4,9 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client' +import { + apply, inject, LOCALE_SETTINGS_NAMESPACE, SETTINGS_NS, +} from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -14,7 +16,36 @@ const SLOT = 'settings.general.item' async function bench() { const ctx = new Context() await ctx.plugin(SlotsService).await() - return { ctx, slots: ctx.get('slots') as SlotsService } + let preference: string | undefined + let revision = 0 + const namespace = () => ({ + ns: LOCALE_SETTINGS_NAMESPACE, + schema: {}, + value: preference === undefined ? {} : { preference }, + applies: 'live' as const, + secrets: [], + revision, + }) + const describe = vi.fn(async () => ({ + rpcId: 'locale-describe' as never, + result: { + ok: true as const, + value: { writable: true, hasDocument: true, namespaces: [namespace()] }, + }, + })) + const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { + preference = request.ops[0]!.value + revision += 1 + return { + rpcId: 'locale-mutate' as never, + result: { ok: true as const, value: namespace() }, + } + }) + ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, describe, mutate, + setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, + } } /** Stand in for the settings shell: declare the General item slot from root. */ @@ -47,7 +78,7 @@ describe('locale apply', () => { }) it('declares the slot service', () => { - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'connection']) }) it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { @@ -91,6 +122,23 @@ describe('locale apply', () => { expect(locale.getLocale().active).toBe('zh') expect(instance.getSnapshot().active).toBe('zh') expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') + await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) }) + }) + + it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { + const b = await bench() + b.setHostPreference('en') + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const locale = b.ctx.get('locale') as LocaleService + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + b.setHostPreference(undefined) + b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) + b.setHostPreference('en') + b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + expect(b.describe).toHaveBeenCalledTimes(3) }) it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { diff --git a/packages/client/locale/tests/host.spec.ts b/packages/client/locale/tests/host.spec.ts new file mode 100644 index 0000000000..8fa339e660 --- /dev/null +++ b/packages/client/locale/tests/host.spec.ts @@ -0,0 +1,30 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + LOCALE_SETTINGS_NAMESPACE, apply, +} from '@deepseek-ai/dsh-client-locale' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('locale host', () => { + it('registers an optional explicit locale preference with the Host settings lifecycle', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({}) + await ctx.settings.update(ns, { preference: 'en' }) + expect(ctx.settings.get(ns)).toEqual({ preference: 'en' }) + await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index fa62ca79f6..2b362cb115 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -14,16 +14,16 @@ describe('invariant companion', () => { await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined() }) - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract + it('node-half apply tolerates a Host without settings', () => { + nodeApply(new Context()) }) it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { // The feature registers its own Language settings row, hence the slots edge. - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'connection']) const ctx = new Context() new SlotsService(ctx) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin({ inject, apply: clientApply }).await() const locale = ctx.get('locale') expect(locale).toBeInstanceOf(LocaleService) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 442701dbb3..9215bd51e6 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { const ctx = new Context() @@ -24,7 +24,6 @@ const stubLanguages = (...tags: string[]): void => { describe('LocaleService', () => { beforeEach(() => { - localStorage.clear() // A Chinese browser is the baseline these specs assert their zh state on. stubLanguages('zh-CN') }) @@ -132,16 +131,19 @@ describe('LocaleService', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { + it('setLocale requests persistence, republishes an immutable snapshot, and no-ops on same value', () => { const { svc, events } = make() + const persist = vi.fn() + svc.bindPersistence(persist) svc.setLocale('en') expect(svc.getLocale().active).toBe('en') - expect(localStorage.getItem(STORAGE_KEY)).toBe('en') + expect(persist).toHaveBeenCalledWith('en') expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) svc.setLocale('en') expect(events).toHaveLength(1) + expect(persist).toHaveBeenCalledOnce() }) it('throws on unknown locale ids', () => { @@ -149,14 +151,19 @@ describe('LocaleService', () => { expect(() => { svc.setLocale('fr') }).toThrow('not registered') }) - it('restores a persisted locale over the browser language, and garbage reads as no preference', () => { - localStorage.setItem(STORAGE_KEY, 'en') - expect(make().svc.getLocale().active).toBe('en') - localStorage.setItem(STORAGE_KEY, 'fr') - expect(make().svc.getLocale().active).toBe('zh') + it('syncs a Host preference over the browser language without writing it back', () => { + const { svc, events } = make() + const persist = vi.fn() + svc.bindPersistence(persist) + svc.syncPreference('en') + expect(svc.getLocale().active).toBe('en') + expect(events).toHaveLength(1) + expect(persist).not.toHaveBeenCalled() + svc.syncPreference('en') + expect(events).toHaveLength(1) }) - it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => { + it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => { stubLanguages('en-GB', 'zh-CN') expect(make().svc.getLocale().active).toBe('en') stubLanguages('zh-Hant-TW') @@ -176,8 +183,7 @@ describe('LocaleService', () => { expect(make().svc.getLocale().active).toBe('zh') }) - it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => { - vi.stubGlobal('localStorage', undefined) + it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => { vi.stubGlobal('window', undefined) // Node exposes its own global navigator; without a window it must not // reach the resolution at all. @@ -188,12 +194,11 @@ describe('LocaleService', () => { expect(svc.getLocale().active).toBe('en') }) - it('keeps the browser language out of the way once a preference exists', () => { + it('lets an explicit in-process preference replace the browser-derived value', () => { stubLanguages('en-US') const { svc } = make() svc.setLocale('zh') - expect(localStorage.getItem(STORAGE_KEY)).toBe('zh') - expect(make().svc.getLocale().active).toBe('zh') + expect(svc.getLocale().active).toBe('zh') }) it('exposes the two shipped locales with self-described labels', () => { diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8585ba74ca..313c11f5bf 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 23c867e4c0..5698297cb4 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27 -README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d +README.md: c05089badb29ad0e22ed1f66d7804eccbb11c1d4 +README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 8ac29a4258..c05089badb 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +`bindSettingsPreference` is the browser lifecycle for one domain-owned scalar setting. It subscribes before starting a nonblocking initial read, serializes writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. Loopback pages use the Host settings API; remote pages stay in memory. Domain packages own the namespace schema, value guard, default, and live service rather than putting product policy in runtime. + ## Slot declaration injection `ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 0e065e43ec..ccbb96266c 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,6 +4,8 @@ 客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +`bindSettingsPreference` 是单项由领域持有的标量设置所用的浏览器生命周期。它在开始非阻塞初始读取前建立订阅,使用已知最新 namespace revision 串行写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。回环页面使用 Host settings API,远程页面则只保留内存状态。namespace schema、取值校验器、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 + ## Slot 声明注入 `ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 06f88a9131..2854a16659 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -21,6 +21,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' +export { bindSettingsPreference, SettingsPreferenceController } from './settings-preference.ts' +export type { SettingsPreferenceSpec } from './settings-preference.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' export type { diff --git a/packages/client/runtime/src/client/settings-preference.ts b/packages/client/runtime/src/client/settings-preference.ts new file mode 100644 index 0000000000..a459999cc7 --- /dev/null +++ b/packages/client/runtime/src/client/settings-preference.ts @@ -0,0 +1,160 @@ +/** Host-backed scalar preference synchronization for browser plugins. */ + +import type { Context } from 'cordis' +import type { + ConnectionHandle, IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' + +/** Domain-owned description of one scalar field in a settings namespace. */ +export interface SettingsPreferenceSpec { + /** Settings namespace registered by the owning Host plugin. */ + namespace: string + /** Scalar field inside that namespace. */ + field: string + /** Validate a wire value; undefined leaves the current in-process value active. */ + decode(value: unknown): T | undefined + /** Apply a validated Host value without writing it back. */ + sync(value: T): void +} + +type SettingsFace = Pick + +/** + * Serializes one scalar preference's Host reads and writes. Reads never block + * plugin activation; writes carry the latest known namespace revision and + * teardown waits for the operation already crossing the wire. + */ +export class SettingsPreferenceController { + private tail: Promise = Promise.resolve() + private readGeneration = 0 + private writeGeneration = 0 + private revision: number | undefined + private disposed = false + + /** + * @param api - settings wire face. + * @param spec - namespace, field validator, and live target. + * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly spec: SettingsPreferenceSpec, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} + + /** + * Queue a Host refresh; a newer read or user write suppresses stale publication. + * @returns settlement after the queued read completes or is skipped. + */ + load(): Promise { + const generation = ++this.readGeneration + return this.enqueue(() => this.read(generation)) + } + + /** + * Queue one user preference write. Rapid selections preserve mutation order, + * while only the latest settlement may resynchronize the live target. + * @param value - validated domain preference selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + persist(value: T): Promise { + this.readGeneration += 1 + const generation = ++this.writeGeneration + return this.enqueue(async () => { + let response: Awaited> + try { + response = await this.api.settings.mutate({ + ns: this.spec.namespace, + ops: [{ op: 'set', path: [this.spec.field], value }], + ...(this.revision === undefined ? {} : { expectedRevision: this.revision }), + }) + } catch (_settingsWriteFailure) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + if (!response.result.ok) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + this.accept(response.result.value, generation === this.writeGeneration) + }) + } + + /** + * Stop queued operations and wait for the current wire call to settle. + * @returns settlement after the controller reaches quiescence. + */ + async dispose(): Promise { + this.disposed = true + this.readGeneration += 1 + this.writeGeneration += 1 + await this.tail + } + + private enqueue(operation: () => Promise): Promise { + if (this.persistence === 'memory' || this.disposed) return Promise.resolve() + const task = this.tail.then(async () => { + if (this.disposed) return + await operation() + }) + // The returned task carries its own settlement to the caller; the queue + // tail is kept fulfilled so one failed target callback cannot strand later operations. + this.tail = task.catch(() => {}) + return task + } + + private async read(generation: number): Promise { + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + return + } + if (!response.result.ok || this.disposed) return + const view = response.result.value.namespaces.find(candidate => candidate.ns === this.spec.namespace) + if (view === undefined) return + this.accept(view, generation === this.readGeneration) + } + + private accept(view: SettingsNamespaceView, publish: boolean): void { + this.revision = view.revision + if (!publish || typeof view.value !== 'object' || view.value === null) return + const value = this.spec.decode((view.value as Record)[this.spec.field]) + if (value !== undefined) this.spec.sync(value) + } +} + +/** + * Bind one controller to settings and connection invalidations on the caller's + * plugin lifecycle. Listeners exist before the initial background read starts. + * @param ctx - owning browser plugin context. + * @param spec - domain-owned scalar preference contract. + * @returns the bound controller used by the domain's user-write callback. + */ +export function bindSettingsPreference( + ctx: Context, + spec: SettingsPreferenceSpec, +): SettingsPreferenceController { + const connection = ctx.get('connection') as ConnectionHandle + const controller = new SettingsPreferenceController( + connection.api, + spec, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const refresh = (namespace?: string): void => { + if (namespace !== undefined && namespace !== spec.namespace) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + void controller.load() + return async () => { + for (const dispose of disposers) dispose() + await controller.dispose() + } + }, `runtime: ${spec.namespace}.${spec.field} preference`) + return controller +} diff --git a/packages/client/runtime/tests/settings-preference.spec.ts b/packages/client/runtime/tests/settings-preference.spec.ts new file mode 100644 index 0000000000..a93df780bb --- /dev/null +++ b/packages/client/runtime/tests/settings-preference.spec.ts @@ -0,0 +1,237 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + bindSettingsPreference, SettingsPreferenceController, +} from '../src/client/settings-preference.ts' + +type Preference = 'light' | 'dark' | 'system' + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(): RpcResponse { + return { + rpcId: `preference-${rpc++}` as never, + result: { + ok: false, + error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, + }, + } +} + +function view(value: unknown, revision = 0): SettingsNamespaceView { + return { + ns: 'ui-test', + schema: {}, + value, + applies: 'live', + secrets: [], + revision, + } +} + +function described(value: unknown, revision = 0) { + return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +function spec(values: Preference[]) { + return { + namespace: 'ui-test', + field: 'preference', + decode: (value: unknown): Preference | undefined => + value === 'light' || value === 'dark' || value === 'system' ? value : undefined, + sync: (value: Preference) => { values.push(value) }, + } +} + +describe('SettingsPreferenceController', () => { + it('loads only a valid owned field and contains unavailable transports', async () => { + const values: Preference[] = [] + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'sepia' })) + .mockResolvedValueOnce(described(null)) + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const controller = new SettingsPreferenceController({ settings: { describe } } as never, spec(values)) + for (let i = 0; i < 6; i++) await controller.load() + expect(values).toEqual(['dark']) + }) + + it('serializes rapid writes, carries revisions, and publishes only the latest settlement', async () => { + const first = deferred>() + const values: Preference[] = [] + const describe = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await controller.load() + const dark = controller.persist('dark') + const light = controller.persist('light') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + first.resolve(ok(view({ preference: 'dark' }, 5))) + await Promise.all([dark, light]) + expect(values).toEqual(['system', 'light']) + expect(mutate).toHaveBeenNthCalledWith(1, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + expectedRevision: 4, + }) + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 5, + }) + }) + + it('recovers the latest rejected or thrown write from Host state', async () => { + const values: Preference[] = [] + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + .mockResolvedValueOnce(described({ preference: 'light' }, 3)) + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await controller.persist('dark') + await controller.persist('system') + expect(values).toEqual(['system', 'light']) + }) + + it('does not recover superseded rejected or thrown writes', async () => { + const values: Preference[] = [] + const describe = vi.fn() + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec(values), + ) + await Promise.all([ + controller.persist('dark'), + controller.persist('system'), + controller.persist('light'), + ]) + expect(describe).not.toHaveBeenCalled() + expect(values).toEqual(['light']) + }) + + it('keeps the queue usable when a target callback throws', async () => { + const describe = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' })) + .mockResolvedValueOnce(described({ preference: 'sepia' })) + const controller = new SettingsPreferenceController( + { settings: { describe } } as never, + { ...spec([]), sync: () => { throw new Error('target failed') } }, + ) + await expect(controller.load()).rejects.toThrow('target failed') + await expect(controller.load()).resolves.toBeUndefined() + }) + + it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { + const first = deferred>() + const mutate = vi.fn().mockReturnValue(first.promise) + const values: Preference[] = [] + const controller = new SettingsPreferenceController( + { settings: { mutate } } as never, + spec(values), + ) + const dark = controller.persist('dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const light = controller.persist('light') + let stopped = false + const stop = controller.dispose().then(() => { stopped = true }) + await Promise.resolve() + expect(stopped).toBe(false) + first.resolve(ok(view({ preference: 'dark' }, 1))) + await Promise.all([dark, light, stop]) + await controller.persist('system') + await controller.load() + expect(mutate).toHaveBeenCalledOnce() + expect(values).toEqual([]) + }) + + it('keeps remote-browser preferences in memory without Host calls', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const controller = new SettingsPreferenceController( + { settings: { describe, mutate } } as never, + spec([]), + 'memory', + ) + await controller.load() + await controller.persist('dark') + await controller.dispose() + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) +}) + +describe('bindSettingsPreference', () => { + it('subscribes before the initial read and converges to the latest queued invalidation', async () => { + const initial = deferred>() + const describe = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe } }, + isLoopback: true, + } as never) + const values: Preference[] = [] + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (scope: Context) => { bindSettingsPreference(scope, spec(values)) }, + }) + await fiber.await() + await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) + ctx.emit('settings/changed', 'unrelated') + ctx.emit('settings/changed', 'ui-test') + ctx.emit('connection/reset') + initial.resolve(described({ preference: 'dark' }, 1)) + await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(values).toEqual(['system']) }) + await fiber.dispose() + ctx.emit('settings/changed', 'ui-test') + await Promise.resolve() + expect(describe).toHaveBeenCalledTimes(3) + }) + + it('binds a remote browser in memory without starting a settings read', async () => { + const describe = vi.fn() + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe } }, + isLoopback: false, + } as never) + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (scope: Context) => { bindSettingsPreference(scope, spec([])) }, + }) + await fiber.await() + await fiber.dispose() + expect(describe).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 78169601c8..c1f1278e61 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: 8d6c26f67916f043251c58a3283542bd58a08666 -README.zh.md: 8dd43cca59f8dfda18ce036b5d8c6f948306c947 +README.md: 2789265d867e8e1e23f97e01b2ea7d12960a188c +README.zh.md: 9707c8b64f872fae52bb0c5900f5db403ed75e59 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8d6c26f679..2789265d86 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the Host-backed `ui-conversation.busyEnter` General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; the local settings provider stores it in `$DSH_HOME/settings.yaml`, so the choice follows the same user home across Web ports. Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8dd43cca59..9707c8b64f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,由 Host settings 支撑的 `ui-conversation.busyEnter` General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;本地 settings 提供方将其存入 `$DSH_HOME/settings.yaml`,因此该选择会跟随同一个用户 home 跨越 Web 端口。Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index ff4e74da5e..6503f3cbd6 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", - "description": "Conversation domain: skeleton (header/tabs/composer), chat view, ctx.toolviews registry, minimal details panel", + "description": "Conversation domain: shell, chat and tool views, input policy with Host-backed busy-Enter preference, and details panel", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" @@ -36,9 +37,12 @@ }, "license": "BSD-3-Clause", "dependencies": { - "clsx": "^2.0.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", + "schemastery": "^3.18.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^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", @@ -50,6 +54,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..8f61c30e01 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,7 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -36,6 +36,9 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' +import { + BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, isBusyEnterBehavior, +} from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -45,7 +48,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -97,6 +100,13 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() const submissionPolicy = new ComposerSubmissionPolicy() + const preference = bindSettingsPreference(ctx, { + namespace: CONVERSATION_SETTINGS_NAMESPACE, + field: BUSY_ENTER_FIELD, + decode: value => isBusyEnterBehavior(value) ? value : undefined, + sync: (behavior) => { submissionPolicy.syncPreference(behavior) }, + }) + submissionPolicy.bindPersistence((behavior) => { void preference.persist(behavior) }) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts index c5bcdc7826..23d9df94c1 100644 --- a/packages/client/ui-conversation/src/client/contract/composer-submission.ts +++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts @@ -1,10 +1,11 @@ /** Composer submission vocabulary shared by the input and settings domains. */ -/** Delivery mode requested for one ordinary composer message. */ -export type InputSubmitMode = 'queue' | 'steer' +import type { BusyEnterBehavior } from '../../submission-settings.ts' -/** Configurable meaning of plain Enter while the addressed agent is busy. */ -export type BusyEnterBehavior = InputSubmitMode +export type { BusyEnterBehavior } from '../../submission-settings.ts' + +/** Delivery mode requested for one ordinary composer message. */ +export type InputSubmitMode = BusyEnterBehavior /** Keyboard gesture whose delivery mode the submission policy resolves. */ export type ComposerSubmitGesture = 'enter' | 'accelerated' diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 6ef87e42c8..968406972c 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -1,5 +1,5 @@ /** - * Browser-local Composer submission policy. It owns the persisted busy-Enter + * Composer submission policy. It owns the live busy-Enter * preference and resolves keyboard gestures into queue/steer delivery modes; * Host and Agent keep the actual delivery-window authority. */ @@ -7,12 +7,9 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' +import { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' -/** localStorage key holding the busy-Enter preference. */ -export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter' - -/** Default preserves Enter-as-Queue for running conversations. */ -export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' +export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' /** * Persisted policy used by both the composer inject face and its Settings row. @@ -21,7 +18,21 @@ export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' */ export class ComposerSubmissionPolicy { /** Reactive preference source for the Settings row. */ - readonly busyEnter: SnapshotStore = createSnapshotStore(restoreBusyEnter()) + readonly busyEnter: SnapshotStore = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR) + private persist: (behavior: BusyEnterBehavior) => void + + /** @param persist - durable write callback for explicit behavior changes. */ + constructor(persist: (behavior: BusyEnterBehavior) => void = () => {}) { + this.persist = persist + } + + /** + * Bind the owning plugin's durable writer before the policy is exposed. + * @param persist - callback accepting explicit behavior changes. + */ + bindPersistence(persist: (behavior: BusyEnterBehavior) => void): void { + this.persist = persist + } /** * Resolve one keyboard gesture without changing state. @@ -42,36 +53,21 @@ export class ComposerSubmissionPolicy { } /** - * Change and persist the plain-Enter behavior used during busy state. + * Change the plain-Enter behavior used during busy state. * @param behavior - Queue or Steer. */ setBusyEnter(behavior: BusyEnterBehavior): void { if (this.busyEnter.getSnapshot() === behavior) return this.busyEnter.set(behavior) - persistBusyEnter(behavior) + this.persist(behavior) } -} -/** Restore a valid preference; unavailable or corrupt storage uses Queue. */ -function restoreBusyEnter(): BusyEnterBehavior { - if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR - let stored: string | null - try { - stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY) - } catch { - // Storage access can fail in privacy modes; the default remains usable. - return DEFAULT_BUSY_ENTER_BEHAVIOR - } - if (stored === 'queue' || stored === 'steer') return stored - return DEFAULT_BUSY_ENTER_BEHAVIOR -} - -/** Persist a preference when browser storage is available. */ -function persistBusyEnter(behavior: BusyEnterBehavior): void { - if (typeof localStorage === 'undefined') return - try { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior) - } catch { - // A storage failure makes the preference session-only; input stays usable. + /** + * Apply a Host preference without writing it back. + * @param behavior - validated behavior from settings. + */ + syncPreference(behavior: BusyEnterBehavior): void { + if (this.busyEnter.getSnapshot() === behavior) return + this.busyEnter.set(behavior) } } diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 142d3853e3..2377c8a73f 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,4 +1,35 @@ -/** Host loader entry for the browser-only conversation plugin. */ +/** Host registration for browser conversation preferences. */ -/** Provides no host-side behavior. */ -export function apply(): void {} +import type { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, +} from './submission-settings.ts' + +export { + BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, +} from './submission-settings.ts' + +interface ConversationSettings { + busyEnter: BusyEnterBehavior +} + +const ConversationSettingsSchema: z = z.object({ + [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), +}) + +/** + * Register the durable conversation section when a settings provider exists. + * @param ctx - Host context whose optional settings service owns the section. + */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE), + ConversationSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts new file mode 100644 index 0000000000..a1ba6e082c --- /dev/null +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -0,0 +1,25 @@ +/** Busy-Enter preference stored in the Host user-settings document. */ + +/** Settings namespace owned by the conversation plugin. */ +export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation' + +/** Field carrying the delivery mode for plain Enter while an agent is busy. */ +export const BUSY_ENTER_FIELD = 'busyEnter' + +/** Busy-Enter behaviors accepted at settings and input boundaries. */ +export const BUSY_ENTER_BEHAVIORS = ['queue', 'steer'] as const + +/** Configurable meaning of plain Enter while the addressed agent is busy. */ +export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number] + +/** Default preserves Enter-as-Queue for running conversations. */ +export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' + +/** + * Narrow one settings-wire value to a busy-Enter behavior. + * @param value - value crossing the settings boundary. + * @returns whether the value names a supported behavior. + */ +export function isBusyEnterBehavior(value: unknown): value is BusyEnterBehavior { + return BUSY_ENTER_BEHAVIORS.some(behavior => behavior === value) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 49682fea52..6868422eb2 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -47,6 +47,7 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 16163065eb..d6c8a8d106 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -96,6 +96,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -183,6 +184,7 @@ describe('terminal card assembly', () => { describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -201,6 +203,7 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -270,6 +273,7 @@ describe('resident composer', () => { describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index df8fff6719..1a906235c8 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -24,6 +24,7 @@ const CHILD = 'child-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) await runtime.sessions.add( { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 8c4af6a921..88dc16a838 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -137,6 +137,7 @@ async function bench(snapshot: ConversationSnapshot) { } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) const locale = new LocaleService(ctx) ctx.provide('locale', locale) slots.installLocale(locale) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index e2319157ea..12e4011964 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -62,6 +62,7 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layout) const locale = new LocaleService(runtime.ctx) @@ -193,6 +194,7 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index c92e43db6c..6f9f91da73 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot, -// bash sample state dots, the node-half empty apply, and AssistantMarkdown +// bash sample state dots, the node-half optional settings registration, and AssistantMarkdown // reasoning/unknown block arms. import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -25,8 +26,8 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) afterEach(cleanup) describe('tails', () => { - it('node-half apply is an intentional no-op', () => { - expect(() => { nodeApply() }).not.toThrow() + it('node-half apply tolerates a Host without settings', () => { + expect(() => { nodeApply(new Context()) }).not.toThrow() }) it('ToolRow stopped state renders the warning dot in the leading slot', () => { diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts new file mode 100644 index 0000000000..bb16273d64 --- /dev/null +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -0,0 +1,37 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply, +} from '@deepseek-ai/dsh-client-ui-conversation' +import { isBusyEnterBehavior } from '../src/submission-settings.ts' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-conversation host', () => { + it('narrows settings-wire values to the supported behavior pair', () => { + expect(isBusyEnterBehavior('queue')).toBe(true) + expect(isBusyEnterBehavior('steer')).toBe(true) + expect(isBusyEnterBehavior('later')).toBe(false) + }) + + it('registers, validates, and disposes the durable busy-Enter preference', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(CONVERSATION_SETTINGS_NAMESPACE) + expect(ctx.settings.get(ns)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) + await ctx.settings.update(ns, { busyEnter: 'steer' }) + expect(ctx.settings.get(ns)).toEqual({ busyEnter: 'steer' }) + await expect(ctx.settings.update(ns, { busyEnter: 'invalid' })).rejects.toThrow() + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) +}) diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts index 5b892982ab..6519e9f9d3 100644 --- a/packages/client/ui-conversation/tests/submission-policy.spec.ts +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -1,14 +1,9 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, + ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, } from '../src/client/input/submission-policy.ts' -afterEach(() => { - vi.unstubAllGlobals() - localStorage.clear() -}) - describe('ComposerSubmissionPolicy', () => { it('defaults to Queue and only applies the preference while running', () => { const policy = new ComposerSubmissionPolicy() @@ -21,6 +16,8 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', false)).toBe('queue') const changed = vi.fn() + const persist = vi.fn() + policy.bindPersistence(persist) policy.busyEnter.subscribe(changed) policy.setBusyEnter('steer') expect(changed).toHaveBeenCalledTimes(1) @@ -28,40 +25,25 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', true)).toBe('queue') expect(policy.resolve(false, 'enter', true)).toBe('queue') expect(policy.resolve(false, 'accelerated', true)).toBe('queue') - expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer') + expect(persist).toHaveBeenCalledWith('steer') }) - it('restores a valid preference and leaves an identical write untouched', () => { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer') - const write = vi.spyOn(Storage.prototype, 'setItem') - const policy = new ComposerSubmissionPolicy() + it('syncs a Host preference without writing it back and leaves an identical write untouched', () => { + const persist = vi.fn() + const policy = new ComposerSubmissionPolicy(persist) + policy.syncPreference('steer') expect(policy.busyEnter.getSnapshot()).toBe('steer') policy.setBusyEnter('steer') - expect(write).not.toHaveBeenCalled() - write.mockRestore() + expect(persist).not.toHaveBeenCalled() }) - it('uses Queue for invalid, unavailable, or unreadable storage', () => { - localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid') - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - - vi.stubGlobal('localStorage', undefined) - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - - vi.stubGlobal('localStorage', { - getItem: () => { throw new Error('blocked') }, - setItem: vi.fn(), - }) - expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') - }) - - it('keeps the in-memory preference when persistence throws', () => { - vi.stubGlobal('localStorage', { - getItem: () => null, - setItem: () => { throw new Error('quota') }, - }) + it('publishes the in-memory preference before calling the durable writer', () => { const policy = new ComposerSubmissionPolicy() + const persist = vi.fn(() => { + expect(policy.busyEnter.getSnapshot()).toBe('steer') + }) + policy.bindPersistence(persist) policy.setBusyEnter('steer') - expect(policy.busyEnter.getSnapshot()).toBe('steer') + expect(persist).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 04b265bdd5..f2b78f7dfe 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection" + }, { "path": "../ui-slots" }, @@ -47,6 +50,9 @@ { "path": "../locale" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 09221e1e94..30d956fc39 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,7 +18,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' -import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -84,8 +84,9 @@ async function fullBench(sessions: SessionSummary[]) { const face = sessionsWith(sessions) ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await provideSlotFaces(ctx) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -119,8 +120,9 @@ describe('apply', () => { const ctx = new Context() await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await provideSlotFaces(ctx) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 04fd1e81c2..84438c92a4 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 32868bcac4313a3badfe92dbf41c84e793f09709 -README.zh.md: a38765b8004826133875c38deeb66128d52ec986 +README.md: b79eac0d7777ac7af9b6a8960dc4d9797b41513d +README.zh.md: c57ccbdb8fdfb735b3a5d0d66f3538dd01966ada diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 32868bcac4..b79eac0d77 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser loads `ui-theme.preference` before providing the service and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema. Contract: api-contracts v3 §8; the [Host-backed preference decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md) owns the persistence boundary. +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. Contract: api-contracts v3 §8; the [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index a38765b800..c57ccbdb8f 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会在提供该服务前加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-theme-preference.md)拥有。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。契约:api-contracts v3 §8;该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7635da17b8..a4f9a78d37 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -44,7 +44,6 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 497f4a22f1..05c8a741af 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -8,28 +8,24 @@ * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' import { AppearanceRow } from './AppearanceRow.tsx' import { createAppearanceRowStore } from './settings-store.ts' -import { ThemeSettingsController } from './theme-settings.ts' import { en, zh, type ThemeKey } from './locales.ts' import { - DEFAULT_PREFERENCE, isThemePreference, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' -export type { ThemePreferenceTarget } from './theme-settings.ts' -export { ThemeSettingsController } from './theme-settings.ts' export type { ThemeKey } from './locales.ts' export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from '../theme-settings.ts' @@ -196,7 +192,6 @@ export class ThemeService { this.themes = this.themes.filter(t => t.id !== definition.id) if (this.preference === definition.id) { this.preference = DEFAULT_PREFERENCE - this.persist(this.preference) } this.publish() } @@ -235,33 +230,17 @@ export const inject = ['slots', 'locale', 'connection'] * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export async function apply(ctx: ClientContext): Promise { - const connection = ctx.get('connection') as ConnectionHandle +export function apply(ctx: ClientContext): void { const theme = new ThemeService(ctx) - const controller = new ThemeSettingsController( - connection.api, - theme, - connection.isLoopback ? 'host' : 'memory', - ) + const controller = bindSettingsPreference(ctx, { + namespace: THEME_SETTINGS_NAMESPACE, + field: THEME_PREFERENCE_FIELD, + decode: value => isThemePreference(value) ? value : undefined, + sync: (preference) => { theme.syncPreference(preference) }, + }) theme.bindPersistence((preference) => { void controller.persist(preference) }) - await controller.load() ctx.provide('theme', theme) - ctx.effect(() => { - const refresh = (ns?: string): void => { - if (ns !== undefined && ns !== THEME_SETTINGS_NAMESPACE) return - void controller.load() - } - const disposers = [ - ctx.on('settings/changed', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - return () => { - controller.dispose() - for (const dispose of disposers) dispose() - } - }, 'ui-theme: settings invalidations') - ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') const store = createAppearanceRowStore() diff --git a/packages/client/ui-theme/src/client/theme-settings.ts b/packages/client/ui-theme/src/client/theme-settings.ts deleted file mode 100644 index 66b332313b..0000000000 --- a/packages/client/ui-theme/src/client/theme-settings.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** Host-backed persistence controller for the browser theme preference. */ - -import type { - IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-client-connection/client' -import { - THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, isThemePreference, - type ThemePreference, -} from '../theme-settings.ts' - -/** Preference target implemented by {@link ThemeService}. */ -export interface ThemePreferenceTarget { - /** - * Apply a Host value without writing it back. - * @param preference - validated durable preference. - */ - syncPreference(preference: ThemePreference): void -} - -function preferenceOf(view: SettingsNamespaceView): ThemePreference | undefined { - if (typeof view.value !== 'object' || view.value === null) return undefined - const preference = (view.value as Record)[THEME_PREFERENCE_FIELD] - return isThemePreference(preference) ? preference : undefined -} - -/** Coordinates startup reads, ordered writes, and pushed invalidations. */ -export class ThemeSettingsController { - private generation = 0 - private writeTail: Promise = Promise.resolve() - - /** - * @param api - settings wire face. - * @param target - live theme service receiving durable values. - * @param persistence - remote browsers stay process-local because the settings API is loopback-only. - */ - constructor( - private readonly api: Pick, - private readonly target: ThemePreferenceTarget, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} - - /** - * Load the durable preference after earlier writes settle; the latest operation wins. - * @returns nothing; an unavailable or invalid descriptor leaves the last good value active. - */ - async load(): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') return - await this.writeTail - if (generation !== this.generation) return - let response: Awaited['settings']['describe']>> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - // A transport failure leaves the last good in-process theme active. A - // connection/reset or settings/changed notification retries the read. - return - } - if (!response.result.ok || generation !== this.generation) return - const view = response.result.value.namespaces.find( - candidate => candidate.ns === THEME_SETTINGS_NAMESPACE, - ) - if (view === undefined) return - const preference = preferenceOf(view) - if (preference !== undefined) this.target.syncPreference(preference) - } - - /** - * Persist one user selection. Writes are serialized so rapid picks land in - * gesture order; a rejected latest write reloads the durable value. - * @param preference - selected built-in preference. - * @returns nothing after the write or recovery read settles. - */ - async persist(preference: ThemePreference): Promise { - const generation = ++this.generation - if (this.persistence === 'memory') return - const write = this.writeTail.then(async () => { - const response = await this.api.settings.mutate({ - ns: THEME_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: preference }], - }) - if (!response.result.ok) throw new Error(response.result.error.message) - if (generation === this.generation) { - const accepted = preferenceOf(response.result.value) - if (accepted !== undefined) this.target.syncPreference(accepted) - } - }) - this.writeTail = write.catch(() => {}) - try { - await write - } catch { - if (generation === this.generation) await this.load() - } - } - - /** Prevent in-flight reads and writes from publishing after plugin disposal. */ - dispose(): void { - this.generation += 1 - } -} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 5f746d6d83..32d3689950 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -4,12 +4,12 @@ import type { Context } from 'cordis' import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from './theme-settings.ts' export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, + DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, } from './theme-settings.ts' @@ -18,7 +18,7 @@ interface ThemeSettings { } const ThemeSettingsSchema: z = z.object({ - [THEME_PREFERENCE_FIELD]: z.union(['light', 'dark', 'system']).default(DEFAULT_PREFERENCE), + [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), }) /** diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index e93b3c56e0..ca06ec28a7 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -1,5 +1,8 @@ /** Theme preferences stored in the Host user-settings document. */ +/** Built-in preferences accepted at the registry and settings boundaries. */ +export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const + /** Settings namespace owned by the theme plugin. */ export const THEME_SETTINGS_NAMESPACE = 'ui-theme' @@ -7,7 +10,7 @@ export const THEME_SETTINGS_NAMESPACE = 'ui-theme' export const THEME_PREFERENCE_FIELD = 'preference' /** Theme preference persisted by the product Appearance row. */ -export type ThemePreference = 'light' | 'dark' | 'system' +export type ThemePreference = typeof THEME_PREFERENCES[number] /** Default preference when the user-settings document has no override. */ export const DEFAULT_PREFERENCE: ThemePreference = 'system' @@ -18,5 +21,5 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system' * @returns whether the value is a built-in preference. */ export function isThemePreference(value: unknown): value is ThemePreference { - return value === 'light' || value === 'dark' || value === 'system' + return THEME_PREFERENCES.some(preference => preference === value) } diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 350ea0525a..d134340560 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -19,6 +19,12 @@ usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() @@ -122,7 +128,7 @@ describe('ui-theme apply', () => { declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeService - expect(theme.getTheme().preference).toBe('dark') + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) b.ctx.emit('settings/changed', 'unrelated') expect(b.describe).toHaveBeenCalledOnce() b.setHostPreference('light') @@ -142,6 +148,30 @@ describe('ui-theme apply', () => { expect(remote.mutate).not.toHaveBeenCalled() }) + it('activates before a slow initial settings read and converges when it settles', async () => { + const b = await bench() + b.setHostPreference('dark') + const describe = b.describe.getMockImplementation()! + const pending = deferred>>() + b.describe.mockImplementationOnce(() => pending.promise) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const theme = b.ctx.get('theme') as ThemeService + expect(theme.getTheme().preference).toBe('system') + pending.resolve(await describe()) + await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) + await fiber.dispose() + }) + + it('ignores an invalid preference crossing the settings wire', async () => { + const b = await bench() + b.setHostPreference('sepia') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() }) + expect(theme.getTheme().preference).toBe('system') + }) + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { const b = await bench() const host = declareItems(b.slots) diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 42a2651099..c5eedc9dd7 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -4,7 +4,7 @@ import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' -import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client' +import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -26,7 +26,6 @@ describe('invariant companion', () => { expect(inject).toEqual(['slots', 'locale', 'connection']) const ctx = new Context() new SlotsService(ctx) - await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() ctx.provide('connection', { api: { settings: { describe: () => Promise.resolve({ rpcId: 'theme-invariant' as never, @@ -34,6 +33,7 @@ describe('invariant companion', () => { }) } }, isLoopback: true, } as never) + await ctx.plugin({ inject: localeInject, apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tests/theme-settings.spec.ts b/packages/client/ui-theme/tests/theme-settings.spec.ts deleted file mode 100644 index b2b921a4c2..0000000000 --- a/packages/client/ui-theme/tests/theme-settings.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { - THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, ThemeSettingsController, - type ThemePreference, -} from '@deepseek-ai/dsh-client-ui-theme/client' - -let rpc = 0 - -function ok(value: T): RpcResponse { - return { rpcId: `theme-${rpc++}` as never, result: { ok: true, value } } -} - -function view(preference: unknown = 'system'): SettingsNamespaceView { - return { - ns: THEME_SETTINGS_NAMESPACE, - schema: {}, - value: { [THEME_PREFERENCE_FIELD]: preference }, - applies: 'live', - secrets: [], - revision: 0, - } -} - -function described(preference: unknown = 'system') { - return ok({ writable: true, hasDocument: true, namespaces: [view(preference)] }) -} - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } -} - -function target() { - const values: ThemePreference[] = [] - return { values, syncPreference: (preference: ThemePreference) => { values.push(preference) } } -} - -describe('ThemeSettingsController', () => { - it('loads a valid Host value and ignores unavailable or malformed namespaces', async () => { - const receiver = target() - const describe = vi.fn() - .mockResolvedValueOnce(described('dark')) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) - .mockResolvedValueOnce(described('sepia')) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [{ ...view(), value: null }] })) - .mockResolvedValueOnce({ - rpcId: 'failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'offline', details: {} } }, - }) - .mockRejectedValueOnce(new Error('transport offline')) - const controller = new ThemeSettingsController({ settings: { describe } } as never, receiver) - for (let i = 0; i < 6; i++) await controller.load() - expect(receiver.values).toEqual(['dark']) - }) - - it('persists ordered rapid selections and publishes only the latest settlement', async () => { - const first = deferred>>() - const calls: string[] = [] - const mutate = vi.fn(async (request: { ops: { value: string }[] }) => { - const preference = request.ops[0]!.value - calls.push(preference) - if (preference === 'dark') return first.promise - return ok(view(preference)) - }) - const receiver = target() - const controller = new ThemeSettingsController({ settings: { mutate } } as never, receiver) - const dark = controller.persist('dark') - const light = controller.persist('light') - await Promise.resolve() - expect(calls).toEqual(['dark']) - first.resolve(ok(view('dark'))) - await Promise.all([dark, light]) - expect(calls).toEqual(['dark', 'light']) - expect(receiver.values).toEqual(['light']) - expect(mutate).toHaveBeenNthCalledWith(1, { - ns: THEME_SETTINGS_NAMESPACE, - ops: [{ op: 'set', path: [THEME_PREFERENCE_FIELD], value: 'dark' }], - }) - }) - - it('reloads after a rejected latest write and contains stale reads and disposal', async () => { - const stale = deferred>() - const describe = vi.fn() - .mockImplementationOnce(() => stale.promise) - .mockResolvedValueOnce(described('system')) - const mutate = vi.fn().mockResolvedValue({ - rpcId: 'rejected' as never, - result: { ok: false as const, error: { code: 'settings-rejected' as const, message: 'disk full', details: {} } }, - }) - const receiver = target() - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - const oldLoad = controller.load() - await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) - await controller.persist('dark') - stale.resolve(described('light')) - await oldLoad - expect(receiver.values).toEqual(['system']) - - const disposedRead = deferred>() - describe.mockImplementationOnce(() => disposedRead.promise) - const pending = controller.load() - controller.dispose() - disposedRead.resolve(described('dark')) - await pending - expect(receiver.values).toEqual(['system']) - }) - - it('keeps remote-browser persistence in memory without calling Host settings', async () => { - const describe = vi.fn() - const mutate = vi.fn() - const receiver = target() - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver, 'memory') - await controller.load() - await controller.persist('dark') - expect(describe).not.toHaveBeenCalled() - expect(mutate).not.toHaveBeenCalled() - expect(receiver.values).toEqual([]) - }) - - it('reloads after a thrown write and ignores a malformed success response', async () => { - const receiver = target() - const describe = vi.fn().mockResolvedValue(described('light')) - const mutate = vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(ok(view('sepia'))) - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - await controller.persist('dark') - await controller.persist('system') - expect(receiver.values).toEqual(['light']) - }) - - it('lets an explicit refresh supersede a stale rejected write', async () => { - const rejected = deferred() - const receiver = target() - const describe = vi.fn().mockResolvedValue(described('system')) - const mutate = vi.fn().mockReturnValue(rejected.promise) - const controller = new ThemeSettingsController({ settings: { describe, mutate } } as never, receiver) - const write = controller.persist('dark') - await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - const refresh = controller.load() - rejected.reject(new Error('stale rejection')) - await Promise.all([write, refresh]) - expect(receiver.values).toEqual(['system']) - expect(describe).toHaveBeenCalledOnce() - }) -}) diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 68f0f3c7f8..f6d8a7ff62 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -71,8 +71,7 @@ describe('ThemeService', () => { expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) // Custom ids are in-process extension themes; only the built-in product // preferences cross the Host settings schema. - expect(persist).toHaveBeenCalledTimes(1) - expect(persist).toHaveBeenCalledWith('system') + expect(persist).not.toHaveBeenCalled() // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 6b15b210d6..f3ca3240c2 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,9 +8,6 @@ "src" ], "references": [ - { - "path": "../connection" - }, { "path": "../locale" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5035572ba4..f9ba0ce0a1 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: c1e818fa8ff52b10e722d9fd450073a6aede85da -README.zh.md: dfac19fa04d6b934c86733cb2a075017740ed37a +README.md: 2e7e50c2251a0cf0daa5821d210a34635acd57ea +README.zh.md: 2d2d732bbc1982f750991fc90de51b78c7ed1019 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c1e818fa8f..2e7e50c225 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `permission` and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index dfac19fa04..2d2d732bbc 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 80d4923dac..2b211f2b6f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -74,7 +74,7 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' const DEFAULT_MAX_MESSAGES = 50 /** Non-model settings namespaces intentionally served to the Web client. */ -const WEB_SETTINGS_NAMESPACES = ['permission', 'ui-theme'] as const +const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index cc16519f65..a803d2048f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -308,8 +308,8 @@ describe('settings domain', () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane remains opt-in, so a // future internal plugin cannot become remotely configurable just by - // registering; permission, theme, and the product onboarding namespace - // are the non-model namespaces intentionally admitted by this surface. + // registering; locale, permission, conversation, theme, and the product + // onboarding namespace are intentionally admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) @@ -321,10 +321,18 @@ describe('settings domain', () => { ctx.settings.register(settingsNamespace('ui-theme'), z.object({ preference: z.union(['light', 'dark', 'system']).default('system'), })) + ctx.settings.register(settingsNamespace('locale'), z.object({ + preference: z.union(['zh', 'en']).required(false), + })) + ctx.settings.register(settingsNamespace('ui-conversation'), z.object({ + busyEnter: z.union(['queue', 'steer']).default('queue'), + })) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission', 'ui-theme']) + expect(value.namespaces.map(view => view.ns)).toEqual([ + 'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation', + ]) const permission = expectOk(await api.settings.mutate(request({ ns: 'permission', ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], @@ -335,6 +343,16 @@ describe('settings domain', () => { ops: [{ op: 'set', path: ['preference'], value: 'dark' }], }))) expect(theme.value).toEqual({ preference: 'dark' }) + const locale = expectOk(await api.settings.mutate(request({ + ns: 'locale', + ops: [{ op: 'set', path: ['preference'], value: 'en' }], + }))) + expect(locale.value).toEqual({ preference: 'en' }) + const conversation = expectOk(await api.settings.mutate(request({ + ns: 'ui-conversation', + ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }], + }))) + expect(conversation.value).toEqual({ busyEnter: 'steer' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 562e645956..0809ab5150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1286,6 +1286,16 @@ importers: version: link:../../../vendor/cordis packages/client/locale: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: ^0.0.1 + version: link:../connection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -1480,10 +1490,19 @@ importers: packages/client/ui-conversation: dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings clsx: specifier: ^2.0.0 version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2142,6 +2161,9 @@ importers: packages/client/ui-theme: dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: ^0.0.1 + version: link:../connection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -2152,9 +2174,6 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..84e273f0da 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -160,8 +160,13 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', - 'packages/client/runtime/src/*', - 'packages/client/ui-conversation/src/*', + // This isolated scalar-settings lifecycle has complete unit coverage; + // keep it out of the broader client-runtime GUI debt exemption. + 'packages/client/runtime/src/**/!(settings-preference).ts', + // Keep the browser conversation tree under its existing GUI debt + // exemption while gating the newly stateful Host half and vocabulary. + 'packages/client/ui-conversation/src/client/*', + 'packages/client/ui-conversation/src/invariant.ts', 'packages/client/ui-slots/src/*', 'packages/client/ui-layout/src/*', 'packages/client/web/src/*', From 1dd2252d16252c9cf84595574dc605fbcb79b288 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 16:49:36 +0800 Subject: [PATCH 38/73] docs: refresh module graph --- docs/module-graph.md | 132 ++++++++++++++++++++++--------------------- 1 file changed, 67 insertions(+), 65 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index f84255b203..7dc0976cea 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -320,10 +320,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants pkg_client_test_runtime --> pkg_client_runtime pkg_client_test_runtime --> pkg_client_ui_slots pkg_client_test_runtime --> pkg_client_web_react @@ -378,6 +374,11 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_locale --> pkg_client_connection + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_client_ui_models --> pkg_client_connection pkg_client_ui_models --> pkg_client_runtime pkg_client_ui_models --> pkg_client_schema_form @@ -385,37 +386,6 @@ flowchart TD pkg_client_ui_models --> pkg_client_ui_slots pkg_client_ui_models --> pkg_client_web_react pkg_client_ui_models --> pkg_invariants - 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_connection - 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_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_invariants @@ -464,24 +434,41 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - 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_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_connection + 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_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_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -567,6 +554,10 @@ flowchart TD pkg_headless --> pkg_host_webserver pkg_headless --> pkg_invariants pkg_headless --> 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 @@ -574,10 +565,16 @@ flowchart TD pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants pkg_tmux_context --> 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_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_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -669,6 +666,7 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval + pkg_client_ui_conversation --> pkg_client_connection pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -679,6 +677,10 @@ 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_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -1154,7 +1156,6 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`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-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-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) | @@ -1173,13 +1174,8 @@ 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-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`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-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-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-connection`](../packages/client/connection), [`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) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`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) | @@ -1195,10 +1191,13 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`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-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-connection`](../packages/client/connection), [`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) | | [`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) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1220,9 +1219,11 @@ flowchart TD | [`user-approval`](../packages/ui/user-approval) | `ui` | [`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/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`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) | -| [`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) | +| [`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) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -1242,8 +1243,9 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | -| [`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` | [`client-connection`](../packages/client/connection), [`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) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/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) | | [`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) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | From e9f6d82b12d39e33a76f9017ccc75ff9d387dcb1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 19:30:22 +0800 Subject: [PATCH 39/73] fix(locale): keep settings constants private --- packages/client/locale/src/client/index.ts | 4 +--- packages/client/locale/tests/apply.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index ac694b0bce..228b3a3375 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -29,9 +29,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' -export { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, -} from '../locale-settings.ts' +export type { LocaleId } from '../locale-settings.ts' // The translate currency lives in ui-slots (the render machinery synthesizes // the seat); re-exported here so dictionary owners import one package. diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 2bd424a974..3f1f6b2c0a 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -5,9 +5,10 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { - apply, inject, LOCALE_SETTINGS_NAMESPACE, SETTINGS_NS, + apply, inject, SETTINGS_NS, } from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' From 638c9e4bd7d89363b345f694710aefd79418f1ff Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 23:25:42 +0800 Subject: [PATCH 40/73] refactor(client): replace the per-field settings preference controller with a namespace settings scope bindSettingsScope mirrors the Host-side settings owner seam in the browser: one scope per namespace publishes a snapshot store (status, section value, revision, writability, host/memory mode), validates sections against the namespace's serialized wire schema via dsh-client-schema-form, and keeps the controller's listener-before-read, revisioned serialized writes, latest-wins publication, conflict recovery, and disposal quiescence. Theme, locale, and busy-Enter services now take the scope as a constructor collaborator, which removes the bindPersistence/syncPreference two-phase callback pair and the defaulted no-op persist writers; hand-written wire guards fall away in favor of the registered schema. test-runtime gains a stubSettingsScope double. --- ...8-06-host-backed-web-preferences.i18n.yaml | 4 +- .../2026-08-06-host-backed-web-preferences.md | 10 +- ...26-08-06-host-backed-web-preferences.zh.md | 10 +- packages/client/locale/src/client/index.ts | 64 ++-- packages/client/locale/src/index.ts | 12 +- packages/client/locale/src/locale-settings.ts | 11 +- packages/client/locale/tests/apply.spec.ts | 3 +- packages/client/locale/tests/locale.spec.ts | 61 ++- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/package.json | 4 +- packages/client/runtime/src/client/index.ts | 4 +- .../runtime/src/client/settings-preference.ts | 160 -------- .../runtime/src/client/settings-scope.ts | 261 +++++++++++++ .../runtime/tests/settings-preference.spec.ts | 237 ------------ .../runtime/tests/settings-scope.spec.ts | 352 ++++++++++++++++++ packages/client/runtime/tsconfig.json | 3 + packages/client/test-runtime/README.i18n.yaml | 4 +- packages/client/test-runtime/README.md | 2 +- packages/client/test-runtime/README.zh.md | 2 +- packages/client/test-runtime/src/index.ts | 2 + .../client/test-runtime/src/settings-scope.ts | 48 +++ .../ui-conversation/src/client/apply.ts | 17 +- .../src/client/input/submission-policy.ts | 46 ++- packages/client/ui-conversation/src/index.ts | 11 +- .../src/submission-settings.ts | 11 +- .../client/ui-conversation/tests/host.spec.ts | 7 - .../tests/submission-policy.spec.ts | 49 ++- packages/client/ui-theme/src/client/index.ts | 58 ++- packages/client/ui-theme/src/index.ts | 11 +- packages/client/ui-theme/src/invariant.ts | 4 +- .../client/ui-theme/src/theme-settings.ts | 6 + packages/client/ui-theme/tests/apply.spec.ts | 3 +- packages/client/ui-theme/tests/theme.spec.ts | 48 ++- pnpm-lock.yaml | 6 + vitest.config.ts | 4 +- 37 files changed, 926 insertions(+), 617 deletions(-) delete mode 100644 packages/client/runtime/src/client/settings-preference.ts create mode 100644 packages/client/runtime/src/client/settings-scope.ts delete mode 100644 packages/client/runtime/tests/settings-preference.spec.ts create mode 100644 packages/client/runtime/tests/settings-scope.spec.ts create mode 100644 packages/client/test-runtime/src/settings-scope.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml index 13dd2d5672..00884185ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.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-08-06-host-backed-web-preferences.md -2026-08-06-host-backed-web-preferences.md: ee1c0aea360eb1a4b34eadc86c5c3091abc6663a -2026-08-06-host-backed-web-preferences.zh.md: 376e670f9af39f43783a1447498ca2d4c65a49cd +2026-08-06-host-backed-web-preferences.md: d56a8d2e330b214a1922997e3cc7165fd0fb31e4 +2026-08-06-host-backed-web-preferences.zh.md: 593646fe0845c20fb09cb7d115e6fa558226506e diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md index ee1c0aea36..d56a8d2e33 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md @@ -14,9 +14,9 @@ The first theme implementation moved only Appearance to Host settings but awaite The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary. -The client runtime provides one `bindSettingsPreference` lifecycle for scalar preferences. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap. Domain services publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then accept a validated Host value without writing it back. +The client runtime provides one `bindSettingsScope` lifecycle per namespace — the browser mirror of the Host-side settings owner seam. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through dsh-client-schema-form, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local. -User changes update the live service synchronously and queue a `settings.mutate` path operation. The controller serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. +User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence. Remote browsers cannot call the loopback-only configuration API, so their preferences remain process-local. Dynamic third-party theme ids remain in-process extensions outside the built-in Host schema; removing one resets the live registry without replacing the last durable built-in preference. @@ -28,7 +28,9 @@ Remote browsers cannot call the loopback-only configuration API, so their prefer **Await the initial read to avoid a provisional render.** Configuration availability is not a prerequisite for drawing the page. A background read may cause one live convergence, but it keeps failure isolated and preserves the existing browser/system/default fallbacks. -**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas and decoders keep product policy out of the shared runtime. +**Give every domain its own settings controller.** The concurrency, revision, failure, invalidation, and disposal rules are identical; copying them already produced lifecycle drift in the theme implementation. Domain-owned schemas keep product policy out of the shared runtime. + +**A per-field preference controller with paired sync/persist callbacks.** The first shared lifecycle synchronized one scalar field through a domain `sync` callback while the service wrote back through an injected `persist` callback. The mutual callbacks forced two-phase construction — a defaulted no-op writer later replaced via `bindPersistence` — every additional field of a namespace would have carried its own controller and whole-document read, and each domain re-declared a hand-written guard the registered wire schema already expresses. The namespace scope publishes a snapshot the service subscribes to and accepts writes directly, so the callback pair and the second construction phase do not exist. **Move every `localStorage` entry into settings.** Current session, drafts, panel disclosure, trajectory display state, and similar entries are browser-instance state rather than user configuration. Promoting them would synchronize transient navigation state across tabs and ports without a product contract. @@ -38,4 +40,4 @@ Appearance, Language, and busy-Enter choices follow the DSH user home across rel Boot may briefly show the domain default before the background read settles. A transient read failure keeps that default or the last good in-process value; reconnect retries. A write rejection can visibly restore the durable preference after the immediate local change. -Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home. +Focused unit coverage pins schema registration, listener-before-read ordering, nonblocking activation, schema-validated section acceptance, revisioned ordered writes, stale-response containment, failure recovery, disposal quiescence, and remote memory mode. The namespace-granular scope also carries multi-field sections, so later configuration surfaces can ride the same lifecycle instead of hand-rolling describe/mutate synchronization. The keyless Web settings scenario writes all three preferences through the UI, verifies the YAML document and empty legacy storage, reloads, and boots another Host on a distinct port against the same DSH home. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md index 376e670f9a..593646fe08 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.zh.md @@ -14,9 +14,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo 各领域所属的 Host half 注册三份 schema:可选的 `locale.preference`(`zh` 或 `en`,缺失时交由浏览器决定)、`ui-theme.preference`(`light`、`dark` 或 `system`,默认为 `system`),以及 `ui-conversation.busyEnter`(`queue` 或 `steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。 -客户端运行时为标量偏好提供一份 `bindSettingsPreference` 生命周期。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档。领域服务会立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后接纳已校验的 Host 值,但不将其写回。 +客户端运行时为每个 namespace 提供一份 `bindSettingsScope` 生命周期——即 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/changed` 和 `connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。 -用户变更会同步更新实时服务,并将一项 `settings.mutate` 路径操作排入队列。控制器会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,控制器会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 +用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。 远程浏览器无法调用仅限回环请求的配置 API,因此其偏好仅保留在进程内。动态第三方主题 id 仍是内置 Host schema 之外的进程内扩展;移除其中一个会重置实时注册表,但不会替换上一个持久化的内置偏好。 @@ -28,7 +28,9 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo **等待初始读取,以避免暂定渲染。** 绘制页面不以配置可用为前置条件。后台读取可能引发一次实时收敛,但它会隔离失败,并保留既有的浏览器/系统/默认回落路径。 -**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema 和解码器,可以避免把产品政策放入共享运行时。 +**让每个领域拥有自己的 settings 控制器。** 并发、revision、失败、失效与释放规则完全一致;此前的主题实现已因复制这些规则产生生命周期漂移。由领域持有 schema,可以避免把产品政策放入共享运行时。 + +**带成对 sync/persist 回调的逐字段偏好控制器。** 第一版共享生命周期经领域提供的 `sync` 回调同步单个标量字段,服务则经注入的 `persist` 回调写回。这对相互依赖的回调迫使构造分两阶段完成——写入器先默认为无操作,稍后经 `bindPersistence` 替换——namespace 每新增一个字段,本都得再携带一个自己的控制器和一次全文档读取,且每个领域都重新声明了一个已注册 wire schema 本已表达的手写校验器。namespace scope 发布一份供服务订阅的快照并直接接受写入,因此这对回调与第二个构造阶段都不存在。 **把每个 `localStorage` 条目都移入 settings。** 当前会话、草稿、面板展开状态、trajectory 显示状态和类似条目属于浏览器实例状态,而非用户配置。将它们提升为设置,会在没有产品契约的情况下,跨标签页和端口同步短暂导航状态。 @@ -38,4 +40,4 @@ Appearance、Language 和繁忙态 Enter 选择会跟随 DSH 用户 home,跨 启动时可能会在后台读取结算前短暂显示领域默认值。短暂的读取失败会保留该默认值或上一个正确的进程内值;重连时会重试。写入被拒时,界面可能会在本地值立即变化后明显恢复为持久化偏好。 -聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 +聚焦的单元测试覆盖 schema 注册、先监听后读取的顺序、非阻塞激活、经 schema 校验的分节接受、携带 revision 的有序写入、陈旧响应隔离、故障恢复、释放时完全停稳,以及远程端仅内存模式。以 namespace 为粒度的 scope 也承载多字段分节,因此后续的配置表面可以沿用同一份生命周期,而不必手搭 describe/mutate 同步。无密钥 Web settings 场景通过 UI 写入全部三项偏好,校验 YAML 文档并确认旧 `localStorage` 为空,重新加载,再使用同一个 DSH home 在不同端口上启动另一个 Host。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 228b3a3375..72fc88c934 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -13,9 +13,11 @@ import type { Context } from 'cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { - isLocaleId, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, + bindSettingsScope, type ClientContext, type SettingsScope, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, } from '../locale-settings.ts' import { en, zh, type CommonKey } from '../locales/index.ts' import { @@ -29,7 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' -export type { LocaleId } from '../locale-settings.ts' +export type { LocaleId, LocaleSettings } from '../locale-settings.ts' // The translate currency lives in ui-slots (the render machinery synthesizes // the seat); re-exported here so dictionary owners import one package. @@ -114,24 +116,25 @@ export class LocaleService { private snapshot: LocaleSnapshot private listeners = new Set<() => void>() private readonly ctx: Context - private persist: (id: LocaleId) => void + private readonly host: SettingsScope | undefined + /** Browser-derived locale standing wherever no explicit Host selection does. */ + private readonly provisional: LocaleId /** - * @param ctx - owning context (change events are emitted on it). - * @param persist - durable write callback for explicit locale selections. + * @param ctx - owning context (change events are emitted on it; the scope + * listener is released through ctx.effect on dispose). + * @param host - durable preference scope owned by the providing plugin; + * absent compositions (standalone dictionary registries) stay process-local. */ - constructor(ctx: Context, persist: (id: LocaleId) => void = () => {}) { + constructor(ctx: Context, host?: SettingsScope) { this.ctx = ctx - this.persist = persist - this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 }) - } - - /** - * Bind the owning plugin's durable writer before the service is provided. - * @param persist - callback accepting explicit locale changes. - */ - bindPersistence(persist: (id: LocaleId) => void): void { - this.persist = persist + this.host = host + this.provisional = resolveInitialLocale() + this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 }) + if (host !== undefined) { + ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption') + this.adopt(host) + } } /** @@ -172,16 +175,20 @@ export class LocaleService { if (match === undefined) throw new Error(`locale "${id}" is not registered`) if (this.snapshot.active === match.id) return this.publish(match.id, true) - this.persist(match.id) + void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) } /** - * Apply an explicit Host preference without writing it back. - * @param id - validated shipped locale. + * Adopt the scope's accepted durable selection without writing it back; an + * absent selection returns to the browser-derived locale. + * @param host - the constructor-narrowed scope driving this adoption. */ - syncPreference(id: LocaleId): void { - if (this.snapshot.active === id) return - this.publish(id, true) + private adopt(host: SettingsScope): void { + const section = host.getSnapshot().value + if (section === undefined) return + const target = section.preference ?? this.provisional + if (this.snapshot.active === target) return + this.publish(target, true) } /** @@ -345,17 +352,10 @@ export const inject = ['slots', 'connection'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const locale = new LocaleService(ctx) - const browserLocale = locale.getLocale().active + const host = bindSettingsScope(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE }) + const locale = new LocaleService(ctx, host) locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) - const controller = bindSettingsPreference(ctx, { - namespace: LOCALE_SETTINGS_NAMESPACE, - field: LOCALE_PREFERENCE_FIELD, - decode: value => isLocaleId(value) ? value : browserLocale, - sync: (id) => { locale.syncPreference(id) }, - }) - locale.bindPersistence((id) => { void controller.persist(id) }) ctx.provide('locale', locale) // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it // so the render machinery can synthesize the `t` standard seat. diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index 09afbef04e..3001890569 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -4,18 +4,16 @@ import type { Context } from 'cordis' import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleSettings, } from './locale-settings.ts' export { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, + type LocaleId, type LocaleSettings, } from './locale-settings.ts' -interface LocaleSettings { - preference?: LocaleId -} - -const LocaleSettingsSchema: z = z.object({ +/** Durable locale schema; also the wire envelope the browser scope validates against. */ +export const LocaleSettingsSchema: z = z.object({ [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), }) diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index dd1ad39339..90459981fa 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -12,11 +12,8 @@ export const LOCALE_IDS = ['zh', 'en'] as const /** Shipped locale identifier. */ export type LocaleId = typeof LOCALE_IDS[number] -/** - * Narrow one settings-wire value to a shipped locale. - * @param value - value crossing the settings boundary. - * @returns whether the value names a shipped locale. - */ -export function isLocaleId(value: unknown): value is LocaleId { - return LOCALE_IDS.some(locale => locale === value) +/** Durable locale section shared by the Host schema and the browser scope. */ +export interface LocaleSettings { + /** Explicit locale selection; absence delegates to the browser. */ + preference?: LocaleId } diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 3f1f6b2c0a..152d0e6987 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -9,6 +9,7 @@ import { } from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts' +import { LocaleSettingsSchema } from '../src/index.ts' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -21,7 +22,7 @@ async function bench() { let revision = 0 const namespace = () => ({ ns: LOCALE_SETTINGS_NAMESPACE, - schema: {}, + schema: LocaleSettingsSchema.toJSON(), value: preference === undefined ? {} : { preference }, applies: 'live' as const, secrets: [], diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 9215bd51e6..0cbbd3e717 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,14 +1,19 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { +const make = (host?: StubSettingsScope): { + ctx: Context + svc: LocaleService + events: LocaleSnapshot[] +} => { const ctx = new Context() const events: LocaleSnapshot[] = [] ctx.on('locale/change', (snapshot) => { events.push(snapshot) }) - return { ctx, svc: new LocaleService(ctx), events } + return { ctx, svc: new LocaleService(ctx, host?.scope), events } } /** @@ -131,19 +136,25 @@ describe('LocaleService', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale requests persistence, republishes an immutable snapshot, and no-ops on same value', () => { - const { svc, events } = make() - const persist = vi.fn() - svc.bindPersistence(persist) + it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => { + const host = stubSettingsScope() + const { svc, events } = make(host) svc.setLocale('en') expect(svc.getLocale().active).toBe('en') - expect(persist).toHaveBeenCalledWith('en') + expect(host.set).toHaveBeenCalledWith('preference', 'en') expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) svc.setLocale('en') expect(events).toHaveLength(1) - expect(persist).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledOnce() + }) + + it('setLocale without a host scope stays process-local', () => { + const { svc, events } = make() + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + expect(events).toHaveLength(1) }) it('throws on unknown locale ids', () => { @@ -151,18 +162,36 @@ describe('LocaleService', () => { expect(() => { svc.setLocale('fr') }).toThrow('not registered') }) - it('syncs a Host preference over the browser language without writing it back', () => { - const { svc, events } = make() - const persist = vi.fn() - svc.bindPersistence(persist) - svc.syncPreference('en') + it('adopts a Host preference over the browser language without writing it back', () => { + const host = stubSettingsScope() + const { svc, events } = make(host) + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) expect(svc.getLocale().active).toBe('en') expect(events).toHaveLength(1) - expect(persist).not.toHaveBeenCalled() - svc.syncPreference('en') + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { preference: 'en' }, revision: 2 }) expect(events).toHaveLength(1) }) + it('an absent Host preference returns to the browser-derived locale', () => { + const host = stubSettingsScope() + const { svc } = make(host) + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) + expect(svc.getLocale().active).toBe('en') + host.publish({ value: {}, revision: 2 }) + expect(svc.getLocale().active).toBe('zh') + }) + + it('adopts a section already standing at construction and releases its subscription on dispose', async () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true }) + const { ctx, svc } = make(host) + expect(svc.getLocale().active).toBe('en') + expect(host.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(host.listenerCount()).toBe(0) + }) + it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => { stubLanguages('en-GB', 'zh-CN') expect(make().svc.getLocale().active).toBe('en') diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 5698297cb4..3f5bd9263b 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: c05089badb29ad0e22ed1f66d7804eccbb11c1d4 -README.zh.md: ccbb96266cf8ca442adbdbf9784c54400593d5c2 +README.md: 767352a0682f16abcbfce3c226cda790adcc8011 +README.zh.md: 791a74691cd20705614ac782d6b55d9af290955c diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index c05089badb..767352a068 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. -`bindSettingsPreference` is the browser lifecycle for one domain-owned scalar setting. It subscribes before starting a nonblocking initial read, serializes writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. Loopback pages use the Host settings API; remote pages stay in memory. Domain packages own the namespace schema, value guard, default, and live service rather than putting product policy in runtime. +`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index ccbb96266c..791a74691c 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,7 +4,7 @@ 客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 -`bindSettingsPreference` 是单项由领域持有的标量设置所用的浏览器生命周期。它在开始非阻塞初始读取前建立订阅,使用已知最新 namespace revision 串行写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。回环页面使用 Host settings API,远程页面则只保留内存状态。namespace schema、取值校验器、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 +`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 ## Slot 声明注入 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index b636316b68..b64a4de0ec 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -32,6 +32,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -53,7 +54,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 2854a16659..04748b445e 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -21,8 +21,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' -export { bindSettingsPreference, SettingsPreferenceController } from './settings-preference.ts' -export type { SettingsPreferenceSpec } from './settings-preference.ts' +export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts' +export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' export type { diff --git a/packages/client/runtime/src/client/settings-preference.ts b/packages/client/runtime/src/client/settings-preference.ts deleted file mode 100644 index a459999cc7..0000000000 --- a/packages/client/runtime/src/client/settings-preference.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** Host-backed scalar preference synchronization for browser plugins. */ - -import type { Context } from 'cordis' -import type { - ConnectionHandle, IApiClient, SettingsNamespaceView, -} from '@deepseek-ai/dsh-client-connection/client' - -/** Domain-owned description of one scalar field in a settings namespace. */ -export interface SettingsPreferenceSpec { - /** Settings namespace registered by the owning Host plugin. */ - namespace: string - /** Scalar field inside that namespace. */ - field: string - /** Validate a wire value; undefined leaves the current in-process value active. */ - decode(value: unknown): T | undefined - /** Apply a validated Host value without writing it back. */ - sync(value: T): void -} - -type SettingsFace = Pick - -/** - * Serializes one scalar preference's Host reads and writes. Reads never block - * plugin activation; writes carry the latest known namespace revision and - * teardown waits for the operation already crossing the wire. - */ -export class SettingsPreferenceController { - private tail: Promise = Promise.resolve() - private readGeneration = 0 - private writeGeneration = 0 - private revision: number | undefined - private disposed = false - - /** - * @param api - settings wire face. - * @param spec - namespace, field validator, and live target. - * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. - */ - constructor( - private readonly api: SettingsFace, - private readonly spec: SettingsPreferenceSpec, - private readonly persistence: 'host' | 'memory' = 'host', - ) {} - - /** - * Queue a Host refresh; a newer read or user write suppresses stale publication. - * @returns settlement after the queued read completes or is skipped. - */ - load(): Promise { - const generation = ++this.readGeneration - return this.enqueue(() => this.read(generation)) - } - - /** - * Queue one user preference write. Rapid selections preserve mutation order, - * while only the latest settlement may resynchronize the live target. - * @param value - validated domain preference selected by the user. - * @returns settlement after the write and any latest-write recovery read. - */ - persist(value: T): Promise { - this.readGeneration += 1 - const generation = ++this.writeGeneration - return this.enqueue(async () => { - let response: Awaited> - try { - response = await this.api.settings.mutate({ - ns: this.spec.namespace, - ops: [{ op: 'set', path: [this.spec.field], value }], - ...(this.revision === undefined ? {} : { expectedRevision: this.revision }), - }) - } catch (_settingsWriteFailure) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) - return - } - if (!response.result.ok) { - if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) - return - } - this.accept(response.result.value, generation === this.writeGeneration) - }) - } - - /** - * Stop queued operations and wait for the current wire call to settle. - * @returns settlement after the controller reaches quiescence. - */ - async dispose(): Promise { - this.disposed = true - this.readGeneration += 1 - this.writeGeneration += 1 - await this.tail - } - - private enqueue(operation: () => Promise): Promise { - if (this.persistence === 'memory' || this.disposed) return Promise.resolve() - const task = this.tail.then(async () => { - if (this.disposed) return - await operation() - }) - // The returned task carries its own settlement to the caller; the queue - // tail is kept fulfilled so one failed target callback cannot strand later operations. - this.tail = task.catch(() => {}) - return task - } - - private async read(generation: number): Promise { - let response: Awaited> - try { - response = await this.api.settings.describe({}) - } catch (_settingsReadFailure) { - return - } - if (!response.result.ok || this.disposed) return - const view = response.result.value.namespaces.find(candidate => candidate.ns === this.spec.namespace) - if (view === undefined) return - this.accept(view, generation === this.readGeneration) - } - - private accept(view: SettingsNamespaceView, publish: boolean): void { - this.revision = view.revision - if (!publish || typeof view.value !== 'object' || view.value === null) return - const value = this.spec.decode((view.value as Record)[this.spec.field]) - if (value !== undefined) this.spec.sync(value) - } -} - -/** - * Bind one controller to settings and connection invalidations on the caller's - * plugin lifecycle. Listeners exist before the initial background read starts. - * @param ctx - owning browser plugin context. - * @param spec - domain-owned scalar preference contract. - * @returns the bound controller used by the domain's user-write callback. - */ -export function bindSettingsPreference( - ctx: Context, - spec: SettingsPreferenceSpec, -): SettingsPreferenceController { - const connection = ctx.get('connection') as ConnectionHandle - const controller = new SettingsPreferenceController( - connection.api, - spec, - connection.isLoopback ? 'host' : 'memory', - ) - ctx.effect(() => { - const refresh = (namespace?: string): void => { - if (namespace !== undefined && namespace !== spec.namespace) return - void controller.load() - } - const disposers = [ - ctx.on('settings/changed', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - void controller.load() - return async () => { - for (const dispose of disposers) dispose() - await controller.dispose() - } - }, `runtime: ${spec.namespace}.${spec.field} preference`) - return controller -} diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/runtime/src/client/settings-scope.ts new file mode 100644 index 0000000000..91b6c7ec3a --- /dev/null +++ b/packages/client/runtime/src/client/settings-scope.ts @@ -0,0 +1,261 @@ +/** Host-backed settings-namespace synchronization for browser plugins. */ + +import type { Context } from 'cordis' +import type { + ConnectionHandle, IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' +import { createSnapshotStore, type SnapshotStore } from './contract/store.ts' + +/** Client-side sync state of one settings namespace. */ +export interface SettingsScopeSnapshot { + /** + * `loading` until the first accepted section, `ready` while one stands, and + * `unavailable` when the namespace is not exposed to this client or the + * connection keeps preferences process-local (memory mode). + */ + status: 'loading' | 'ready' | 'unavailable' + /** Last accepted schema-resolved section; undefined before the first acceptance. */ + value: T | undefined + /** Namespace revision fencing the next write; undefined before the first Host view. */ + revision: number | undefined + /** Whether the Host document accepts writes; memory mode never does. */ + writable: boolean + /** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */ + mode: 'host' | 'memory' +} + +/** Domain-owned description of one settings namespace consumed by a browser plugin. */ +export interface SettingsScopeSpec { + /** Settings namespace registered by the owning Host plugin. */ + namespace: string + /** + * Narrow one wire section; undefined keeps the last accepted value. The + * default validates the section against the namespace's own serialized wire + * schema, so domains add a decoder only to narrow beyond that schema. + */ + decode?: (section: unknown) => T | undefined +} + +/** + * Reactive owner handle over one namespace's durable section — the browser + * mirror of the Host-side `SettingsScope` owner seam. Domain services read + * and observe the snapshot and route explicit user choices through `set`. + */ +export interface SettingsScope { + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsScopeSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Queue one field write. Rapid writes preserve mutation order, each carries + * the latest known namespace revision, and only the latest settlement may + * publish; a rejected or failed latest write reloads Host state instead. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + set(field: string, value: unknown): Promise +} + +type SettingsFace = Pick + +/** + * Serializes one namespace's Host reads and writes behind a snapshot store. + * Reads never block plugin activation; writes carry the latest known + * namespace revision and teardown waits for the operation already crossing + * the wire. + */ +export class SettingsScopeController implements SettingsScope { + private readonly store: SnapshotStore> + private tail: Promise = Promise.resolve() + private readGeneration = 0 + private writeGeneration = 0 + private disposed = false + + /** + * @param api - settings wire face. + * @param spec - namespace identity and optional narrowing decoder. + * @param persistence - remote browsers remain process-local because settings RPCs are loopback-only. + */ + constructor( + private readonly api: SettingsFace, + private readonly spec: SettingsScopeSpec, + private readonly persistence: 'host' | 'memory' = 'host', + ) { + this.store = createSnapshotStore>({ + status: persistence === 'host' ? 'loading' : 'unavailable', + value: undefined, + revision: undefined, + writable: false, + mode: persistence, + }) + } + + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsScopeSnapshot { + return this.store.getSnapshot() + } + + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void { + return this.store.subscribe(listener) + } + + /** + * Queue a Host refresh; a newer read or user write suppresses stale publication. + * @returns settlement after the queued read completes or is skipped. + */ + load(): Promise { + const generation = ++this.readGeneration + return this.enqueue(() => this.read(generation)) + } + + /** + * Queue one field write; see {@link SettingsScope.set} for the ordering, + * revision, and recovery contract. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + set(field: string, value: unknown): Promise { + this.readGeneration += 1 + const generation = ++this.writeGeneration + return this.enqueue(async () => { + const revision = this.getSnapshot().revision + let response: Awaited> + try { + response = await this.api.settings.mutate({ + ns: this.spec.namespace, + ops: [{ op: 'set', path: [field], value }], + ...(revision === undefined ? {} : { expectedRevision: revision }), + }) + } catch (_settingsWriteFailure) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + if (!response.result.ok) { + if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration) + return + } + this.accept(response.result.value, generation === this.writeGeneration) + }) + } + + /** + * Stop queued operations and wait for the current wire call to settle. + * @returns settlement after the controller reaches quiescence. + */ + async dispose(): Promise { + this.disposed = true + this.readGeneration += 1 + this.writeGeneration += 1 + await this.tail + } + + private enqueue(operation: () => Promise): Promise { + if (this.persistence === 'memory' || this.disposed) return Promise.resolve() + const task = this.tail.then(async () => { + if (this.disposed) return + await operation() + }) + // The returned task carries its own settlement to the caller; the queue + // tail is kept fulfilled so one failed subscriber cannot strand later operations. + this.tail = task.catch(() => {}) + return task + } + + private async read(generation: number): Promise { + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + return + } + if (!response.result.ok || this.disposed) return + const { namespaces, writable } = response.result.value + const view = namespaces.find(candidate => candidate.ns === this.spec.namespace) + const publish = generation === this.readGeneration + if (view === undefined) { + if (publish) { + this.store.update((draft) => { + draft.status = 'unavailable' + draft.writable = writable + }) + } + return + } + this.accept(view, publish, writable) + } + + private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void { + const decoded = publish ? this.decode(view) : undefined + this.store.update((draft) => { + draft.revision = view.revision + if (writable !== undefined) draft.writable = writable + if (decoded === undefined) return + draft.status = 'ready' + draft.value = decoded + }) + } + + private decode(view: SettingsNamespaceView): T | undefined { + if (this.spec.decode !== undefined) return this.spec.decode(view.value) + // Sections are plain objects by construction; schemastery alone would + // resolve null or an array through object defaults instead of refusing. + if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined + let failure: string | undefined + try { + failure = validateDraft(rehydrateSchema(view.schema), view.value) + } catch (_malformedSchemaEnvelope) { + // A schema envelope this client cannot rehydrate vouches for no section; + // the value is treated exactly like a schema-invalid one. + return undefined + } + return failure === undefined ? view.value as T : undefined + } +} + +/** + * Bind one namespace scope to settings and connection invalidations on the + * caller's plugin lifecycle. Listeners exist before the initial background + * read starts, so activation never blocks on the settings transport. + * @param ctx - owning browser plugin context. + * @param spec - domain-owned namespace contract. + * @returns the bound scope consumed by the domain's services and rows. + */ +export function bindSettingsScope( + ctx: Context, + spec: SettingsScopeSpec, +): SettingsScope { + const connection = ctx.get('connection') as ConnectionHandle + const controller = new SettingsScopeController( + connection.api, + spec, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const refresh = (namespace?: string): void => { + if (namespace !== undefined && namespace !== spec.namespace) return + void controller.load() + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + void controller.load() + return async () => { + for (const dispose of disposers) dispose() + await controller.dispose() + } + }, `runtime: ${spec.namespace} settings scope`) + return controller +} diff --git a/packages/client/runtime/tests/settings-preference.spec.ts b/packages/client/runtime/tests/settings-preference.spec.ts deleted file mode 100644 index a93df780bb..0000000000 --- a/packages/client/runtime/tests/settings-preference.spec.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' -import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { - bindSettingsPreference, SettingsPreferenceController, -} from '../src/client/settings-preference.ts' - -type Preference = 'light' | 'dark' | 'system' - -let rpc = 0 - -function ok(value: T): RpcResponse { - return { rpcId: `preference-${rpc++}` as never, result: { ok: true, value } } -} - -function rejected(): RpcResponse { - return { - rpcId: `preference-${rpc++}` as never, - result: { - ok: false, - error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, - }, - } -} - -function view(value: unknown, revision = 0): SettingsNamespaceView { - return { - ns: 'ui-test', - schema: {}, - value, - applies: 'live', - secrets: [], - revision, - } -} - -function described(value: unknown, revision = 0) { - return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) -} - -function deferred() { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { promise, resolve, reject } -} - -function spec(values: Preference[]) { - return { - namespace: 'ui-test', - field: 'preference', - decode: (value: unknown): Preference | undefined => - value === 'light' || value === 'dark' || value === 'system' ? value : undefined, - sync: (value: Preference) => { values.push(value) }, - } -} - -describe('SettingsPreferenceController', () => { - it('loads only a valid owned field and contains unavailable transports', async () => { - const values: Preference[] = [] - const describe = vi.fn() - .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) - .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) - .mockResolvedValueOnce(described({ preference: 'sepia' })) - .mockResolvedValueOnce(described(null)) - .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) - const controller = new SettingsPreferenceController({ settings: { describe } } as never, spec(values)) - for (let i = 0; i < 6; i++) await controller.load() - expect(values).toEqual(['dark']) - }) - - it('serializes rapid writes, carries revisions, and publishes only the latest settlement', async () => { - const first = deferred>() - const values: Preference[] = [] - const describe = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) - const mutate = vi.fn() - .mockReturnValueOnce(first.promise) - .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec(values), - ) - await controller.load() - const dark = controller.persist('dark') - const light = controller.persist('light') - await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - first.resolve(ok(view({ preference: 'dark' }, 5))) - await Promise.all([dark, light]) - expect(values).toEqual(['system', 'light']) - expect(mutate).toHaveBeenNthCalledWith(1, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'dark' }], - expectedRevision: 4, - }) - expect(mutate).toHaveBeenNthCalledWith(2, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'light' }], - expectedRevision: 5, - }) - }) - - it('recovers the latest rejected or thrown write from Host state', async () => { - const values: Preference[] = [] - const describe = vi.fn() - .mockResolvedValueOnce(described({ preference: 'system' }, 2)) - .mockResolvedValueOnce(described({ preference: 'light' }, 3)) - const mutate = vi.fn() - .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec(values), - ) - await controller.persist('dark') - await controller.persist('system') - expect(values).toEqual(['system', 'light']) - }) - - it('does not recover superseded rejected or thrown writes', async () => { - const values: Preference[] = [] - const describe = vi.fn() - const mutate = vi.fn() - .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec(values), - ) - await Promise.all([ - controller.persist('dark'), - controller.persist('system'), - controller.persist('light'), - ]) - expect(describe).not.toHaveBeenCalled() - expect(values).toEqual(['light']) - }) - - it('keeps the queue usable when a target callback throws', async () => { - const describe = vi.fn() - .mockResolvedValueOnce(described({ preference: 'dark' })) - .mockResolvedValueOnce(described({ preference: 'sepia' })) - const controller = new SettingsPreferenceController( - { settings: { describe } } as never, - { ...spec([]), sync: () => { throw new Error('target failed') } }, - ) - await expect(controller.load()).rejects.toThrow('target failed') - await expect(controller.load()).resolves.toBeUndefined() - }) - - it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { - const first = deferred>() - const mutate = vi.fn().mockReturnValue(first.promise) - const values: Preference[] = [] - const controller = new SettingsPreferenceController( - { settings: { mutate } } as never, - spec(values), - ) - const dark = controller.persist('dark') - await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) - const light = controller.persist('light') - let stopped = false - const stop = controller.dispose().then(() => { stopped = true }) - await Promise.resolve() - expect(stopped).toBe(false) - first.resolve(ok(view({ preference: 'dark' }, 1))) - await Promise.all([dark, light, stop]) - await controller.persist('system') - await controller.load() - expect(mutate).toHaveBeenCalledOnce() - expect(values).toEqual([]) - }) - - it('keeps remote-browser preferences in memory without Host calls', async () => { - const describe = vi.fn() - const mutate = vi.fn() - const controller = new SettingsPreferenceController( - { settings: { describe, mutate } } as never, - spec([]), - 'memory', - ) - await controller.load() - await controller.persist('dark') - await controller.dispose() - expect(describe).not.toHaveBeenCalled() - expect(mutate).not.toHaveBeenCalled() - }) -}) - -describe('bindSettingsPreference', () => { - it('subscribes before the initial read and converges to the latest queued invalidation', async () => { - const initial = deferred>() - const describe = vi.fn() - .mockReturnValueOnce(initial.promise) - .mockResolvedValueOnce(described({ preference: 'light' }, 2)) - .mockResolvedValueOnce(described({ preference: 'system' }, 3)) - const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe } }, - isLoopback: true, - } as never) - const values: Preference[] = [] - const fiber = ctx.plugin({ - inject: ['connection'], - apply: (scope: Context) => { bindSettingsPreference(scope, spec(values)) }, - }) - await fiber.await() - await vi.waitFor(() => { expect(describe).toHaveBeenCalledOnce() }) - ctx.emit('settings/changed', 'unrelated') - ctx.emit('settings/changed', 'ui-test') - ctx.emit('connection/reset') - initial.resolve(described({ preference: 'dark' }, 1)) - await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(3) }) - await vi.waitFor(() => { expect(values).toEqual(['system']) }) - await fiber.dispose() - ctx.emit('settings/changed', 'ui-test') - await Promise.resolve() - expect(describe).toHaveBeenCalledTimes(3) - }) - - it('binds a remote browser in memory without starting a settings read', async () => { - const describe = vi.fn() - const ctx = new Context() - ctx.provide('connection', { - api: { settings: { describe } }, - isLoopback: false, - } as never) - const fiber = ctx.plugin({ - inject: ['connection'], - apply: (scope: Context) => { bindSettingsPreference(scope, spec([])) }, - }) - await fiber.await() - await fiber.dispose() - expect(describe).not.toHaveBeenCalled() - }) -}) diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/runtime/tests/settings-scope.spec.ts new file mode 100644 index 0000000000..db980bf6d1 --- /dev/null +++ b/packages/client/runtime/tests/settings-scope.spec.ts @@ -0,0 +1,352 @@ +import { Context } from 'cordis' +import z from 'schemastery' +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + bindSettingsScope, SettingsScopeController, type SettingsScope, +} from '../src/client/settings-scope.ts' + +interface UiTestSettings { + preference: 'light' | 'dark' | 'system' +} + +const ENVELOPE = z.object({ + preference: z.union(['light', 'dark', 'system']).default('system'), +}).toJSON() + +let rpc = 0 + +function ok(value: T): RpcResponse { + return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } } +} + +function rejected(): RpcResponse { + return { + rpcId: `scope-${rpc++}` as never, + result: { + ok: false, + error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } }, + }, + } +} + +function view(value: unknown, revision = 0): SettingsNamespaceView { + return { + ns: 'ui-test', + schema: ENVELOPE, + value, + applies: 'live', + secrets: [], + revision, + } +} + +function described(value: unknown, revision = 0) { + return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] }) +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +/** Record each distinct published section, starting from the current one. */ +function trackValues(scope: SettingsScope): Array { + const seen: Array = [scope.getSnapshot().value] + scope.subscribe(() => { + const value = scope.getSnapshot().value + if (value !== seen[seen.length - 1]) seen.push(value) + }) + return seen +} + +describe('SettingsScopeController', () => { + it('starts loading and publishes a schema-valid section with revision and writability', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + expect(scope.getSnapshot()).toEqual({ + status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + }) + await scope.load() + expect(scope.getSnapshot()).toEqual({ + status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host', + }) + }) + + it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(described({ preference: 'sepia' }, 4)) + .mockResolvedValueOnce(described(null, 5)) + .mockResolvedValueOnce(described('scalar', 6)) + .mockResolvedValueOnce(described(['queue'], 7)) + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + const good = trackValues(scope) + for (let i = 0; i < 7; i++) await scope.load() + expect(scope.getSnapshot()).toMatchObject({ + status: 'ready', value: { preference: 'dark' }, revision: 7, + }) + expect(good).toEqual([undefined, { preference: 'dark' }]) + }) + + it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => { + const broken = { ...view({ preference: 'dark' }, 2), schema: null } + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] })) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 }) + }) + + it('suppresses a superseded read of an unexposed namespace', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + const statuses: string[] = [] + scope.subscribe(() => { statuses.push(scope.getSnapshot().status) }) + const stale = scope.load() + const fresh = scope.load() + await Promise.all([stale, fresh]) + expect(statuses).not.toContain('unavailable') + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } }) + }) + + it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'light' }, 1)) + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] })) + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + expect(scope.getSnapshot().status).toBe('ready') + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } }) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 }) + }) + + it('applies a custom decode override in place of the wire schema', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'light' }, 1)) + .mockResolvedValueOnce(described({ preference: 'dark' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { + namespace: 'ui-test', + decode: section => (section as UiTestSettings).preference === 'dark' + ? section as UiTestSettings + : undefined, + }, + ) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 }) + await scope.load() + expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 }) + }) + + it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => { + const first = deferred>() + const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 6))) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await scope.load() + const dark = scope.set('preference', 'dark') + const light = scope.set('preference', 'light') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + first.resolve(ok(view({ preference: 'dark' }, 5))) + await Promise.all([dark, light]) + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 }) + expect(mutate).toHaveBeenNthCalledWith(1, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'dark' }], + expectedRevision: 4, + }) + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 5, + }) + }) + + it('recovers the latest rejected or thrown write from Host state', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 2)) + .mockResolvedValueOnce(described({ preference: 'light' }, 3)) + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await scope.set('preference', 'dark') + await scope.set('preference', 'system') + expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) + }) + + it('does not recover superseded rejected or thrown writes', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + .mockResolvedValueOnce(rejected()) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + await Promise.all([ + scope.set('preference', 'dark'), + scope.set('preference', 'system'), + scope.set('preference', 'light'), + ]) + expect(describeCall).not.toHaveBeenCalled() + expect(published.map(section => section?.preference)).toEqual([undefined, 'light']) + }) + + it('keeps the write queue usable when a subscriber throws', async () => { + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 1)) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + let thrown = false + scope.subscribe(() => { + if (thrown) return + thrown = true + throw new Error('subscriber failed') + }) + await expect(scope.load()).rejects.toThrow('subscriber failed') + await expect(scope.load()).resolves.toBeUndefined() + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 }) + }) + + it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => { + const first = deferred>() + const mutate = vi.fn().mockReturnValue(first.promise) + const describeCall = vi.fn() + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + const published = trackValues(scope) + const dark = scope.set('preference', 'dark') + await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + const light = scope.set('preference', 'light') + let stopped = false + const stop = scope.dispose().then(() => { stopped = true }) + await Promise.resolve() + expect(stopped).toBe(false) + first.resolve(ok(view({ preference: 'dark' }, 1))) + await Promise.all([dark, light, stop]) + await scope.set('preference', 'system') + await scope.load() + expect(mutate).toHaveBeenCalledOnce() + expect(describeCall).not.toHaveBeenCalled() + expect(published).toEqual([undefined]) + }) + + it('keeps a remote browser in memory mode without Host calls', async () => { + const describeCall = vi.fn() + const mutate = vi.fn() + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + 'memory', + ) + expect(scope.getSnapshot()).toEqual({ + status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', + }) + await scope.load() + await scope.set('preference', 'dark') + await scope.dispose() + expect(describeCall).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) +}) + +describe('bindSettingsScope', () => { + it('subscribes before the initial read and converges to the latest queued invalidation', async () => { + const initial = deferred>() + const describeCall = vi.fn() + .mockReturnValueOnce(initial.promise) + .mockResolvedValueOnce(described({ preference: 'light' }, 2)) + .mockResolvedValueOnce(described({ preference: 'system' }, 3)) + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: true, + } as never) + let scope!: SettingsScope + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (plugin: Context) => { + scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + }, + }) + await fiber.await() + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() }) + ctx.emit('settings/changed', 'unrelated') + ctx.emit('settings/changed', 'ui-test') + ctx.emit('connection/reset') + initial.resolve(described({ preference: 'dark' }, 1)) + await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 }) + }) + await fiber.dispose() + ctx.emit('settings/changed', 'ui-test') + await Promise.resolve() + expect(describeCall).toHaveBeenCalledTimes(3) + }) + + it('binds a remote browser in memory mode without starting a settings read', async () => { + const describeCall = vi.fn() + const ctx = new Context() + ctx.provide('connection', { + api: { settings: { describe: describeCall } }, + isLoopback: false, + } as never) + let scope!: SettingsScope + const fiber = ctx.plugin({ + inject: ['connection'], + apply: (plugin: Context) => { + scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + }, + }) + await fiber.await() + expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false }) + await fiber.dispose() + expect(describeCall).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index f1512c7059..4357200479 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../connection" }, + { + "path": "../schema-form" + }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/test-runtime/README.i18n.yaml b/packages/client/test-runtime/README.i18n.yaml index 26845c1d5c..4707337ef0 100644 --- a/packages/client/test-runtime/README.i18n.yaml +++ b/packages/client/test-runtime/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/test-runtime/README.md -README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e -README.zh.md: a86b9e469a5632886891628267002a14588afeaa +README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0 +README.zh.md: e292c57c21dde1f7639ce37ee9b65930c6d153ea diff --git a/packages/client/test-runtime/README.md b/packages/client/test-runtime/README.md index 74da8fde7f..455d6f564c 100644 --- a/packages/client/test-runtime/README.md +++ b/packages/client/test-runtime/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic. -The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. +The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face. Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `

` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) to keep `.snap` files structural and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis. diff --git a/packages/client/test-runtime/README.zh.md b/packages/client/test-runtime/README.zh.md index a86b9e469a..e292c57c21 100644 --- a/packages/client/test-runtime/README.zh.md +++ b/packages/client/test-runtime/README.zh.md @@ -4,7 +4,7 @@ 面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。 -替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 +替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions`、`TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`;`stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`),生产面一旦改形,测试台在编译期即断,而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写)、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。 局部 DOM 快照:`declare(children)` 注册自动 frame,逐 key 的 `
` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图(container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`)保持 `.snap` 只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)`;`mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。 diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 5ef5350434..4703b0112c 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -34,6 +34,8 @@ import type { Stabilizer } from './fixtures.ts' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' export { FixtureSession, TestSessions } from './sessions.ts' +export { stubSettingsScope } from './settings-scope.ts' +export type { StubSettingsScope } from './settings-scope.ts' export { TestWorkspaces } from './workspaces.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' diff --git a/packages/client/test-runtime/src/settings-scope.ts b/packages/client/test-runtime/src/settings-scope.ts new file mode 100644 index 0000000000..c901221018 --- /dev/null +++ b/packages/client/test-runtime/src/settings-scope.ts @@ -0,0 +1,48 @@ +/** Test double for the client settings-scope seam. */ +import { vi } from 'vitest' +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' + +/** Handle over one stubbed scope: the scope, its write spy, and publication controls. */ +export interface StubSettingsScope { + /** The scope face handed to the service under test. */ + scope: SettingsScope + /** Spy behind `scope.set`; resolves immediately. */ + set: ReturnType + /** @returns how many listeners are currently subscribed (disposal assertions). */ + listenerCount(): number + /** + * Replace part of the snapshot and notify subscribers, as a Host + * acceptance would. + * @param next - snapshot fields to replace. + */ + publish(next: Partial>): void +} + +/** + * Build an in-memory settings scope for service specs: starts in the host + * loading state, records writes, and lets the test publish Host acceptances. + * @returns the stub handle. + */ +export function stubSettingsScope(): StubSettingsScope { + let snapshot: SettingsScopeSnapshot = { + status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + } + const listeners = new Set<() => void>() + const set = vi.fn(() => Promise.resolve()) + return { + scope: { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set, + }, + set, + listenerCount: () => listeners.size, + publish: (next) => { + snapshot = { ...snapshot, ...next } + for (const listener of [...listeners]) listener() + }, + } +} diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 5fc1e9e353..eda582504f 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,7 +1,7 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSettingsPreference, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSettingsScope, type ISessions, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -38,9 +38,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' -import { - BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, isBusyEnterBehavior, -} from '../submission-settings.ts' +import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -106,14 +104,9 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() - const submissionPolicy = new ComposerSubmissionPolicy() - const preference = bindSettingsPreference(ctx, { - namespace: CONVERSATION_SETTINGS_NAMESPACE, - field: BUSY_ENTER_FIELD, - decode: value => isBusyEnterBehavior(value) ? value : undefined, - sync: (behavior) => { submissionPolicy.syncPreference(behavior) }, - }) - submissionPolicy.bindPersistence((behavior) => { void preference.persist(behavior) }) + const submissionPolicy = new ComposerSubmissionPolicy( + bindSettingsScope(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }), + ) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ name: 'settings.general.item', diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 968406972c..27b1cff326 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -3,35 +3,39 @@ * preference and resolves keyboard gestures into queue/steer delivery modes; * Host and Agent keep the actual delivery-window authority. */ -import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, type SettingsScope, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' import type { BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, } from '../contract/composer-submission.ts' -import { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' +import { BUSY_ENTER_FIELD, DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' +import type { ConversationSettings } from '../../submission-settings.ts' export { DEFAULT_BUSY_ENTER_BEHAVIOR } from '../../submission-settings.ts' /** - * Persisted policy used by both the composer inject face and its Settings row. + * Busy-Enter policy used by both the composer inject face and its Settings row. * Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window * submission into the next waking Queue item. */ export class ComposerSubmissionPolicy { /** Reactive preference source for the Settings row. */ readonly busyEnter: SnapshotStore = createSnapshotStore(DEFAULT_BUSY_ENTER_BEHAVIOR) - private persist: (behavior: BusyEnterBehavior) => void - - /** @param persist - durable write callback for explicit behavior changes. */ - constructor(persist: (behavior: BusyEnterBehavior) => void = () => {}) { - this.persist = persist - } + private readonly host: SettingsScope | undefined /** - * Bind the owning plugin's durable writer before the policy is exposed. - * @param persist - callback accepting explicit behavior changes. + * @param host - durable preference scope owned by the providing plugin; + * absent compositions stay process-local. The adoption subscription shares + * the scope's plugin lifetime — a disposed scope never publishes again, so + * the policy needs no release hook. */ - bindPersistence(persist: (behavior: BusyEnterBehavior) => void): void { - this.persist = persist + constructor(host?: SettingsScope) { + this.host = host + if (host !== undefined) { + host.subscribe(() => { this.adopt(host) }) + this.adopt(host) + } } /** @@ -53,21 +57,23 @@ export class ComposerSubmissionPolicy { } /** - * Change the plain-Enter behavior used during busy state. + * Change the plain-Enter behavior used during busy state; the live value + * publishes before the durable write starts. * @param behavior - Queue or Steer. */ setBusyEnter(behavior: BusyEnterBehavior): void { if (this.busyEnter.getSnapshot() === behavior) return this.busyEnter.set(behavior) - this.persist(behavior) + void this.host?.set(BUSY_ENTER_FIELD, behavior) } /** - * Apply a Host preference without writing it back. - * @param behavior - validated behavior from settings. + * Adopt the scope's accepted durable behavior without writing it back. + * @param host - the constructor-narrowed scope driving this adoption. */ - syncPreference(behavior: BusyEnterBehavior): void { - if (this.busyEnter.getSnapshot() === behavior) return - this.busyEnter.set(behavior) + private adopt(host: SettingsScope): void { + const section = host.getSnapshot().value + if (section === undefined || this.busyEnter.getSnapshot() === section.busyEnter) return + this.busyEnter.set(section.busyEnter) } } diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 2377c8a73f..1d36164767 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -5,19 +5,16 @@ import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, - DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, + DEFAULT_BUSY_ENTER_BEHAVIOR, type ConversationSettings, } from './submission-settings.ts' export { BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, - DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, + DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings, } from './submission-settings.ts' -interface ConversationSettings { - busyEnter: BusyEnterBehavior -} - -const ConversationSettingsSchema: z = z.object({ +/** Durable conversation schema; also the wire envelope the browser scope validates against. */ +export const ConversationSettingsSchema: z = z.object({ [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), }) diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts index a1ba6e082c..cf19472c47 100644 --- a/packages/client/ui-conversation/src/submission-settings.ts +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -15,11 +15,8 @@ export type BusyEnterBehavior = typeof BUSY_ENTER_BEHAVIORS[number] /** Default preserves Enter-as-Queue for running conversations. */ export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' -/** - * Narrow one settings-wire value to a busy-Enter behavior. - * @param value - value crossing the settings boundary. - * @returns whether the value names a supported behavior. - */ -export function isBusyEnterBehavior(value: unknown): value is BusyEnterBehavior { - return BUSY_ENTER_BEHAVIORS.some(behavior => behavior === value) +/** Durable conversation section shared by the Host schema and the browser scope. */ +export interface ConversationSettings { + /** Delivery mode for plain Enter while the addressed agent is busy. */ + busyEnter: BusyEnterBehavior } diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts index bb16273d64..0d16a23da2 100644 --- a/packages/client/ui-conversation/tests/host.spec.ts +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -4,7 +4,6 @@ import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-a import { CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, apply, } from '@deepseek-ai/dsh-client-ui-conversation' -import { isBusyEnterBehavior } from '../src/submission-settings.ts' class MemorySettings extends Settings { readonly writable = true @@ -15,12 +14,6 @@ class MemorySettings extends Settings { } describe('ui-conversation host', () => { - it('narrows settings-wire values to the supported behavior pair', () => { - expect(isBusyEnterBehavior('queue')).toBe(true) - expect(isBusyEnterBehavior('steer')).toBe(true) - expect(isBusyEnterBehavior('later')).toBe(false) - }) - it('registers, validates, and disposes the durable busy-Enter preference', async () => { const ctx = new Context() await ctx.plugin(MemorySettings).await() diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts index 6519e9f9d3..3117c39032 100644 --- a/packages/client/ui-conversation/tests/submission-policy.spec.ts +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -1,8 +1,10 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, } from '../src/client/input/submission-policy.ts' +import type { ConversationSettings } from '../src/submission-settings.ts' describe('ComposerSubmissionPolicy', () => { it('defaults to Queue and only applies the preference while running', () => { @@ -16,8 +18,6 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', false)).toBe('queue') const changed = vi.fn() - const persist = vi.fn() - policy.bindPersistence(persist) policy.busyEnter.subscribe(changed) policy.setBusyEnter('steer') expect(changed).toHaveBeenCalledTimes(1) @@ -25,25 +25,42 @@ describe('ComposerSubmissionPolicy', () => { expect(policy.resolve(true, 'accelerated', true)).toBe('queue') expect(policy.resolve(false, 'enter', true)).toBe('queue') expect(policy.resolve(false, 'accelerated', true)).toBe('queue') - expect(persist).toHaveBeenCalledWith('steer') }) - it('syncs a Host preference without writing it back and leaves an identical write untouched', () => { - const persist = vi.fn() - const policy = new ComposerSubmissionPolicy(persist) - policy.syncPreference('steer') + it('writes an explicit change through the scope after publishing it locally', () => { + const host = stubSettingsScope() + const observed: string[] = [] + let liveBehavior = (): string => 'unconstructed' + const scope: typeof host.scope = { + ...host.scope, + set: (field, value) => { + observed.push(`${field}=${String(value)}:${liveBehavior()}`) + return host.scope.set(field, value) + }, + } + const policy = new ComposerSubmissionPolicy(scope) + liveBehavior = () => policy.busyEnter.getSnapshot() + policy.setBusyEnter('steer') + expect(observed).toEqual(['busyEnter=steer:steer']) + expect(host.set).toHaveBeenCalledWith('busyEnter', 'steer') + expect(host.set).toHaveBeenCalledOnce() + }) + + it('adopts a Host preference without writing it back and leaves an identical write untouched', () => { + const host = stubSettingsScope() + const policy = new ComposerSubmissionPolicy(host.scope) + host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true }) expect(policy.busyEnter.getSnapshot()).toBe('steer') policy.setBusyEnter('steer') - expect(persist).not.toHaveBeenCalled() + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { busyEnter: 'steer' }, revision: 2 }) + expect(policy.busyEnter.getSnapshot()).toBe('steer') }) - it('publishes the in-memory preference before calling the durable writer', () => { - const policy = new ComposerSubmissionPolicy() - const persist = vi.fn(() => { - expect(policy.busyEnter.getSnapshot()).toBe('steer') - }) - policy.bindPersistence(persist) - policy.setBusyEnter('steer') - expect(persist).toHaveBeenCalledOnce() + it('adopts a section already standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { busyEnter: 'steer' }, revision: 1, writable: true }) + const policy = new ComposerSubmissionPolicy(host.scope) + expect(policy.busyEnter.getSnapshot()).toBe('steer') }) }) diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 05c8a741af..73221b15a7 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -3,13 +3,15 @@ * owns the live theme preference (light/dark/system), resolves `system` through * `prefers-color-scheme`, and publishes immutable snapshots; it never touches * the DOM — ui-layout's presenter consumes the resolved snapshot. The Host - * settings controller loads and stores the preference in the user-settings + * settings scope loads and stores the preference in the user-settings * document. The plugin also registers the Appearance preference row into the * settings General section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSettingsPreference, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { + bindSettingsScope, type ClientContext, type SettingsScope, +} from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' @@ -18,7 +20,7 @@ import { createAppearanceRowStore } from './settings-store.ts' import { en, zh, type ThemeKey } from './locales.ts' import { DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemePreference, type ThemeSettings, } from '../theme-settings.ts' export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' @@ -26,7 +28,7 @@ export type { AppearanceRowState } from './settings-store.ts' export type { ThemeKey } from './locales.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemePreference, type ThemeSettings, } from '../theme-settings.ts' /** Namespace owning this feature's settings-row copy. */ @@ -98,21 +100,21 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ */ export class ThemeService { private readonly ctx: Context + private readonly host: SettingsScope private themes: ThemeDefinition[] = [...BUILTIN_THEMES] private preference: ThemePreference private revision = 0 private snapshot: ThemeSnapshot private readonly media: MediaQueryList | undefined - private persist: (preference: ThemePreference) => void /** * @param ctx - owning context (change events are emitted on it; the - * media-query listener is released through ctx.effect on dispose). - * @param persist - durable write callback for built-in preferences. + * media-query and scope listeners are released through ctx.effect on dispose). + * @param host - durable preference scope owned by the same plugin. */ - constructor(ctx: Context, persist: (preference: ThemePreference) => void = () => {}) { + constructor(ctx: Context, host: SettingsScope) { this.ctx = ctx - this.persist = persist + this.host = host this.preference = DEFAULT_PREFERENCE // Non-browser runs (node e2e booting the client tree) have no matchMedia. this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') @@ -128,6 +130,8 @@ export class ThemeService { return () => { media.removeEventListener('change', onChange) } }, 'ui-theme: prefers-color-scheme listener') } + ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption') + this.adopt() } /** @@ -138,18 +142,10 @@ export class ThemeService { return this.snapshot } - /** - * Bind the owning plugin's durable writer before the service is provided. - * @param persist - callback accepting built-in preference changes. - */ - bindPersistence(persist: (preference: ThemePreference) => void): void { - this.persist = persist - } - /** * Switch the theme preference — the only user preference write entry. - * Built-in preferences are persisted and every accepted value emits - * `theme/change`. + * Built-in preferences are written through the settings scope and every + * accepted value emits `theme/change`. * @param id - a registered theme id or `system`; unknown ids throw. */ setTheme(id: string): void { @@ -158,17 +154,15 @@ export class ThemeService { } if (this.preference === id) return this.preference = id as ThemePreference - if (isThemePreference(id)) this.persist(id) + if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id) this.publish() } - /** - * Apply a preference read from Host settings without writing it back. - * @param preference - validated durable preference. - */ - syncPreference(preference: ThemePreference): void { - if (this.preference === preference) return - this.preference = preference + /** Adopt the scope's accepted durable preference without writing it back. */ + private adopt(): void { + const section = this.host.getSnapshot().value + if (section === undefined || this.preference === section.preference) return + this.preference = section.preference this.publish() } @@ -231,14 +225,8 @@ export const inject = ['slots', 'locale', 'connection'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const theme = new ThemeService(ctx) - const controller = bindSettingsPreference(ctx, { - namespace: THEME_SETTINGS_NAMESPACE, - field: THEME_PREFERENCE_FIELD, - decode: value => isThemePreference(value) ? value : undefined, - sync: (preference) => { theme.syncPreference(preference) }, - }) - theme.bindPersistence((preference) => { void controller.persist(preference) }) + const host = bindSettingsScope(ctx, { namespace: THEME_SETTINGS_NAMESPACE }) + const theme = new ThemeService(ctx, host) ctx.provide('theme', theme) ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries') diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 32d3689950..785e6ca898 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -5,19 +5,16 @@ import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemeSettings, } from './theme-settings.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, + type ThemePreference, type ThemeSettings, } from './theme-settings.ts' -interface ThemeSettings { - preference: ThemePreference -} - -const ThemeSettingsSchema: z = z.object({ +/** Durable theme schema; also the wire envelope the browser scope validates against. */ +export const ThemeSettingsSchema: z = z.object({ [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), }) diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index e15985a9dc..51667dc5a9 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,10 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the settings seam validates and publishes the durable + * No runtime invariant: the settings scope validates and publishes the durable * theme section, while the registry emits `theme/change` synchronously with * its own mutations. Store/registry agreement is covered directly by this - * package's Host, controller, and service behavior specs. + * package's Host, scope, and service behavior specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index ca06ec28a7..1cf3a46808 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -15,6 +15,12 @@ export type ThemePreference = typeof THEME_PREFERENCES[number] /** Default preference when the user-settings document has no override. */ export const DEFAULT_PREFERENCE: ThemePreference = 'system' +/** Durable theme section shared by the Host schema and the browser scope. */ +export interface ThemeSettings { + /** Selected built-in preference. */ + preference: ThemePreference +} + /** * Narrow one wire or registry value to a persistable preference. * @param value - value crossing the settings or registry boundary. diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index d134340560..c92bf47e85 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -10,6 +10,7 @@ import { apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE, } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { ThemeSettingsSchema } from '@deepseek-ai/dsh-client-ui-theme' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -33,7 +34,7 @@ async function bench(isLoopback = true) { let preference = 'system' const namespace = () => ({ ns: THEME_SETTINGS_NAMESPACE, - schema: {}, + schema: ThemeSettingsSchema.toJSON(), value: { preference }, applies: 'live' as const, secrets: [], diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index f6d8a7ff62..b7fc3bd17a 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,19 +1,20 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -const make = (persist = vi.fn()): { +const make = (host = stubSettingsScope()): { ctx: Context theme: ThemeService events: ThemeSnapshot[] - persist: typeof persist + host: StubSettingsScope } => { const ctx = new Context() const events: ThemeSnapshot[] = [] ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) - return { ctx, theme: new ThemeService(ctx, persist), events, persist } + return { ctx, theme: new ThemeService(ctx, host.scope), events, host } } describe('ThemeService', () => { @@ -27,12 +28,12 @@ describe('ThemeService', () => { expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) }) - it('setTheme switches, requests persistence, republishes, and keeps DOM untouched', () => { - const { theme, events, persist } = make() + it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => { + const { theme, events, host } = make() theme.setTheme('dark') expect(theme.getTheme().preference).toBe('dark') expect(theme.getTheme().active.colorScheme).toBe('dark') - expect(persist).toHaveBeenCalledWith('dark') + expect(host.set).toHaveBeenCalledWith('preference', 'dark') expect(events).toHaveLength(1) expect(events[0]).toBe(theme.getTheme()) // The service never touches presentation state. @@ -40,19 +41,26 @@ describe('ThemeService', () => { // Same-value set is a no-op (no extra event). theme.setTheme('dark') expect(events).toHaveLength(1) - expect(persist).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledOnce() }) - it('syncs a Host preference without writing it back', () => { - const { theme, events, persist } = make() - theme.syncPreference('dark') + it('adopts a published Host section without writing it back', () => { + const { theme, events, host } = make() + host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) expect(theme.getTheme().preference).toBe('dark') expect(events).toHaveLength(1) - expect(persist).not.toHaveBeenCalled() - theme.syncPreference('dark') + expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { preference: 'dark' }, revision: 2 }) expect(events).toHaveLength(1) }) + it('adopts a section already standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true }) + const { theme } = make(host) + expect(theme.getTheme().preference).toBe('dark') + }) + it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { const { theme } = make() expect(() => { theme.setTheme('sepia') }).toThrow('not registered') @@ -61,7 +69,7 @@ describe('ThemeService', () => { }) it('registered themes join the snapshot; disposing the active one resets to default', () => { - const { theme, events, persist } = make() + const { theme, events, host } = make() const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } }) expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia']) theme.setTheme('sepia') @@ -71,7 +79,7 @@ describe('ThemeService', () => { expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) // Custom ids are in-process extension themes; only the built-in product // preferences cross the Host settings schema. - expect(persist).not.toHaveBeenCalled() + expect(host.set).not.toHaveBeenCalled() // register + set + dispose = three publishes; disposer is idempotent. expect(events.length).toBe(3) dispose() @@ -95,11 +103,11 @@ describe('ThemeService', () => { expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) - it('uses a no-op persistence callback when constructed directly', () => { - const ctx = new Context() - const theme = new ThemeService(ctx) - theme.setTheme('dark') - expect(theme.getTheme().preference).toBe('dark') + it('context dispose releases the scope subscription', async () => { + const { ctx, host } = make() + expect(host.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(host.listenerCount()).toBe(0) }) describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f72439c8d4..2f90a138e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -1400,6 +1403,9 @@ importers: cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery packages/client/schema-form: dependencies: diff --git a/vitest.config.ts b/vitest.config.ts index 84e273f0da..20768618f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -160,9 +160,9 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', - // This isolated scalar-settings lifecycle has complete unit coverage; + // This isolated settings-scope lifecycle has complete unit coverage; // keep it out of the broader client-runtime GUI debt exemption. - 'packages/client/runtime/src/**/!(settings-preference).ts', + 'packages/client/runtime/src/**/!(settings-scope).ts', // Keep the browser conversation tree under its existing GUI debt // exemption while gating the newly stateful Host half and vocabulary. 'packages/client/ui-conversation/src/client/*', From 53c66ecbebd21e3d589660d8afc156ccc9809cc4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 23:30:04 +0800 Subject: [PATCH 41/73] refactor(client): keep settings constants and schemas off the client contract surfaces The /client entry of a UI plugin exports no values beyond what cordis loading needs; the theme constants block returns to type-only re-exports, the per-namespace schemas move into the shared *-settings modules instead of widening the host entries, and same-package specs import those internals directly per the export discipline in packages/client/AGENTS.md. --- packages/client/locale/src/index.ts | 10 +--------- packages/client/locale/src/locale-settings.ts | 7 +++++++ packages/client/locale/tests/apply.spec.ts | 3 +-- packages/client/ui-conversation/src/index.ts | 11 +---------- .../client/ui-conversation/src/submission-settings.ts | 7 +++++++ packages/client/ui-theme/src/client/index.ts | 5 +---- packages/client/ui-theme/src/index.ts | 11 +---------- packages/client/ui-theme/src/theme-settings.ts | 7 +++++++ packages/client/ui-theme/tests/apply.spec.ts | 6 ++---- 9 files changed, 28 insertions(+), 39 deletions(-) diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index 3001890569..c8d7ed9f95 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,22 +1,14 @@ /** Host registration for the browser locale preference. */ import type { Context } from 'cordis' -import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleSettings, -} from './locale-settings.ts' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts' export { LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, } from './locale-settings.ts' -/** Durable locale schema; also the wire envelope the browser scope validates against. */ -export const LocaleSettingsSchema: z = z.object({ - [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), -}) - /** * Register the durable locale section when a settings provider exists. * @param ctx - Host context whose optional settings service owns the section. diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index 90459981fa..c5d0399f86 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -1,5 +1,7 @@ /** Locale preference stored in the Host user-settings document. */ +import z from 'schemastery' + /** Settings namespace owned by the locale plugin. */ export const LOCALE_SETTINGS_NAMESPACE = 'locale' @@ -17,3 +19,8 @@ export interface LocaleSettings { /** Explicit locale selection; absence delegates to the browser. */ preference?: LocaleId } + +/** Durable locale schema; also the wire envelope the browser scope validates against. */ +export const LocaleSettingsSchema: z = z.object({ + [LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false), +}) diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 152d0e6987..84f4299ef2 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -8,8 +8,7 @@ import { apply, inject, SETTINGS_NS, } from '@deepseek-ai/dsh-client-locale/client' import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { LOCALE_SETTINGS_NAMESPACE } from '../src/locale-settings.ts' -import { LocaleSettingsSchema } from '../src/index.ts' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { createLanguageRowStore } from '../src/client/settings-store.ts' diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 1d36164767..b49d7dcf0d 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,23 +1,14 @@ /** Host registration for browser conversation preferences. */ import type { Context } from 'cordis' -import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { - BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, - DEFAULT_BUSY_ENTER_BEHAVIOR, type ConversationSettings, -} from './submission-settings.ts' +import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts' export { BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, DEFAULT_BUSY_ENTER_BEHAVIOR, type BusyEnterBehavior, type ConversationSettings, } from './submission-settings.ts' -/** Durable conversation schema; also the wire envelope the browser scope validates against. */ -export const ConversationSettingsSchema: z = z.object({ - [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), -}) - /** * Register the durable conversation section when a settings provider exists. * @param ctx - Host context whose optional settings service owns the section. diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts index cf19472c47..0bd42d33cf 100644 --- a/packages/client/ui-conversation/src/submission-settings.ts +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -1,5 +1,7 @@ /** Busy-Enter preference stored in the Host user-settings document. */ +import z from 'schemastery' + /** Settings namespace owned by the conversation plugin. */ export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation' @@ -20,3 +22,8 @@ export interface ConversationSettings { /** Delivery mode for plain Enter while the addressed agent is busy. */ busyEnter: BusyEnterBehavior } + +/** Durable conversation schema; also the wire envelope the browser scope validates against. */ +export const ConversationSettingsSchema: z = z.object({ + [BUSY_ENTER_FIELD]: z.union([...BUSY_ENTER_BEHAVIORS]).default(DEFAULT_BUSY_ENTER_BEHAVIOR), +}) diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 73221b15a7..8ef7f3fee7 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -26,10 +26,7 @@ import { export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' export type { AppearanceRowState } from './settings-store.ts' export type { ThemeKey } from './locales.ts' -export { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemePreference, type ThemeSettings, -} from '../theme-settings.ts' +export type { ThemePreference, ThemeSettings } from '../theme-settings.ts' /** Namespace owning this feature's settings-row copy. */ export const SETTINGS_NS = 'settings.theme' diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 785e6ca898..576028d37d 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,23 +1,14 @@ /** Host registration for the browser theme preference. */ import type { Context } from 'cordis' -import z from 'schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { - DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, - type ThemeSettings, -} from './theme-settings.ts' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, type ThemeSettings, } from './theme-settings.ts' -/** Durable theme schema; also the wire envelope the browser scope validates against. */ -export const ThemeSettingsSchema: z = z.object({ - [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), -}) - /** * Register the durable theme section when a settings provider exists. * @param ctx - Host context whose optional settings service owns the section. diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index 1cf3a46808..d7fc966031 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -1,5 +1,7 @@ /** Theme preferences stored in the Host user-settings document. */ +import z from 'schemastery' + /** Built-in preferences accepted at the registry and settings boundaries. */ export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const @@ -21,6 +23,11 @@ export interface ThemeSettings { preference: ThemePreference } +/** Durable theme schema; also the wire envelope the browser scope validates against. */ +export const ThemeSettingsSchema: z = z.object({ + [THEME_PREFERENCE_FIELD]: z.union([...THEME_PREFERENCES]).default(DEFAULT_PREFERENCE), +}) + /** * Narrow one wire or registry value to a persistable preference. * @param value - value crossing the settings or registry boundary. diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index c92bf47e85..25c2ac14df 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -6,11 +6,9 @@ import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { - apply, inject, SETTINGS_NS, THEME_SETTINGS_NAMESPACE, -} from '@deepseek-ai/dsh-client-ui-theme/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -import { ThemeSettingsSchema } from '@deepseek-ai/dsh-client-ui-theme' +import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' From 5ed934935a564eaf5806ee8c102c92ede8fe599d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:00:28 +0800 Subject: [PATCH 42/73] 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 16c1eaf546d4d59d9e8786d96280bd724665104a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:05:27 -0700 Subject: [PATCH 43/73] fix(fs-local): bound overwrite contextual diff bases Rebuild of the fs-overwrite-diff-bound branch on current master. Adds the diffBasisMaxBytes Config field (10 MiB default, capped by runtime allocation/decode limits), gates both overwrite sides, and reads the prior basis from the bounded opened descriptor in cancellation-aware chunks; any post-stat size change returns a null basis. Also pins the one-extra-byte growth probe with a regression and drops the now-covered v8 ignore. --- ...-30-bounded-overwrite-diff-basis.i18n.yaml | 6 + ...2026-07-30-bounded-overwrite-diff-basis.md | 31 +++ ...6-07-30-bounded-overwrite-diff-basis.zh.md | 31 +++ docs/config-catalog.md | 15 +- .../core-data-structures/filesystem.i18n.yaml | 4 +- docs/core-data-structures/filesystem.md | 9 +- docs/core-data-structures/filesystem.zh.md | 9 +- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/README.zh.md | 4 +- packages/fs/fs-local/src/fsio.ts | 57 ++++- packages/fs/fs-local/src/index.ts | 35 ++- packages/fs/fs-local/tests/filesystem.spec.ts | 63 ++++++ packages/fs/fs-local/tests/fsio.spec.ts | 202 +++++++++++++++++- packages/fs/fs-sandbox/README.i18n.yaml | 4 +- packages/fs/fs-sandbox/README.md | 2 + packages/fs/fs-sandbox/README.zh.md | 2 + packages/fs/fs-sandbox/src/index.ts | 8 +- packages/fs/fs/src/types.ts | 9 +- 19 files changed, 452 insertions(+), 47 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml new file mode 100644 index 0000000000..f7361eda32 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.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-30-bounded-overwrite-diff-basis.md +2026-07-30-bounded-overwrite-diff-basis.md: 353b538a12b8cf48dfa3a561c62d6d8ab9a8bfcf +2026-07-30-bounded-overwrite-diff-basis.zh.md: e2b473a21d16dc47b5b8bf781b8ddffdf443955b diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md new file mode 100644 index 0000000000..353b538a12 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md @@ -0,0 +1,31 @@ +# Agent Note: Bound overwrite contextual-diff bases at the provider + +Status: implemented + +English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md) + +## Problem + +`dsh-fs-local` returned the complete prior file in `FsWriteOutcome.before` so consumers could build a contextual overwrite diff. That presentation-only pre-read was unbounded: a large overwrite could allocate the entire prior file, and checking an earlier path stat alone could not enforce a limit because an external process could replace or grow the file between the stat and the read. A large replacement also made the contextual hunk approach the replacement size even when the prior file was small. This closes the deferred bound recorded by [result-time applied-hunk diffs](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md). + +## Decision + +`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`. These outcomes do not block the atomic write. + +The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value. + +`before: null` asks consumers to use their existing whole-file fallback. The limit bounds only the extra prior-content acquisition and eligibility for a contextual pair. It does not bound the caller-owned replacement, the returned `after` value, or a consumer's fallback rendering. + +## Alternatives considered + +**Keep a hardcoded threshold equal to the read tool's streaming threshold.** Rejected because the read threshold is deployment-configurable and consumer-owned. Two same-valued constants would create an unenforced cross-package coupling, while the overwrite basis is itself a deployment memory/presentation choice. + +**Gate only the prior side in the provider and cap new-content diffing in `tool-fs`.** Rejected because it would acquire prior text even when the provider's configured pair limit already excludes the replacement, and it would split one `before` eligibility rule across two plugins. Consumers remain free to impose additional output limits. + +**Trust the initial `probe()` size before using an ordinary whole-file read.** Rejected because that size can become stale before the read. The descriptor reader must enforce the bound on the object it actually reads. + +**Stream a contextual diff for arbitrarily large pairs.** Rejected for this bug fix because the current filesystem seam returns complete `before`/`after` strings and the current diff implementation consumes them. A streaming diff would require a separate cross-package protocol and presentation design. + +## Consequences + +Deployments can tune the extra overwrite-basis cost without changing read routing. At or above the exclusive limit, overwrites still succeed and remain visible through the whole-file fallback, but lose contextual hunks. Below the limit, the provider can still hold almost `diffBasisMaxBytes` of prior text in addition to the caller's replacement. The bounded descriptor read adds an open/stat/read sequence for eligible overwrites, while preventing a stale path probe from turning that sequence into an unbounded allocation. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md new file mode 100644 index 0000000000..e2b473a21d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 在提供方限制覆写上下文 diff 基础 + +Status: implemented + +[English](2026-07-30-bounded-overwrite-diff-basis.md) | 中文 + +## Problem + +`dsh-fs-local` 会在 `FsWriteOutcome.before` 中返回完整旧文件,供消费方生成覆写上下文 diff。这个仅用于展示的预读没有上限:大文件覆写可能分配整个旧文件;而仅检查较早的路径 stat 也无法真正实施上限,因为外部进程可以在 stat 与读取之间替换文件或扩大文件。即使旧文件很小,大替换内容也会使上下文 hunk 接近替换内容本身的大小。本改动关闭了 [result-time applied-hunk diff](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md) 中记录的暂缓上限事项。 + +## Decision + +`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`。这些结果都不会阻止原子写入。 + +本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。 + +`before: null` 要求消费方使用既有的整文件回退。该上限只限制额外获取旧内容的成本,以及上下文内容对是否合格;它不限制调用方持有的替换内容、返回的 `after` 值或消费方的回退渲染。 + +## Alternatives considered + +**保留一个与读取工具流式阈值相等的硬编码阈值。** 否决,因为读取阈值可由部署配置,且归消费方所有。两个同值常量会形成无法强制的一致性耦合,而覆写基础本身也是部署层面的内存与展示选择。 + +**提供方只限制旧内容一侧,并在 `tool-fs` 中限制新内容 diff。** 否决,因为当提供方配置的成对上限已经排除替换内容时,这仍会获取旧文本;同时会把同一条 `before` 合格规则拆到两个插件中。消费方仍可自由施加额外的输出限制。 + +**信任初次 `probe()` 的大小,再执行普通整文件读取。** 否决,因为该大小可能在读取前变旧;描述符读取必须对它真正读取的对象实施上限。 + +**为任意大的内容对流式生成上下文 diff。** 本次缺陷修复不采用,因为当前文件系统 seam 返回完整的 `before`/`after` 字符串,当前 diff 实现也消费这两个字符串。流式 diff 需要独立的跨包协议与展示设计。 + +## Consequences + +部署可以调整额外的覆写基础成本,而不改变读取路由。达到或超过排他上限时,覆写仍会成功,并通过整文件回退保持可见,但不再提供上下文 hunk。低于上限时,除调用方的替换内容外,提供方仍可能持有接近 `diffBasisMaxBytes` 的旧文本。对于合格覆写,有上限的描述符读取会增加一次 open/stat/read 序列,同时防止陈旧路径探测把该序列变成无上限分配。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index feb8aa9d86..f3a59d4ec4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -460,10 +460,15 @@ Source: [`packages/host/frontend-static/src/index.ts:28`](../packages/host/front export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ cwd?: string + /** + * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the + * runtime's safe allocation/decode maximum. Defaults to 10 MiB. + */ + diffBasisMaxBytes?: number } ``` -Source: [`packages/fs/fs-local/src/index.ts:38`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -471,10 +476,10 @@ Requires: `sandboxPolicy` ```ts config-catalog /** - * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve - * base for relative paths). The sandbox default (mode + `workspace-write` - * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling - * session for every enforcing capability. + * Plugin config: the local backend's knobs verbatim (`cwd` resolution default + * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default + * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy` + * resolves each calling session for every enforcing capability. */ export type Config = LocalConfig ``` diff --git a/docs/core-data-structures/filesystem.i18n.yaml b/docs/core-data-structures/filesystem.i18n.yaml index 91ac0ed81d..a4a42e72d2 100644 --- a/docs/core-data-structures/filesystem.i18n.yaml +++ b/docs/core-data-structures/filesystem.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/filesystem.md -filesystem.md: 110c1fd428b15c5094f9dcc94050cad61c324373 -filesystem.zh.md: 010ec22a5d4a29555425c3deede08b317cba7004 +filesystem.md: 4862a25e3b8922a8709b4b025acd5436a2228082 +filesystem.zh.md: 2b5c7abec1555cc2cbb909eaae60d9022ae297dd diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 110c1fd428..4862a25e3b 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -134,10 +134,11 @@ interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ diff --git a/docs/core-data-structures/filesystem.zh.md b/docs/core-data-structures/filesystem.zh.md index 010ec22a5d..2b5c7abec1 100644 --- a/docs/core-data-structures/filesystem.zh.md +++ b/docs/core-data-structures/filesystem.zh.md @@ -134,10 +134,11 @@ interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index cc14be584f..cbc0c56619 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-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/fs/fs-local/README.md -README.md: 6d344fa3fef7f6bda6c0daa50184661156a925a7 -README.zh.md: 4c94de64561d805684f91f02d5b0dfc375f0d04f +README.md: 9efee1ed3c33c825b20b4565f3c6cef7200ce4a2 +README.zh.md: 5554017f3d528e25decffe2f866c843039bb0457 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 6d344fa3fe..9efee1ed3c 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -18,7 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback. - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. @@ -34,8 +34,8 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)). -- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`). - **Version tokens are `mtimeMs:size`** — an external change that preserves both within the filesystem's timestamp granularity defeats the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. +- **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or presentation's whole-file fallback. - **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits. - **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 4c94de6456..5554017f3d 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -18,7 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非已失效的「不存在」结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片流式读取(跨分片解码),因此超大文件无需整体保存在内存中。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)按大小决定调用哪个方法,并负责行窗口逻辑。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 -- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问策略得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选(OPTIONAL)的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 +- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问策略得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选(OPTIONAL)的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。 - **`editText`**:在同一原语之上执行原子式的字面量读取-修改-写入,并通过变更锁按目标串行化。`expected` 防护是可选(OPTIONAL)的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 包根目录的 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 @@ -34,8 +34,8 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 已知限制与暂缓事项 - **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences))。 -- **覆盖会把整个旧文件读入内存**:只用于 UI diff;在大小阈值之上限制这次预读取的工作延期处理(`TODO(overwrite-diff-bound)`)。 - **版本 token 是 `mtimeMs:size`**:如果外部变更在文件系统时间戳粒度内保持两者不变,就能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 +- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制展示层的整文件回退。 - **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。 - **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index c93e4ddaeb..b47d6f2bcd 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -15,6 +15,8 @@ import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts' const BINARY_SAMPLE_BYTES = 8192 +// Bound one non-abortable FileHandle.read so cancellation is observed between chunks. +const DIFF_BASIS_READ_CHUNK_BYTES = 64 * 1024 function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' @@ -70,9 +72,8 @@ function versionOf(info: BigIntStats): FsVersion { } /** - * Test seam: lets specs pin the atomic-write temp names (to prove - * exclusive-open behavior without a name race) and observe the staged temp - * file before it is renamed over the target. + * Test seam: lets specs pin the atomic-write temp names (to prove exclusive-open behavior without + * a name race), override native boundaries, and observe the staged temp file before publication. */ export interface FsIoInternals { /** Override the host platform for native-publication unit coverage. */ @@ -564,17 +565,53 @@ export async function readForEdit( } /** - * Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still - * succeeds and presentation falls back to a whole-file diff. + * Best-effort overwrite diff basis. Binary, invalid UTF-8, or a file at/above the byte limit + * returns `null` so the write still succeeds and presentation falls back to a whole-file diff. + * The bound is enforced on the opened descriptor rather than a prior path stat, so concurrent + * external replacement or size changes cannot make this helper buffer more than `maxBytes`. * @param absolutePath - the file to read (typically a target key); it must exist. + * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis. * @param signal - aborts the read (`FS_ABORTED`). - * @returns the LF-normalized text, or null for a binary or non-UTF-8 file. + * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8, + * or descriptor-size-changed file. */ -export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise { - const buffer = await readFileAbortable(absolutePath, 'read', signal) - if (buffer.includes(0)) return null +export async function readTextForDiff( + absolutePath: string, + maxBytes: number, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal, 'read') + const handle = await open(absolutePath, 'r') + let buffer: Buffer + let total = 0 + let openedSize = 0 try { - return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer)) + throwIfAborted(signal, 'read') + const info = await handle.stat() + throwIfAborted(signal, 'read') + /* v8 ignore next -- requires a post-preflight replacement with a non-file; + * direct coverage is not portable to Windows. */ + if (!info.isFile()) return null + if (info.size >= maxBytes) return null + openedSize = info.size + // One extra byte detects growth after stat without retaining per-read backing buffers. + buffer = Buffer.allocUnsafe(openedSize + 1) + while (total < buffer.length) { + throwIfAborted(signal, 'read') + const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) + const { bytesRead } = await handle.read(buffer, total, length, null) + if (bytesRead === 0) break + total += bytesRead + } + } finally { + await handle.close() + } + throwIfAborted(signal, 'read') + if (total !== openedSize) return null + const basis = buffer.subarray(0, total) + if (basis.includes(0)) return null + try { + return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) } catch (error: unknown) { /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ if (!(error instanceof TypeError)) throw error diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 18433f3f7c..33a1c354b6 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -5,6 +5,7 @@ */ import { Context } from 'cordis' +import { constants as bufferConstants } from 'node:buffer' import { resolve } from 'node:path' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' @@ -38,9 +39,19 @@ import type { FsIoInternals } from './fsio.ts' export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ cwd?: string + /** + * Exclusive UTF-8 byte limit on each overwrite-diff side, capped by the + * runtime's safe allocation/decode maximum. Defaults to 10 MiB. + */ + diffBasisMaxBytes?: number } type ResolvedConfig = Required +const DEFAULT_DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024 +const MAX_DIFF_BASIS_BYTES = Math.min( + bufferConstants.MAX_LENGTH, + bufferConstants.MAX_STRING_LENGTH, +) /** * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd} @@ -51,11 +62,12 @@ type ResolvedConfig = Required export class LocalFileSystem extends FileSystem { static Config: z = z.object({ cwd: z.string().default(process.cwd()), + diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES), }) /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig - /** Test seam forwarded to fsio (force streaming path, pin temp names). */ + /** Test seam forwarded to fsio for atomic-publication boundaries. */ internals: FsIoInternals = {} /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write * window can't interleave, making concurrent writes/edits deterministically @@ -64,7 +76,13 @@ export class LocalFileSystem extends FileSystem { constructor(ctx: Context, config: Config) { super(ctx) - this.config = config as ResolvedConfig + const resolved = config as ResolvedConfig + if (!Number.isSafeInteger(resolved.diffBasisMaxBytes) + || resolved.diffBasisMaxBytes <= 0 + || resolved.diffBasisMaxBytes > MAX_DIFF_BASIS_BYTES) { + throw new Error(`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${MAX_DIFF_BASIS_BYTES}`) + } + this.config = resolved } /** Run `op` with exclusive access to `targetKey` (FIFO per key). */ @@ -150,9 +168,16 @@ export class LocalFileSystem extends FileSystem { } // No expectation means an unconditional but still atomic write. - // Preserve prior text for contextual diffs; null falls back to a whole-file diff. - // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files. - const before = existing ? await readTextForDiff(target.targetKey, signal) : null + // Capture an optional contextual-diff basis before the write. The bounded + // reader checks the opened file itself, so an external replacement after + // `probe()` cannot turn this best-effort presentation read into an + // unbounded allocation. Either side at/above the configured limit yields + // `before: null`; consumers retain their whole-file fallback. + const diffable = existing !== null + && Buffer.byteLength(content, 'utf8') < this.config.diffBasisMaxBytes + const before = diffable + ? await readTextForDiff(target.targetKey, this.config.diffBasisMaxBytes, signal) + : null await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) return { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 61e2c0e999..5b66eaa2a0 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -7,6 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { constants as bufferConstants } from 'node:buffer' import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -42,13 +43,39 @@ async function versionOf(target: FsTarget): Promise { return info.version } +async function remountWithDiffLimit(diffBasisMaxBytes: number): Promise { + await fiber.dispose() + fiber = await ctx.plugin(LocalFileSystem, { cwd: dir, diffBasisMaxBytes }) + fs = ctx.fs as LocalFileSystem +} + describe('registration', () => { it('registers LocalFileSystem as ctx.fs with a default cwd', async () => { const bare = new Context() const bareFiber = await bare.plugin(LocalFileSystem) expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd()) + expect((bare.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(10 * 1024 * 1024) await bareFiber.dispose() }) + + it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => { + const maxDiffBasisBytes = Math.min( + bufferConstants.MAX_LENGTH, + bufferConstants.MAX_STRING_LENGTH, + ) + const valid = new Context() + const validFiber = await valid.plugin(LocalFileSystem, { diffBasisMaxBytes: maxDiffBasisBytes }) + expect((valid.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(maxDiffBasisBytes) + await validFiber.dispose() + + for (const diffBasisMaxBytes of [0, -1, 1.5, maxDiffBasisBytes + 1, Number.MAX_SAFE_INTEGER + 1]) { + const invalid = new Context() + await expect(invalid.plugin(LocalFileSystem, { diffBasisMaxBytes })).rejects.toThrow( + `fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${maxDiffBasisBytes}`, + ) + await invalid.fiber.dispose() + } + }) }) describe('resolve', () => { @@ -384,6 +411,42 @@ describe('writeText', () => { expect(outcome.after).toBe('now valid') }) + it('an overwrite of a prior file AT the whole-file bound reports before:null (undiffable), still succeeds', async () => { + // The configured bound keeps the fixture small; 8 bytes at a bound of 8 + // pins the exclusive edge without coupling this provider to a read tool. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'big.txt'), '12345678') + const target = await fs.resolve('big.txt') + const outcome = await fs.writeText(target, 'tiny') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('tiny') + }) + + it('an overwrite whose NEW content is at the whole-file bound reports before:null (no huge contextual diff)', async () => { + // The bound gates BOTH sides of the diff pair: a small prior file rewritten + // with at/above-bound content yields no contextual-hunk basis either, since + // a small-to-huge rewrite's hunk is as large as the new content — the + // consumer must fall back to the whole-file diff card, exactly like a + // create of the same size. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'grow.txt'), 'tiny') + const target = await fs.resolve('grow.txt') + const outcome = await fs.writeText(target, '12345678') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('12345678') + }) + + it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => { + await remountWithDiffLimit(8) + await writeFile(join(dir, 'small.txt'), '1234567') + const target = await fs.resolve('small.txt') + const outcome = await fs.writeText(target, 'new') + expect(outcome.before).toBe('1234567') + expect(outcome.after).toBe('new') + }) + it('releases per-target mutation locks after success and failure', async () => { const target = await fs.resolve('a.txt') await fs.writeText(target, 'created', { kind: 'createIfAbsent' }) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 15588e40b9..32232c424c 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -5,7 +5,7 @@ * policy and lives in `dsh-fs-policy`, so it is not tested here. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -16,6 +16,7 @@ import { probe, probeNoFollow, readForEdit, + readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -316,6 +317,205 @@ describe('readWholeText', () => { }) }) +describe('readTextForDiff', () => { + it('returns normalized text only when the opened file is strictly below the limit', async () => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'a\r\nb') + expect(await readTextForDiff(file, 5)).toBe('a\nb') + expect(await readTextForDiff(file, 4)).toBeNull() + }) + + it('bounds the actual opened file rather than trusting an earlier path size', async () => { + const file = join(dir, 'replaced.txt') + await writeFile(file, 'tiny') + const earlierSize = (await stat(file)).size + await writeFile(file, '123456789') + expect(earlierSize).toBeLessThan(8) + expect(await readTextForDiff(file, 8)).toBeNull() + }) + + it('returns null when the opened file shrinks after descriptor stat', async () => { + const file = join(dir, 'shrinking.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + await writeFile(file, 'abc') + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 8)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('returns null when the opened file grows after descriptor stat', async () => { + // Pins the one-extra-byte EOF probe: with a buffer of exactly openedSize a + // grown file would read openedSize bytes and pass the consistency check. + const file = join(dir, 'growing.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + await writeFile(file, 'abcdef-grown') + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 32)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) + await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) + expect(await readTextForDiff(join(dir, 'bin'), 8)).toBeNull() + expect(await readTextForDiff(join(dir, 'bad'), 8)).toBeNull() + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'text') + await expect(readTextForDiff(file, 8, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it.each(['open', 'stat'] as const)('observes cancellation immediately after %s', async (stage) => { + const file = join(dir, 'basis.txt') + await writeFile(file, 'text') + const reached = Promise.withResolvers() + const release = Promise.withResolvers() + let statCalls = 0 + const allocate = vi.spyOn(Buffer, 'allocUnsafe') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + if (stage === 'open') { + reached.resolve(undefined) + await release.promise + } + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + statCalls += 1 + const info = await handle.stat(...statArgs) + if (stage === 'stat') { + reached.resolve(undefined) + await release.promise + } + return info + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + const controller = new AbortController() + const pending = isolatedReadTextForDiff(file, 8, controller.signal) + await reached.promise + const allocationCalls = allocate.mock.calls.length + controller.abort() + release.resolve(undefined) + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + expect(statCalls).toBe(stage === 'open' ? 0 : 1) + expect(allocate).toHaveBeenCalledTimes(allocationCalls) + } finally { + release.resolve(undefined) + allocate.mockRestore() + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('bounds descriptor reads and observes cancellation before the next chunk', async () => { + const file = join(dir, 'large-basis.txt') + const fileBytes = 200 * 1024 + await writeFile(file, 'x'.repeat(fileBytes)) + const firstRead = Promise.withResolvers() + const releaseFirstRead = Promise.withResolvers() + const readLengths: number[] = [] + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + stat: handle.stat.bind(handle), + close: handle.close.bind(handle), + async read(buffer: Buffer, offset: number, length: number, position: number | null) { + readLengths.push(length) + const result = await handle.read(buffer, offset, length, position) + if (readLengths.length === 1) { + firstRead.resolve(undefined) + await releaseFirstRead.promise + } + return result + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + const controller = new AbortController() + const pending = isolatedReadTextForDiff(file, fileBytes + 1, controller.signal) + await firstRead.promise + expect(readLengths).toEqual([64 * 1024]) + controller.abort() + releaseFirstRead.resolve(undefined) + await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) + expect(readLengths).toHaveLength(1) + } finally { + releaseFirstRead.resolve(undefined) + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) +}) + describe('streamWholeText', () => { it('streams the whole file as decoded text', async () => { const file = join(dir, 'a.txt') diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml index 35a33d135b..4f7771cd3c 100644 --- a/packages/fs/fs-sandbox/README.i18n.yaml +++ b/packages/fs/fs-sandbox/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/fs/fs-sandbox/README.md -README.md: c40fc7999ab85a70702f65a5675208163f5fc351 -README.zh.md: 15db5abbfc5307c0570925026ec435d8dbb51bf2 +README.md: ae1fd746c711a86e308a02e0054ba478e8d913c0 +README.zh.md: e25a3467c06fbd93de9bb75d6b364e8da5a451fe diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index c40fc7999a..ae1fd746c7 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) `SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading. +Its plugin config is the local backend config unchanged: `cwd` remains the relative-path resolution default, and `diffBasisMaxBytes` bounds the optional overwrite contextual-diff basis. + Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. The tool layer resolves the calling session's mode and cwd into the SAME per-call policy bash receives, so the two families never confine to different roots. ## The fence diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md index 15db5abbfc..e25a3467c0 100644 --- a/packages/fs/fs-sandbox/README.zh.md +++ b/packages/fs/fs-sandbox/README.zh.md @@ -4,6 +4,8 @@ `SandboxedFileSystem` 扩展 [`LocalFileSystem`](../fs-local/README.md) 并注册为 `ctx.fs`。它逐字继承全部文本存储机制(解析、stat、读取/流式读取、列出、原子写入、按读取、匹配、写入顺序执行的编辑临界区),只为 `writeText`/`editText` 增加按调用的模式围栏。读取始终直接通过:所有模式都允许读取。 +它原样复用本地后端配置:`cwd` 仍是相对路径的解析默认值,`diffBasisMaxBytes` 则限制可选的覆写上下文 diff 基础。 + 只需加载它来替代 `dsh-fs-local`,并同时加载 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md),即可完成替换;面向模型的工具(`dsh-tool-fs`)无需改动。工具层把调用会话的模式和 cwd 解析为与 bash 相同的按调用策略,因此两个能力族绝不会约束到不同根目录。 ## 围栏 diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 5e0121c89a..51248149ff 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -41,10 +41,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy' import { isPathUnder } from './containment.ts' /** - * Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve - * base for relative paths). The sandbox default (mode + `workspace-write` - * fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling - * session for every enforcing capability. + * Plugin config: the local backend's knobs verbatim (`cwd` resolution default + * and `diffBasisMaxBytes` overwrite-presentation bound). The sandbox default + * (mode + `workspace-write` fallback root) is NOT here — `ctx.sandboxPolicy` + * resolves each calling session for every enforcing capability. */ export type Config = LocalConfig diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index f5753f09bd..a44f4c6bc7 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -123,10 +123,11 @@ export interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text - * (the diff basis), never a diff — a consumer computes the result-time - * contextual diff from `before`/`after` when `before` is present, else falls - * back to a whole-file diff. + * (a create) or the backend declined a contextual basis (for example, a + * binary/non-UTF-8 prior file or either overwrite side reaching its exclusive limit). + * LF-normalized storage text (the diff basis), never a diff — a consumer + * computes the result-time contextual diff from `before`/`after` when + * `before` is present, else falls back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ From da05e5f058ff897bbf7e302ede2be97cd6a15605 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 02:07:54 +0800 Subject: [PATCH 44/73] 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 45/73] 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 d7705e2dcd8e8f8ca44e2188d9284014b478cdab Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:56:08 -0700 Subject: [PATCH 46/73] test(fs-local): pin multibyte byte-length gating --- packages/fs/fs-local/tests/filesystem.spec.ts | 12 ++++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 2 -- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 5b66eaa2a0..4f52e232d1 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -438,6 +438,18 @@ describe('writeText', () => { expect(outcome.after).toBe('12345678') }) + it('gates the NEW content by UTF-8 byte length, not character count', async () => { + // Three CJK characters are 9 UTF-8 bytes: below an 8-byte bound by + // characters but at/above it by bytes, so the basis must be declined. + await remountWithDiffLimit(8) + await writeFile(join(dir, 'cjk.txt'), 'tiny') + const target = await fs.resolve('cjk.txt') + const outcome = await fs.writeText(target, '你好吗') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('你好吗') + }) + it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => { await remountWithDiffLimit(8) await writeFile(join(dir, 'small.txt'), '1234567') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 32232c424c..d800997d0f 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -367,8 +367,6 @@ describe('readTextForDiff', () => { }) it('returns null when the opened file grows after descriptor stat', async () => { - // Pins the one-extra-byte EOF probe: with a buffer of exactly openedSize a - // grown file would read openedSize bytes and pass the consistency check. const file = join(dir, 'growing.txt') await writeFile(file, 'abcdef') vi.resetModules() From 9a299f9827e6d34158ce3e50d4ab5062c886f9a7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:49:42 -0700 Subject: [PATCH 47/73] fix(fs-local): degrade basis I/O failures to null Descriptor-phase errnos in readTextForDiff fold to before: null so a file deleted or made unreadable after the caller's preflight cannot fail the committed write; cancellation and non-errno faults still propagate. Drops the now-covered isFile v8 ignore, extends llm-replay with catalog capability parity (defaultMaxTokens/reasoningEfforts), and records the fs-write-overwrite-bounded keyless snapshot pinning the over-limit whole-file fallback through the real acp-agent composition. --- ...-30-bounded-overwrite-diff-basis.i18n.yaml | 4 +- ...2026-07-30-bounded-overwrite-diff-basis.md | 2 +- ...6-07-30-bounded-overwrite-diff-basis.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 16 ++++ .../tests/fs-diff-bound.cordis.snapshot.yml | 46 ++++++++++ .../acp-agent/tests/fs-diff-bound.cordis.yml | 29 +++++++ .../fs-write-overwrite-bounded/input.json | 7 ++ .../fs-write-overwrite-bounded/session.jsonl | 42 +++++++++ .../stdout.expected.jsonl | 4 + .../workspace/data.txt | 1 + packages/fs/fs-local/src/fsio.ts | 86 +++++++++++-------- packages/fs/fs-local/tests/fsio.spec.ts | 58 +++++++++++++ packages/support/llm-replay/src/index.ts | 27 +++++- 13 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml index f7361eda32..da4e330119 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.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-30-bounded-overwrite-diff-basis.md -2026-07-30-bounded-overwrite-diff-basis.md: 353b538a12b8cf48dfa3a561c62d6d8ab9a8bfcf -2026-07-30-bounded-overwrite-diff-basis.zh.md: e2b473a21d16dc47b5b8bf781b8ddffdf443955b +2026-07-30-bounded-overwrite-diff-basis.md: 7a09934bd1798059de43a092f338d37aa9ccbd9a +2026-07-30-bounded-overwrite-diff-basis.zh.md: 1d6bdd1068d119aae859132d9f8216ca29d0dc11 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md index 353b538a12..7a09934bd1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md @@ -10,7 +10,7 @@ English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md) ## Decision -`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`. These outcomes do not block the atomic write. +`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`, as does any descriptor-phase errno — a prior file deleted or made unreadable between the caller's preflight and the basis open cannot fail a write the caller already committed to; only cancellation and non-errno faults propagate. These outcomes do not block the atomic write. The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md index e2b473a21d..1d6bdd1068 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`。这些结果都不会阻止原子写入。 +`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`;描述符阶段的任何 errno 同样如此——旧文件在调用方预检之后、基础读取打开之前被删除或变得不可读,不能让调用方已经提交的写入失败;只有取消和非 errno 故障会继续向上传播。这些结果都不会阻止原子写入。 本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c09dd7d2ed..79c63fb642 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -52,6 +52,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -253,6 +254,21 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-write', hasModelTurn: true, recorded: true }, { name: 'fs-edit', hasModelTurn: true, recorded: true }, { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, + // An overwrite whose replacement is at/above the configured diff-basis bound: + // the persisted result meta carries no contextual hunks and presentation + // falls back to the whole-file diff. The overlay leaves the prompt and tool + // sequence identical to text-turn, but the freshly recorded header carries + // the current adapter capability fields, so the scenario pins its own class. + { + name: 'fs-write-overwrite-bounded', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'fs-diff-bound', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', + configPath: FS_DIFF_BOUND_CONFIG, + }, { name: 'fs-read-window', hasModelTurn: true, recorded: true }, { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml new file mode 100644 index 0000000000..a216a34cfd --- /dev/null +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml @@ -0,0 +1,46 @@ +# Keyless replay counterpart to fs-diff-bound.cordis.yml. Replay patches apply +# directly against the live cordis.yml because include patches cannot target +# entries behind a nested include; the acp-agent restatement keeps the recorded +# deepseek-v4-flash model and raw JSONL persistence for the harness's harvest. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + diffBasisMaxBytes: 64 + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + # Capability parity with the live adapter so replay + # reconstructs the freshly recorded request header. + - id: deepseek-v4-flash + contextWindow: 1000000 + defaultMaxTokens: 256000 + reasoningEfforts: ['off', 'high', 'max'] + defaultReasoningEffort: max + - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.yml new file mode 100644 index 0000000000..9be25cdb25 --- /dev/null +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.yml @@ -0,0 +1,29 @@ +# Live counterpart for the bounded-overwrite-diff snapshot: the base stack with +# the fs backend's overwrite diff-basis limit shrunk so a modest replacement +# crosses the exclusive bound and the write result falls back to a whole-file +# diff. A config patch replaces the row's whole config, so `cwd` is restated +# verbatim, and the acp-agent restatement re-pins `deepseek-v4-flash` to match +# the recorded corpus and its pinned request headers. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + diffBasisMaxBytes: 64 diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json new file mode 100644 index 0000000000..480c37827a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl new file mode 100644 index 0000000000..688bf9cb70 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl @@ -0,0 +1,42 @@ +{"type":"session","version":0,"id":"14b14f51-2428-43a0-bcc5-5f392d4faa19","createdAt":1786204699215,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786204699218,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"}]}} +{"type":"turn/start","seq":1,"time":1786204699219,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786204699220,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786204699259,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786204699259,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786204699260,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e374fb32-1cad-4e2d-9cd3-66ac8fcf9588"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786204699261,"data":{"title":"First use the read tool","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786204699262,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786204699262,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":1786204701601,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1786204701602,"data":{"turn":1,"step":1,"index":0,"dt":[60,22,2,1,0,1,0,19,2,1,1,17,21,2,0,20,2,1,21,0,0,0,1,21,2],"texts":["The"," user"," wants"," me"," to"," read"," data",".txt"," first",","," then"," write"," to"," replace"," its"," contents"," with"," the"," exact"," line",","," then"," reply"," D","ONE","."]}} +{"type":"assistant/chunk","seq":36,"time":1786204701863,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":37,"time0":1786204701864,"data":{"turn":1,"step":1,"index":1,"dt":[21,2,0,21,2,1,0,26,1,0,17],"id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":49,"time":1786204701981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."}}}} +{"type":"assistant/chunk","seq":50,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":52,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"time":1786204701988,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."},{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9060e190-9971-4838-81bf-48c3e3888609"},"usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":1786204701990,"data":{"turn":1,"step":1,"callId":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":55,"time":1786204702006,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Jxz49JNt6i4oaDnzes2I0794"},"content":[{"type":"tool-result","toolCallId":"call_00_Jxz49JNt6i4oaDnzes2I0794","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1406fd7d-f181-41d0-b0db-ef196010f620"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"step/end","seq":56,"time":1786204702006,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":57,"time":1786204702016,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":58,"time":1786204703539,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":59,"time0":1786204703539,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,1,0,0,1,0,12,2,0,0,0,22,35,1,0,0,0,0,0,1,0,8,2,0,0,71,1,0,0,1,0],"id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"",", ","\"","content","\"",": ","\"","The"," replacement"," line"," is"," deliberately"," longer"," than"," the"," configured"," sixty","-four"," byte"," diff","-b","asis"," bound",".","\"","}"]}} +{"type":"assistant/chunk","seq":95,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}}} +{"type":"assistant/chunk","seq":96,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":97,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":98,"time":1786204703722,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"46d3792a-eded-45e7-8151-00ca0584f10b"},"usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}},"sourceEventSeqs":[58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"} +{"type":"tool/call","seq":99,"time":1786204703722,"data":{"turn":1,"step":2,"callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}} +{"type":"tool/result","seq":100,"time":1786204703740,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"98c41fc1-6ce6-445f-94f7-32aa7e1c6ea7"},"meta":{"diffs":[]}},"sourceEventSeqs":[99],"surfaceOp":"append"} +{"type":"step/end","seq":101,"time":1786204703740,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":102,"time":1786204703749,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":103,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":104,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":105,"time":1786204705053,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":106,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":107,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":108,"time":1786204705056,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":109,"time":1786204705057,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ddf50859-b0b9-404d-a71c-a1f11ff53341"},"usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}},"sourceEventSeqs":[103,104,105,106,107,108],"surfaceOp":"append"} +{"type":"step/end","seq":110,"time":1786204705057,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":111,"time":1786204705058,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt new file mode 100644 index 0000000000..3359a4b8d9 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt @@ -0,0 +1 @@ +original contents \ No newline at end of file diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b47d6f2bcd..cba29e873c 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -565,15 +565,16 @@ export async function readForEdit( } /** - * Best-effort overwrite diff basis. Binary, invalid UTF-8, or a file at/above the byte limit - * returns `null` so the write still succeeds and presentation falls back to a whole-file diff. - * The bound is enforced on the opened descriptor rather than a prior path stat, so concurrent - * external replacement or size changes cannot make this helper buffer more than `maxBytes`. - * @param absolutePath - the file to read (typically a target key); it must exist. + * Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit, + * or a file deleted/made unreadable after the caller's preflight returns `null` so the write + * still succeeds and presentation falls back to a whole-file diff. The bound is enforced on + * the opened descriptor rather than a prior path stat, so concurrent external replacement or + * size changes cannot make this helper buffer more than `maxBytes`. + * @param absolutePath - the file to read (typically a target key). * @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis. - * @param signal - aborts the read (`FS_ABORTED`). + * @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure. * @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8, - * or descriptor-size-changed file. + * descriptor-size-changed, or unreadable file. */ export async function readTextForDiff( absolutePath: string, @@ -581,41 +582,50 @@ export async function readTextForDiff( signal?: AbortSignal, ): Promise { throwIfAborted(signal, 'read') - const handle = await open(absolutePath, 'r') - let buffer: Buffer - let total = 0 - let openedSize = 0 try { - throwIfAborted(signal, 'read') - const info = await handle.stat() - throwIfAborted(signal, 'read') - /* v8 ignore next -- requires a post-preflight replacement with a non-file; - * direct coverage is not portable to Windows. */ - if (!info.isFile()) return null - if (info.size >= maxBytes) return null - openedSize = info.size - // One extra byte detects growth after stat without retaining per-read backing buffers. - buffer = Buffer.allocUnsafe(openedSize + 1) - while (total < buffer.length) { + const handle = await open(absolutePath, 'r') + let buffer: Buffer + let total = 0 + let openedSize = 0 + try { throwIfAborted(signal, 'read') - const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) - const { bytesRead } = await handle.read(buffer, total, length, null) - if (bytesRead === 0) break - total += bytesRead + const info = await handle.stat() + throwIfAborted(signal, 'read') + if (!info.isFile()) return null + if (info.size >= maxBytes) return null + openedSize = info.size + // One extra byte detects growth after stat without retaining per-read backing buffers. + buffer = Buffer.allocUnsafe(openedSize + 1) + while (total < buffer.length) { + throwIfAborted(signal, 'read') + const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES) + const { bytesRead } = await handle.read(buffer, total, length, null) + if (bytesRead === 0) break + total += bytesRead + } + } finally { + await handle.close() + } + throwIfAborted(signal, 'read') + if (total !== openedSize) return null + const basis = buffer.subarray(0, total) + if (basis.includes(0)) return null + try { + return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) + } catch (error: unknown) { + /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; + * any other throw is an unreachable runtime fault. */ + if (!(error instanceof TypeError)) throw error + return null } - } finally { - await handle.close() - } - throwIfAborted(signal, 'read') - if (total !== openedSize) return null - const basis = buffer.subarray(0, total) - if (basis.includes(0)) return null - try { - return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis)) } catch (error: unknown) { - /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */ - if (!(error instanceof TypeError)) throw error - return null + // Cancellation is the caller's intent and still propagates. + if (error instanceof FsError) throw error + // A descriptor-phase errno — deleted or made unreadable after the caller's + // preflight, or a faulted read — costs only the optional basis: a committed + // write must not fail for a presentation-only pre-read. + if (error instanceof Error && 'code' in error) return null + throw error } } diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index d800997d0f..a30c171fd9 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -398,6 +398,64 @@ describe('readTextForDiff', () => { } }) + it('returns null when the file vanishes before the basis open (deletion race)', async () => { + expect(await readTextForDiff(join(dir, 'deleted-after-preflight.txt'), 32)).toBeNull() + }) + + it('returns null when the opened descriptor is no longer a regular file', async () => { + const file = join(dir, 'swapped.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters) { + const handle = await actual.open(...args) + return { + close: handle.close.bind(handle), + read: handle.read.bind(handle), + async stat(...statArgs: Parameters) { + const info = await handle.stat(...statArgs) + return Object.assign(info, { isFile: () => false }) + }, + } + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + expect(await isolatedReadTextForDiff(file, 32)).toBeNull() + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + + it('propagates a non-errno fault instead of masking it as a null basis', async () => { + const file = join(dir, 'faulted.txt') + await writeFile(file, 'abcdef') + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open() { + throw new TypeError('forged programming fault') + }, + } + }) + + try { + const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts') + await expect(isolatedReadTextForDiff(file, 32)).rejects.toThrow('forged programming fault') + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + } + }) + it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => { await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69])) await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69])) diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..de43d19e96 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -24,7 +24,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import { LlmAdapter, LlmError, ReasoningEffortId, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' /** * One recorded model call. `throw` may replay prefix chunks before failing; @@ -51,6 +51,18 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** + * Optional per-request output cap the replay route materializes when callers + * omit one, so replay reconstructs the request header a live catalog produced. + */ + defaultMaxTokens?: number + /** Optional reasoning-effort ids the replay route accepts, in display order. */ + reasoningEfforts?: string[] + /** + * Optional effort materialized when callers omit one; must appear in + * {@link reasoningEfforts} or call resolution rejects the route. + */ + defaultReasoningEffort?: string } /** One provider route exposed by the replay adapter. */ @@ -585,6 +597,19 @@ class ReplayAdapter extends LlmAdapter { ...configuredModel?.contextWindow === undefined ? {} : { context: { contextWindow: configuredModel.contextWindow } }, + ...configuredModel?.defaultMaxTokens === undefined + ? {} + : { defaultMaxTokens: configuredModel.defaultMaxTokens }, + ...configuredModel?.reasoningEfforts === undefined + ? {} + : { + reasoning: { + efforts: configuredModel.reasoningEfforts.map(id => ({ id: ReasoningEffortId(id), name: id })), + ...configuredModel.defaultReasoningEffort === undefined + ? {} + : { defaultEffort: ReasoningEffortId(configuredModel.defaultReasoningEffort) }, + }, + }, }) } From 6f54fd203d55e3a7cda5245d7977df854c3c2e7a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:23:32 -0700 Subject: [PATCH 48/73] test(llm-replay): cover catalog capability parity Also re-generates the config catalog after the docs-restructure merge. --- docs/config-catalog.md | 16 +++++++++++++-- .../llm-replay/tests/llm-replay.spec.ts | 20 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b9ce45cd3c..d370fc945f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -444,7 +444,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -907,12 +907,24 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** + * Optional per-request output cap the replay route materializes when callers + * omit one, so replay reconstructs the request header a live catalog produced. + */ + defaultMaxTokens?: number + /** Optional reasoning-effort ids the replay route accepts, in display order. */ + reasoningEfforts?: string[] + /** + * Optional effort materialized when callers omit one; must appear in + * {@link reasoningEfforts} or call resolution rejects the route. + */ + defaultReasoningEffort?: string } ``` Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 4be9e01c9d..8a30a23c5d 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -578,8 +578,14 @@ describe('installLlmReplay (through the real LlmService)', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }, models: [ - { id: 'flash', contextWindow: 128_000 }, - { id: 'pro', name: 'Pro', description: 'Larger model' }, + { + id: 'flash', + contextWindow: 128_000, + defaultMaxTokens: 64_000, + reasoningEfforts: ['off', 'max'], + defaultReasoningEffort: 'max', + }, + { id: 'pro', name: 'Pro', description: 'Larger model', reasoningEfforts: ['high'] }, ], }, { id: 'empty' }, @@ -597,8 +603,18 @@ describe('installLlmReplay (through the real LlmService)', () => { await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({ context: { contextWindow: 128_000 }, + defaultMaxTokens: 64_000, + reasoning: { + efforts: [{ id: 'off', name: 'off' }, { id: 'max', name: 'max' }], + defaultEffort: 'max', + }, }) await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') + // Efforts without a configured default preserve the provider's own default. + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.toMatchObject({ + reasoning: { efforts: [{ id: 'high', name: 'high' }] }, + }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('defaultMaxTokens') await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context') expect(ctx.llm.providerRetryPolicy('deepseek')).toMatchObject({ From b77bbb152628ed3335f23a30ac91944468d6dfdf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 23:38:37 +0800 Subject: [PATCH 49/73] 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 50/73] 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 51/73] 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 52/73] =?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 53/73] 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 54/73] 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 55/73] 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 5d86a284e548ccfac0557cd2ea4ff106ac9e1306 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 14:18:41 +0800 Subject: [PATCH 56/73] fix(web-app,agent-presets): keep the task registry on the host plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tool-bash` resolves the background-task registry with `ctx.get('tasks')`, and it sits at the preset's top level. The registry sat inside an entry-local `isolate: { tasks: true }` realm, which is invisible to every sibling row outside it, while the Web surface disabled the host row — so both lookups missed and every `run_in_background` call answered "background tasks unavailable" with `task_output`, `task_list`, and `task_kill` still listed in the catalog. `task_list` returning "(no background tasks)" is what made the outage read as an empty queue rather than a severed producer. That is the `goals` criterion read from inside the preset: a Service a row outside its realm READS belongs to the plane both can see. `tasks` already keys access by owning agent (`assertAccess` compares `task.owner.id`) and mints an independent token per `attachSurface` call, so one host instance serves every session exactly as before presets — the per-preset-standing-mounts note records that sharing `tasks-local` is a return to its design. `minimal` mounts no `tool-tasks`, and the `start()` control-surface gate is a service-wide set that another preset's controls would open for it, so its `tool-bash` disables `run_in_background` and drops the parameter from the schema. Fixes #2141 --- .../agent-presets/code/agent.cordis.yml | 20 +++--- .../agent-presets/cordis/agent.cordis.yml | 20 +++--- .../agent-presets/minimal/agent.cordis.yml | 8 +++ .../agent-presets/standard/agent.cordis.yml | 20 +++--- apps/web/tests/shipped-composition.e2e.ts | 62 +++++++++++++++++++ packages/bundle/web-app/cordis.patch.yml | 13 +++- 6 files changed, 107 insertions(+), 36 deletions(-) diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..d068e00dea 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -70,17 +70,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..91fa28a31d 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -64,17 +64,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── goals ─────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 8ca6f0dcdf..cbccafe160 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -22,8 +22,16 @@ # never reached the model's shell at all. `tool-bash` consumes the host registry # from here; the executor behind it (`bash-sandbox`) is host-plane too, where the # sandbox policy owns it. +# +# `run_in_background` is off because this preset mounts no `tool-tasks`: the +# host task registry gates starts on SOME control surface being attached, and +# that set is process-wide, so another preset's controls would let this agent +# start work it has no `task_output` to collect. Disabling drops the parameter +# from the schema too, which is the honest surface for a two-tool benchmark. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + config: + enableRunInBackground: false - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..f73f4b3fba 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -63,17 +63,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 5d929044b3..8241dfc0bc 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -5,6 +5,7 @@ // surface itself. import { tmpdir } from 'node:os' import { afterEach, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' // Empty type imports carry the tools/sandboxPolicy/approval Context merges. @@ -114,3 +115,64 @@ it('assembles the shipped Web catalog with the confined access default', async ( await commandHandle.dispose() } }, 120_000) + +it('lets a preset producer reach the background-task registry', async () => { + scaffold = await launchWebScaffold() + const ctx = scaffold.ctx + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-background-task'), + meta: { cwd: scaffold.workspaceCwd }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const signal = new AbortController().signal + // `tool-bash` is a preset row and `tasks` is a host registry; the producer + // resolves it with `ctx.get`, so a registry hidden behind a preset realm + // fails here — with every task control still listed in the catalog above. + const started = await ctx.tools.execute({ + signal, + callId: CallId('shipped-bash-background'), + name: 'bash', + arguments: { + command: 'printf SHIPPED_BACKGROUND_OK', + description: 'shipped background probe', + run_in_background: true, + }, + agent: handle.agent, + }) + expect({ isError: started.isError, content: started.content }).toEqual({ + isError: false, + content: [{ type: 'text', text: 'started background task bash-1' }], + }) + + // The control surface reads what the producer started: same registry, one + // owner. A per-preset registry would list nothing here even on success. + const listed = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-list'), + name: 'task_list', + arguments: {}, + agent: handle.agent, + }) + expect(listed.isError).toBe(false) + expect(listed.content).toEqual([ + { type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string }, + ]) + + // The full round trip: the output a host-plane producer wrote is collected + // through a preset-plane control, which is the linkage the realm severed. + const collected = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-output'), + name: 'task_output', + arguments: { task_id: 'bash-1', wait: true }, + agent: handle.agent, + }) + expect(collected.isError).toBe(false) + expect(collected.content).toEqual([ + { type: 'text', text: expect.stringContaining('SHIPPED_BACKGROUND_OK') as unknown as string }, + ]) + } finally { + await handle.dispose() + } +}, 120_000) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index e4c4935a2a..ed08451f1b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -218,10 +218,17 @@ - id: tool-bash disabled: true -- id: tool-tasks - disabled: true +# The background-task REGISTRY stays on the host plane; only the model-facing +# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a +# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it +# with `ctx.get`, and an entry-local realm around the registry is invisible to +# every sibling row outside that realm, so `run_in_background` answered +# "background tasks unavailable" while the controls sat in the catalog. That is +# the `goals` criterion read from inside the preset: a Service a row outside its +# realm READS belongs to the plane both can see. The registry is keyed by owning +# agent, so one host instance serves every session exactly as before presets. -- id: tasks +- id: tool-tasks disabled: true - id: tool-fs From 259d998455d625679549f8941a1ddba9a6ec5516 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 14:35:13 +0800 Subject: [PATCH 57/73] fix(web): follow a blank session's preset switch in the slash catalog Presets own the rows that decide what a session's `/` menu contains, but both browser catalogs cache per session and had no invalidation edge for a recompose: `commands/changed` is registry-wide and recomposing registers nothing, so the menu kept serving the composition the session no longer ran. The host stream now frames the logged `agent-preset/selected` commit as `host/session-preset-changed`; the runtime bridges it to the typed `session/preset-changed` event, `ui-command` soft-refreshes that session's directory key and `ui-skill` invalidates its catalog entry. Reaching the host on a second switch was a separate defect: the list-row identity guard compared every summary field except `agentPreset`, and the merge keeps the row's `updatedAt`, so a switched row looked unchanged and served its cached instance forever. The hero chip compares the pick against that row, so switching back to the creation-time preset sent no RPC at all. --- ...n-row-identity-covers-the-preset.i18n.yaml | 6 + ...-session-row-identity-covers-the-preset.md | 37 ++++++ ...ssion-row-identity-covers-the-preset.zh.md | 37 ++++++ ...sh-catalog-follows-preset-switch.i18n.yaml | 6 + ...-10-slash-catalog-follows-preset-switch.md | 41 +++++++ ...-slash-catalog-follows-preset-switch.zh.md | 41 +++++++ apps/web/tests/agent-preset-selection.e2e.ts | 105 +++++++++++++++--- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 1 + docs/event-producer-consumer.zh.md | 1 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 15 +++ .../runtime/src/client/sessions/manager.ts | 2 +- .../runtime/tests/sessions-service.spec.ts | 17 +++ .../client/runtime/tests/wire-events.spec.ts | 14 ++- packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 2 +- packages/client/ui-command/README.zh.md | 2 +- .../client/ui-command/src/client/service.ts | 5 + .../client/ui-command/tests/service.spec.ts | 24 ++++ packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 7 +- .../ui-skill/tests/browser-plugin.spec.ts | 15 +++ 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 | 11 ++ .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 11 ++ .../tests/api-proxy-agent-preset.spec.ts | 32 ++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 1 + scripts/gen-cordis-catalog.ts | 1 + 36 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml new file mode 100644 index 0000000000..aaa06cf4fe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.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-08-10-session-row-identity-covers-the-preset.md +2026-08-10-session-row-identity-covers-the-preset.md: 7a89dcb4e4ae292a06a1743842d2e9cf6bd96282 +2026-08-10-session-row-identity-covers-the-preset.zh.md: 7ffa3423818bcc867c942651540db1975737e073 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md new file mode 100644 index 0000000000..7a89dcb4e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md @@ -0,0 +1,37 @@ +# Agent Note: The session-row identity guard covers the preset + +Status: implemented + +English | [中文](2026-08-10-session-row-identity-covers-the-preset.zh.md) + +## Problem + +`SessionManager.buildListSnapshot` memoizes list rows by value: a wire refresh mints all-new summary objects, so an entry equal to the cached one is replaced by the cached instance, and every `SessionListItem` memo downstream keeps hitting. The stated contract is "reuse the cached object when every field matches"; the comparison enumerated the fields by hand and did not enumerate `agentPreset`. + +A confirmed preset switch moves exactly that one field. `noteAgentPreset` upserts it and `applyMutation` merges it in — the merge deliberately does not take the mutation's `updatedAt`, so a switched row differs from its cached twin in the preset and in nothing else. The guard therefore judged the row unchanged and served the stale instance, permanently: the manager's own summaries said `minimal` while every reader of the projected snapshot went on reading `standard`. + +The hero chip is one of those readers, and it compares the pick against that row before sending anything. Switching back to the preset the session was created under looked to it like "already on that preset", so it dropped the stage and sent no RPC at all — the chip label moved while the composition did not. A session could be switched away from its creation-time preset once and never back. + +## Decision + +The identity guard compares `agentPreset` alongside the other summary fields, which is what "every field matches" already claimed. Nothing else changes: the memoization, the merge, and the chip's no-op check all stay as they are, because each is correct once the row it reads is. + +## Alternatives considered + +**Have the chip re-read the host instead of the list row.** It would route around the stale row, but the row is also what the session header labels itself from, so the staleness would survive in the surface where it is most visible — and any future reader of `SessionSummary.agentPreset` would inherit the same trap. + +**Drop the entry-identity memoization and rebuild rows every snapshot.** It removes the whole class of missing-field bugs, at the cost the memo exists to avoid: a wire refresh mints new objects for every row, so each refresh would re-render the entire session list. + +**Compare summaries structurally rather than field by field.** A generic deep comparison cannot be added blind: the row carries `projectionValues`, whose reference identity is the deliberate signal that the projection store republished, and folding it into a value comparison would either re-render on every projection tick or mask a real one. + +## Consequences + +Every field a session row carries now participates in row identity, so a surface reading `SessionSummary.agentPreset` sees a switch as soon as the host confirms it — the header label included. The guard is still a hand-written enumeration, so a field added to `SessionSummary` later must be added here too; the `sessions-service` projection test names the failure mode for the next such field rather than only pinning this one. + +## Testing + +`sessions-service.spec.ts` feeds a blank row, notes a switch, and asserts the projected snapshot reports the new preset — it fails on the old guard because the row differs in nothing else. The `agent-preset-selection` web e2e switches down and back up, asserting the host honors the second switch and the `/` catalog returns with it; without this fix the second switch never reaches the host at all. + +## Related + +The same e2e covers [the catalog-invalidation fix](2026-08-10-slash-catalog-follows-preset-switch.md), which is what makes the menu follow either switch once the switch itself lands. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md new file mode 100644 index 0000000000..7ffa342381 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md @@ -0,0 +1,37 @@ +# Agent Note:会话行的标识判定纳入 preset + +Status: implemented + +[English](2026-08-10-session-row-identity-covers-the-preset.md) | 中文 + +## Problem + +`SessionManager.buildListSnapshot` 按值对列表行做记忆化:一次 wire 刷新会铸造全新的 summary 对象,因此与缓存项相等的行会被替换为缓存实例,下游每一个 `SessionListItem` memo 才能持续命中。它声明的约定是「每个字段都相同就复用缓存对象」,而那段比较是手写枚举字段的,其中没有 `agentPreset`。 + +一次已确认的 preset 切换恰好只移动这一个字段。`noteAgentPreset` 把它 upsert 进去,`applyMutation` 合并它——该合并有意不采用 mutation 的 `updatedAt`,因此切换后的行与它的缓存孪生只在 preset 上不同,别处一致。于是标识判定认为这一行没变,永久地提供了过期实例:manager 自己的 summaries 是 `minimal`,而所有读取投影快照的一方继续读到 `standard`。 + +hero 上的 chip 正是其中一个读取方,而且它在发出任何请求之前会拿这次选择和那一行比较。切回会话创建时的那个 preset,在它看来就是「已经是这个 preset 了」,于是丢弃 stage、根本不发 RPC——chip 的标签变了,组成没变。一个会话可以从创建时的 preset 切走一次,然后再也切不回来。 + +## Decision + +标识判定把 `agentPreset` 与其余 summary 字段一起比较,这本就是「每个字段都相同」所声称的内容。其他一概不动:记忆化、合并、chip 的 no-op 检查各自都是对的——只要它们读到的那一行是对的。 + +## Alternatives considered + +**让 chip 改为直接读宿主,而不是读列表行。** 这样能绕开过期的行,但会话头部的标签同样以这一行为准,过期状态会在最显眼的界面里留下来;而且将来任何 `SessionSummary.agentPreset` 的读取方都会继承同一个陷阱。 + +**去掉行标识记忆化,每次快照都重建行。** 这能整类消除「漏字段」缺陷,代价却正是这个 memo 存在的理由:一次 wire 刷新会为每一行铸造新对象,于是每次刷新都要重渲染整个会话列表。 + +**改成结构化比较,而不是逐字段枚举。** 通用的深比较不能盲目加:行上带有 `projectionValues`,它的引用标识本身就是「投影 store 重新发布了」这一有意为之的信号,把它折进值比较,要么每个投影 tick 都重渲染,要么把一次真实变化掩盖掉。 + +## Consequences + +会话行携带的每个字段现在都参与行标识,因此读取 `SessionSummary.agentPreset` 的界面会在宿主确认后立刻看到切换,会话头部标签也包含在内。该判定仍是手写枚举,所以将来给 `SessionSummary` 新增字段时必须同步加进来;`sessions-service` 的投影测试为下一个这样的字段点明了失效形态,而不只是钉住这一次。 + +## Testing + +`sessions-service.spec.ts` 喂入一行空会话、记录一次切换,并断言投影快照报告的是新 preset——在旧判定下它会失败,因为这一行别处都没变。`agent-preset-selection` web e2e 先向下切再向上切,断言宿主认可第二次切换、`/` 目录随之回来;没有这次修复,第二次切换根本到不了宿主。 + +## Related + +同一条 e2e 也覆盖[目录失效的修复](2026-08-10-slash-catalog-follows-preset-switch.md)——正是它让菜单在切换真正落地之后跟随任一方向的切换。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml new file mode 100644 index 0000000000..38cd8786b5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.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-08-10-slash-catalog-follows-preset-switch.md +2026-08-10-slash-catalog-follows-preset-switch.md: 4f32347e04e9b1cde024a59a32fcfd3cca64172a +2026-08-10-slash-catalog-follows-preset-switch.zh.md: fb30df74a93a9eb913dc43b43c3065c6255bcab9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md new file mode 100644 index 0000000000..4f32347e04 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md @@ -0,0 +1,41 @@ +# Agent Note: The slash catalog follows a blank session's preset switch + +Status: implemented + +English | [中文](2026-08-10-slash-catalog-follows-preset-switch.zh.md) + +## Problem + +Presets moved the rows that decide what a session's `/` menu contains. The Web composition disables host-plane `skill-local`, `tool-skill`, `plan-mode`, and `command-compact`; a preset supplies them, so which commands and skills exist is a property of the session's composition rather than of the deployment. + +Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/changed` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it. + +The menu therefore kept serving the composition the session no longer ran. Switching down left `compact`, `plan`, and every project skill listed; switching up left the narrower catalog — the four host-plane rows and the client's own `model` contribution — with no skills at all, which is what the bug report described. The catalog only healed when an unrelated registry change or a reconnect happened to invalidate it. + +## Decision + +The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). + +The frame is per session and carries no catalog. Deriving it from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. + +## Alternatives considered + +**Invalidate in the client's own `agentPresets.select` callback.** Smallest change, and the preset is locked after the first turn, so the hero chip is the only place a switch can originate. Rejected because the invalidation would then live in the surface that happens to issue the RPC rather than at the commit point: a second tab on the same blank session keeps a stale menu, and any future host-side recomposition has no signal at all. + +**Derive the client event from the existing `session/event` mux frame.** The logged event already reaches every subscribed client, so no new wire type would be needed. Rejected on face separation: narrowing `event.type` to `agent-preset/selected` requires the `SessionEventMap` augmentation, and the only ways to load it in the Client program are a project reference to `dsh-agent-presets` — which drags the host `ctx.sessions` merge into a program that publishes its own — or a cast that defeats the discriminant. + +**Reuse `host/commands-changed`.** It is the existing catalog-invalidation frame, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog. + +## Consequences + +The wire gains one frame and the Client one typed event, and every catalog a preset decides now has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The cost is that the frame is a second reader of a logged fact — the host stream must keep deriving it from `agent-preset/selected`, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones. + +## Testing + +`api-proxy-agent-preset.spec.ts` asserts the committed switch frames once with the session and its new preset; `wire-events.spec.ts` asserts the frame-to-event bridge; the `ui-command` and `ui-skill` specs assert that the event repulls the recomposed session and leaves every other session's cache serving. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition. + +That e2e also stopped reading its staged-pick assertion off the serialized session list: the seeded session records `minimal` too, so the substring answered before the switch had landed. It now addresses the live session by id. + +## Related + +Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, the e2e below could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md new file mode 100644 index 0000000000..fb30df74a9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md @@ -0,0 +1,41 @@ +# Agent Note:斜杠目录跟随空会话的 preset 切换 + +Status: implemented + +[English](2026-08-10-slash-catalog-follows-preset-switch.md) | 中文 + +## Problem + +preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿主面的 `skill-local`、`tool-skill`、`plan-mode` 和 `command-compact`,改由 preset 提供,因此一个会话有哪些命令和技能,是它自身组成的属性,而不是部署的属性。 + +浏览器侧两份目录都按会话缓存——`dsh-client-ui-command` 的 `CommandDirectory`,`dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/changed` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。 + +于是菜单继续提供会话已经不再运行的那套组成。向下切换后 `compact`、`plan` 和全部项目技能仍列在菜单里;向上切换后留在原地的是更窄的目录——四条宿主面行加客户端自己的 `model` 贡献——而且完全没有技能,这正是 bug 报告描述的现象。只有当某个无关的注册表变化或一次重连恰好使其失效时,目录才会自愈。 + +## Decision + +这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 + +该帧按会话粒度,且不携带目录。从落账事件而不是 RPC 处理器的返回值派生它,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 + +## Alternatives considered + +**在客户端自己的 `agentPresets.select` 回调里就地失效。** 改动最小,而且第一轮之后 preset 就锁定,hero 上的 chip 是切换唯一可能的发起处。否决理由是失效逻辑会落在恰好发起 RPC 的那个界面上,而不是提交点:同一个空会话在第二个标签页里仍是过期菜单,将来任何宿主侧的重组也完全没有信号。 + +**从既有的 `session/event` mux 帧派生客户端事件。** 落账事件本来就会送达每个已订阅的客户端,不需要新增协议类型。因面(face)分离而否决:把 `event.type` 收窄到 `agent-preset/selected` 需要 `SessionEventMap` 增补,而在 Client 程序里加载它只有两条路——引用 `dsh-agent-presets` 工程,那会把宿主的 `ctx.sessions` 合并拖进一个自己也发布同名服务的程序;或者用一次类型断言绕过判别式。 + +**复用 `host/commands-changed`。** 它是既有的目录失效帧,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。 + +## Consequences + +协议多了一个帧,Client 多了一个类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。代价是该帧成为一项落账事实的第二个读者——宿主流必须持续从 `agent-preset/selected` 派生它,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。 + +## Testing + +`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好成帧一次,并带上会话与新 preset;`wire-events.spec.ts` 断言帧到事件的桥接;`ui-command` 与 `ui-skill` 的 spec 断言该事件只重拉被重组的会话,其他会话的缓存继续服务。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact`、`plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。 + +同一条 e2e 也不再从序列化后的会话列表里读它的 staged-pick 断言:被播种的会话同样记录着 `minimal`,子串匹配在切换落地之前就会通过。现在它按 id 寻址那个活跃会话。 + +## Related + +第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,下面那条 e2e 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 69672f49e1..71e1a28b05 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -11,6 +11,7 @@ // // Zero model calls: no replay fixture mounts, so a stray stream fails loud. import { fileURLToPath } from 'node:url' +import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -29,6 +30,30 @@ const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'agent-preset-selection-web-e2e' +/** A project skill only a preset that mounts `skill-local` can discover. */ +const SKILL_NAME = 'preset-catalog-demo' + +/** + * Seed one project skill under the connected workspace. + * + * Local skill discovery is a PRESET row, so this file is visible through + * `standard` and invisible through `minimal` — which makes the '/' menu's + * skill group a statement about the session's composition. + * @param workspaceCwd - the scaffold's temp project parent. + */ +async function seedWorkspaceSkill(workspaceCwd: string): Promise { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove the slash catalog follows the session composition', + '---', + '', + 'Body.', + '', + ].join('\n')) +} /** * A settled one-turn session with no model content: this lane asserts chrome @@ -53,6 +78,35 @@ function seedLog(): string { ].join('\n') } +/** + * The preset the host reports for the blank session the workspace connect + * produced. Addressed by id rather than by scanning the serialized list: the + * seeded session records `minimal` too, so a substring match over the whole + * list answers before the switch has landed. + * @param baseUrl - the scaffold's origin. + * @returns the live session's preset, or undefined before it is listed. + */ +async function livePreset(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { items: { sessionId: string; agentPreset?: string }[] } } + } + return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset +} + +/** Every option label the trigger menu currently lists. */ +async function menuOptions(page: Page): Promise { + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + return await menu.getByRole('option').allTextContents() +} + describe('web e2e: agent-preset selection', () => { let scaffold: WebScaffold let browser: Browser @@ -67,6 +121,7 @@ describe('web e2e: agent-preset selection', () => { // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + await seedWorkspaceSkill(scaffold.workspaceCwd) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -114,21 +169,45 @@ describe('web e2e: agent-preset selection', () => { // The chip stages; the blank session the workspace connect produced is // what the stage lands on. The host's own answer is what comes back. - await expect.poll(async () => { - const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {}, - }), - }) - const body = await response.json() as { - result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } } - } - return JSON.stringify(body.result.value?.sessions ?? body.result) - }, { timeout: 15_000 }).toContain('minimal') + await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal') }) + it('re-reads the slash catalog through the composition the switch installed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) + const composer = page.locator('textarea:enabled').last() + + // `minimal` (applied above) mounts neither the compaction group nor plan + // mode nor local skill discovery, so the catalog the composer warmed + // under the deployment default must not survive the switch. + await composer.fill('/') + await expect.poll(() => menuOptions(page), { timeout: 15_000 }) + .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) + const onMinimal = await menuOptions(page) + expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) + expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) + // The host-plane commands and the client's own contribution are the + // floor: they belong to no preset and never move. + expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true) + expect(onMinimal.some(option => option.startsWith('model'))).toBe(true) + await composer.fill('') + + // Switching back up reaches the host at all — the chip compares the pick + // against its list row, so a row that never reprojected the first switch + // answers "already standard" and sends nothing — and restores the catalog + // instead of leaving the session reading the narrower composition. + await page.getByRole('button', { name: '极简模式' }).click() + await page.getByRole('menuitem', { name: /^标准模式/ }).first().click() + await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard') + + await composer.fill('/') + await expect.poll(() => menuOptions(page), { timeout: 15_000 }) + .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) + const onStandard = await menuOptions(page) + expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) + expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) + await composer.fill('') + }, 90_000) + it('labels a resumed session with the preset it was created under', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header')) // The seeded session's cwd is the scaffold root rather than the connected diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 20fa4fa5e5..9b71f640ed 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 3b8a6b1dd155fd1350b164f1dd2d2bf0ec26a4a5 -event-producer-consumer.zh.md: 12de167fcd1217f00a8ae719ef3191a4873a2799 +event-producer-consumer.md: 70de749c328f1d901ff6f9bc0d97cd52a6f3bf63 +event-producer-consumer.zh.md: 6c49c33a1b1197a7da9bccfc161b7cfa6b6a548f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3b8a6b1dd1..70de749c32 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -70,6 +70,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | +| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 12de167fcd..6c49c33a1b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -72,6 +72,7 @@ | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | +| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index a5d2c2a44f..29d5a2d4dc 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 0a7d9975093da558af623ee9940f4be398526821 -README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a +README.md: b1c0c8e5b6aa93f5e79b4b75c5f8db89bd656688 +README.zh.md: b6add06324bf9fc5cf4a93d89d88072609a67c50 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 0a7d997509..b1c0c8e5b6 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 41388e4ba5..b6add06324 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 766656a7f9..955431d509 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -181,6 +181,18 @@ declare module 'cordis' { * @mode emit */ 'models/changed'(): void + /** + * One session's agent preset changed (host/session-preset-changed + * passthrough), so everything its composition decides — the command + * catalog, the skill catalog — is stale for that session and no other. + * Every connected client observes it, not only the one that issued the + * switch. Subscribers refetch their own session-keyed caches; the frame + * carries no catalog. + * @mode emit + * @param sessionId - the session whose composition changed. + * @param agentPreset - the preset it now runs. + */ + 'session/preset-changed'(sessionId: SessionId, agentPreset: string): void /** * A connection generation was (re-)established. Wire-derived caches must * treat their state as stale and repull (commands directory; the queue @@ -244,6 +256,9 @@ export function apply(ctx: Context): void { // and model surfaces) subscribe on ctx. const frame = envelope.payload if (frame.type === 'host/commands-changed') ctx.emit('commands/changed') + else if (frame.type === 'host/session-preset-changed') { + ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset) + } else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns) else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref) else if (frame.type === 'host/models-changed') ctx.emit('models/changed') diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ab25781353..ce61351cca 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -1005,7 +1005,7 @@ export class SessionManager { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running - && prev.blank === entry.blank + && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.pendingInteraction === entry.pendingInteraction diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 0a588e8329..e7d702f40a 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -35,6 +35,7 @@ type FeedRow = { origin?: 'subagent' running?: boolean blank?: boolean + agentPreset?: string } async function feedList(b: Bench, rows: FeedRow[]): Promise { @@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), ...(r.origin !== undefined ? { origin: r.origin } : {}), + ...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}), })), }) as never) await b.svc.refresh() @@ -70,6 +72,21 @@ describe('list store projection', () => { expect(state.byId[sid('s2')]?.title).toBeUndefined() }) + it('reprojects a blank session whose composition switched and nothing else moved', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard') + + // A confirmed switch moves the preset alone: the row keeps its updatedAt, + // title, running, and blank bits, so an identity guard blind to the preset + // would serve the old row forever — and every reader (the hero chip's own + // no-op check, the header label) would keep the composition it replaced. + b.svc.noteAgentPreset(sid('s1'), 'minimal') + await Promise.resolve() + + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') + }) + it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 5b71588732..e82c4cae3b 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -1,6 +1,7 @@ /** * Wire-to-typed-event bridge: host/commands-changed - * → ctx 'commands/changed'; each established connection generation → + * → ctx 'commands/changed'; host/session-preset-changed → + * ctx 'session/preset-changed'; each established connection generation → * ctx 'connection/reset' (the forced cache-invalidation broadcast). */ import { Context } from 'cordis' @@ -67,6 +68,17 @@ describe('wire event bridge', () => { ]) }) + it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => { + const bench = await mount() + const seen: Array<[string, string]> = [] + bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) }) + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r1' as never, + payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' }, + }) + expect(seen).toEqual([['s1', 'minimal']]) + }) + it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => { const bench = await mount() let resets = 0 diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index e59b4b2a12..acf611bbca 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/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-command/README.md -README.md: bc7386c8fca3b5c623473328bee6322fa7295277 -README.zh.md: 54190ac9144b1bfc12ba84a47474311d5a5391ea +README.md: db785e769cb40235a77d05b4b66d096896a35d8a +README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index bc7386c8fc..db785e769c 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -6,7 +6,7 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach `src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. -`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 54190ac914..f0f23319a8 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -6,7 +6,7 @@ `src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 -`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 33b42f6b51..f17f3950d2 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -124,6 +124,11 @@ export class CommandService extends Service implements CommandServiceContract { warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.on('commands/changed', () => { this.directory.invalidateAll() }) + // A preset switch changes which commands ONE session's agent resolves and + // registers nothing globally, so the registry-wide signal above never + // fires for it: repull that key alone, soft, so the old snapshot serves + // the menu until the new one lands. + ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) }) ctx.on('connection/reset', () => { this.directory.resetConnected() }) } diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index bd6d72c916..f7ee172b8e 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -617,6 +617,30 @@ describe('directory invalidation events', () => { expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() }) + it('session/preset-changed repulls the recomposed session and leaves the others served', async () => { + const rounds = new Map() + const { ctx, source, warm } = await bench({ + commands: (payload) => { + const round = (rounds.get(payload.sessionId) ?? 0) + 1 + rounds.set(payload.sessionId, round) + return Promise.resolve({ + commands: round === 1 + ? S1_CMDS + : [{ name: 'fresh', description: '', input: { hint: 'h' } }], + }) + }, + }) + await warm(proj('s1')) + await warm(proj('s2')) + // A preset switch changes which commands one session's agent resolves; + // every other session keeps the catalog its own composition serves. + ctx.emit('session/preset-changed', sid('s1'), 'minimal') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() + expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined() + }) + it('connection/reset hard-drops every session key until its rewarm lands', async () => { let block = false let release!: (value: { commands: CommandDescriptor[] }) => void diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c9d9e0b69c..475c844c48 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee -README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c +README.md: f6bf71bab5c5335d3da073101bcbafb30d1c2757 +README.zh.md: eae61780df7ccee350956dc542ebda70c671feb3 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 677ac215d2..f6bf71bab5 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`. A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 8f1f69b26a..eae61780df 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 524cd180cc..8e805f469a 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -17,7 +17,9 @@ * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled * snapshot locally, so one session costs one RPC. The scope-birth warm hook - * prewarms the session's key; connection/reset clears everything — the host + * prewarms the session's key; a preset switch drops that one key (the + * catalog is the preset's, and a blank session may switch after the warm); + * connection/reset clears everything — the host * catalog may differ across generations. A shared in-flight fetch * deliberately outlives any single menu interaction: closing the menu must * not kill the prewarm other consumers will hit, so it carries its own @@ -174,6 +176,9 @@ export function apply(ctx: ClientContext): void { }, } const slash = ctx.get('slash') as SlashServiceContract + // A preset decides which skill providers an agent reads, so a switched + // session's cached catalog belongs to the composition it no longer runs. + ctx.on('session/preset-changed', invalidate) ctx.on('connection/reset', clearAll) ctx.effect(() => { const unregister = slash.registerSource(source) diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index f33924e977..5e143c0b96 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -263,6 +263,21 @@ describe('catalog cache', () => { expect(payloads).toHaveLength(2) }) + it('session/preset-changed clears only the recomposed session', async () => { + const { list, payloads } = countingList() + const { ctx, source } = await bench(list) + await source.candidates(proj('s1'), req('')) + await source.candidates(proj('s2'), req('')) + expect(payloads).toHaveLength(2) + // The catalog a preset supplies is the preset's; the other session's + // composition did not change, so its cached catalog still holds. + ctx.emit('session/preset-changed', sid('s1'), 'minimal') + await source.candidates(proj('s1'), req('')) + await source.candidates(proj('s2'), req('')) + expect(payloads).toHaveLength(3) + expect(payloads[2]).toEqual({ sessionId: 's1' }) + }) + it('connection/reset clears every cached session', async () => { const { list, payloads } = countingList() const { ctx, source } = await bench(list) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f449e05a28..db0e030966 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: 98fcdda155286feb23aaf294cab76529ce31cfc1 -README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c +README.md: 54ac412ca384690a6370c7ee50c54a89972e41b5 +README.zh.md: 94d3caa88b3813a1cc764f69a427ae31257e4e48 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 98fcdda155..54ac412ca3 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,7 +50,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8cfa7e527a..94d3caa88b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 1fbcfadd2d..70ad99ee89 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3164,6 +3164,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('commands/change', () => { queue.push(frame({ type: 'host/commands-changed' })) }), + // The recompose itself registers nothing (it re-parents the agent's + // scope onto a standing mount that may already exist), so the + // logged selection is the only commit point a client can follow. + ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type !== 'agent-preset/selected') return + queue.push(frame({ + type: 'host/session-preset-changed', + sessionId: session.id, + agentPreset: event.data.agentPreset, + })) + }), ctx.on('settings/document-updated', (ns) => { // The RAW-section event, not the resolved one: a field going from // inherited to overridden leaves the resolved value equal, and a diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index b432880810..fc841f9edb 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), + z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), z.object({ type: z.literal('host/models-changed') }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index bbf895625f..43607816b3 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -130,6 +130,17 @@ export type HostFrame = * background rather than diffing. */ | { type: 'host/commands-changed' } + /** + * One blank session was recomposed onto another agent preset (the logged + * `agent-preset/selected` commit point, read off the session stream). The + * registry-wide `host/commands-changed` cannot stand in for it: recomposing + * re-parents that agent's scope without registering anything, so a + * preset already mounted for another session produces no registry change + * at all. Clients refetch the catalogs this session's composition decides + * (`command.list`, `skill.list`) for this sessionId alone; the preset id + * rides along for surfaces that label the session. + */ + | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } /** * One settings namespace's resolved value changed (`settings/updated` * passthrough) — an RPC write, an external `settings.yaml` edit, or a diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 24f08bae21..444cd5490e 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -14,6 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' +import type { HostFrame } from '../src/api/events.ts' import { InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' @@ -350,6 +351,37 @@ describe('agentPreset.select', () => { .toBe('core-web') }) + it('frames the committed switch so clients can drop that session\'s catalogs', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' })) + // The host-stream opener reads the committed-workspace baseline; this + // spec owns preset identity, so the stub suffices (api-proxy-commands + // precedent). + ctx.provide('workspace', { list: () => [] } as never) + const abort = new AbortController() + const frames: HostFrame[] = [] + const stream = api.events.host(request({}), abort.signal) + const consume = (async () => { + for await (const frame of stream) { + if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload) + } + })() + + await api.agentPresets.select( + request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' })) + // The queue push rides the synchronous append, so one turn of the loop is + // enough to deliver it; closing the stream bounds the read either way. + await new Promise(resolve => setTimeout(resolve, 0)) + abort.abort() + await consume + + // Recomposing registers nothing, so this frame — not the registry-wide + // commands one — is what tells a client its cached catalogs are stale. + expect(frames).toEqual([ + { type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' }, + ]) + }) + it('serializes two concurrent selects on one session', async () => { const { api, ctx } = await harness(['standard', 'core-web']) await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9824a637bf..f398305fa7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -493,6 +493,7 @@ describe('events frame schemas', () => { } }, { type: 'host/workspace-removed', workspaceId: 'w' }, { type: 'host/commands-changed' }, + { type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 745f44ed0a..63cf4f43ae 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -180,6 +180,7 @@ export const EVENT_WALK_EXEMPTIONS: Record = { 'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface', 'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', + 'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the surface', 'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface', From e56c6234d2f4cdaa517f875fbddbc3b7c36181d3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 14:49:30 +0800 Subject: [PATCH 58/73] chore: exclude archived Agent Notes from rg --- .../process/2026-07-26-frozen-agent-note-archive.i18n.yaml | 4 ++-- .../process/2026-07-26-frozen-agent-note-archive.md | 6 +++++- .../process/2026-07-26-frozen-agent-note-archive.zh.md | 6 +++++- .rgignore | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .rgignore diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 1ae66cc1b7..df1a615d35 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.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/process/2026-07-26-frozen-agent-note-archive.md -2026-07-26-frozen-agent-note-archive.md: 52b43088b276c0c8e263fc8a81a2df1408cc8059 -2026-07-26-frozen-agent-note-archive.zh.md: a37e06e7cbc19b5000d4849cc3ee2ddc9b451ee3 +2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1 +2026-07-26-frozen-agent-note-archive.zh.md: e67f981e5d7ae9d800b725e5ecbaf15dd7fef46b diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index 52b43088b2..0c139c4a5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -14,6 +14,8 @@ Only implemented Agent Notes can be archived. An implemented note moves when its The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. +The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery. + After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources. [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history. @@ -28,6 +30,8 @@ Supersession is checked while a new Agent Note is being written, not deferred to **Keep every implemented and rejected note active.** Rejected because maintenance effort and search noise grow with records that no longer help a future decision. Rejected notes in particular earn retention only by preventing a plausible fallacy. +**Leave archived notes in default repository search results.** Rejected because archived facts may be stale by design and can outrank current results by lexical match. Historical work can search the archive directory explicitly. + **Defer supersession cleanup to periodic corpus audits.** Rejected because the author of a replacement note has the freshest evidence about ownership and overlap. Postponement leaves redundant active authorities and makes later classification more expensive. **Archive rejected or proposed notes too.** Rejected because archive status means “implemented historical decision.” An obsolete proposal needs an explicit rejection, while a rejection with no guardrail value needs deletion rather than a second low-value holding area. @@ -38,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index a37e06e7cb..e67f981e5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -14,6 +14,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 +根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。 + 归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。 [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单) 中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。 @@ -28,6 +30,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 **继续将每一份 implemented 和 rejected Agent Note 作为活跃记录保留。** 不予采纳,因为不再帮助未来决策的记录会不断增加维护成本和搜索噪声。尤其是 rejected Agent Note,只有能避免一种可能发生的谬误时,才值得保留。 +**让归档 Agent Note 继续出现在默认的仓库搜索结果中。** 不予采纳,因为归档事实按设计可能已经陈旧,并可能仅凭字面匹配就排在当前结果之前。需要查找历史内容时,可以显式搜索归档目录。 + **把取代关系清理留到定期审计记录集合时再做。** 不予采纳,因为替代记录的作者掌握着关于归属和重叠的最新证据。推迟处理会留下冗余的活跃权威依据,并增加日后分类的成本。 **同时归档 rejected 或 proposed Agent Note。** 不予采纳,因为归档状态表达的是「已经实施的历史决策」。过时的提案需要明确转为 rejected;无法提供防错价值的 rejected Agent Note 则应删除,而不是再放入第二个低价值存放区。 @@ -38,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.rgignore b/.rgignore new file mode 100644 index 0000000000..6bffdc1726 --- /dev/null +++ b/.rgignore @@ -0,0 +1,2 @@ +# Frozen Agent Notes are historical snapshots, not current search authority. +/.agents/notes/archived/ From 0be9bf312ad3bdbeaaf3ab21a8399a20bfb02b73 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 15:12:07 +0800 Subject: [PATCH 59/73] fix(web): fold the preset frame into the session row for every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame carried `agentPreset` for surfaces that label the session, but nothing consumed it: `noteAgentPreset` ran only in the switching tab's RPC callback, so a second connected client refetched its catalogs while its session row — the header label's source, and the hero chip's no-op input — kept the composition the session had replaced. `SessionManager.handleHostEnvelope` now folds the frame like the other session frames. Re-applying the switching tab's own frame is a no-op: the merge lowers `blank` only and keeps the row's `updatedAt`. --- ...lash-catalog-follows-preset-switch.i18n.yaml | 4 ++-- ...08-10-slash-catalog-follows-preset-switch.md | 6 ++++-- ...10-slash-catalog-follows-preset-switch.zh.md | 6 ++++-- apps/web/tests/agent-preset-selection.e2e.ts | 8 +++++--- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/sessions/manager.ts | 8 ++++++++ .../runtime/tests/sessions-service.spec.ts | 17 +++++++++++++++++ .../client/ui-command/src/client/service.ts | 2 +- packages/host/apiproxy/src/api/events.ts | 5 +++-- 11 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml index 38cd8786b5..55fc08bfb9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.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-08-10-slash-catalog-follows-preset-switch.md -2026-08-10-slash-catalog-follows-preset-switch.md: 4f32347e04e9b1cde024a59a32fcfd3cca64172a -2026-08-10-slash-catalog-follows-preset-switch.zh.md: fb30df74a93a9eb913dc43b43c3065c6255bcab9 +2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5 +2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md index 4f32347e04..85bd5b2134 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md @@ -16,7 +16,9 @@ The menu therefore kept serving the composition the session no longer ran. Switc The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). -The frame is per session and carries no catalog. Deriving it from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. +The frame is per session and carries no catalog, only the preset id — which the manager folds into the session row, because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against). + +Deriving the frame from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. ## Alternatives considered @@ -38,4 +40,4 @@ That e2e also stopped reading its staged-pick assertion off the serialized sessi ## Related -Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, the e2e below could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. +Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, `agent-preset-selection.e2e.ts` could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md index fb30df74a9..97c8f08a7b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md @@ -16,7 +16,9 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿 这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 -该帧按会话粒度,且不携带目录。从落账事件而不是 RPC 处理器的返回值派生它,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 +该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。 + +从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 ## Alternatives considered @@ -38,4 +40,4 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿 ## Related -第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,下面那条 e2e 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 +第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,`agent-preset-selection.e2e.ts` 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 71e1a28b05..2bcaa5e628 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -173,12 +173,14 @@ describe('web e2e: agent-preset selection', () => { }) it('re-reads the slash catalog through the composition the switch installed', async () => { + // Continues the previous case: the chip has already applied `minimal` to + // the blank session, and this one reads the menu that switch left behind. onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) const composer = page.locator('textarea:enabled').last() - // `minimal` (applied above) mounts neither the compaction group nor plan - // mode nor local skill discovery, so the catalog the composer warmed - // under the deployment default must not survive the switch. + // `minimal` mounts neither the compaction group nor plan mode nor local + // skill discovery, so the catalog the composer warmed under the + // deployment default must not survive the switch. await composer.fill('/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 29d5a2d4dc..16e055f0bf 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: b1c0c8e5b6aa93f5e79b4b75c5f8db89bd656688 -README.zh.md: b6add06324bf9fc5cf4a93d89d88072609a67c50 +README.md: 753d1de796ba8ff20217d423555710429e9b7a75 +README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index b1c0c8e5b6..753d1de796 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index b6add06324..9b5b8ba7ce 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ce61351cca..fd601090bf 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -780,6 +780,14 @@ export class SessionManager { } return } + case 'host/session-preset-changed': { + // Every connected client observes the switch here; only the tab that + // issued it also gets the RPC echo. The merge keeps the row's own + // updatedAt and lowers `blank` only, so re-applying the switching + // tab's own frame is a no-op. + this.noteAgentPreset(frame.sessionId, frame.agentPreset) + return + } case 'host/session-removed': { const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId) const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index e7d702f40a..3b25ff849c 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -87,6 +87,23 @@ describe('list store projection', () => { expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') }) + it('learns a preset switch from the host frame, not only from the tab that issued it', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) + + // Every connected client gets this frame; only the switching tab gets the + // RPC echo. A client that ignored the payload would keep labelling the + // session with the composition it replaced. + b.svc.handleHostEnvelope({ + rpcId: 'r1' as never, + payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never, + }) + await Promise.resolve() + + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true) + }) + it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index f17f3950d2..866ff89c9d 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -124,7 +124,7 @@ export class CommandService extends Service implements CommandServiceContract { warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.on('commands/changed', () => { this.directory.invalidateAll() }) - // A preset switch changes which commands ONE session's agent resolves and + // A preset switch changes which commands one session's agent resolves and // registers nothing globally, so the registry-wide signal above never // fires for it: repull that key alone, soft, so the old snapshot serves // the menu until the new one lands. diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 43607816b3..351ea4115e 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -137,8 +137,9 @@ export type HostFrame = * re-parents that agent's scope without registering anything, so a * preset already mounted for another session produces no registry change * at all. Clients refetch the catalogs this session's composition decides - * (`command.list`, `skill.list`) for this sessionId alone; the preset id - * rides along for surfaces that label the session. + * (`command.list`, `skill.list`) for this sessionId alone, and fold the + * preset id into their session row — the RPC echo reaches only the client + * that issued the switch, so the row is where every other one learns it. */ | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } /** From 298ae62a173078e1134363bcf5f7e5028068af72 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 15:16:53 +0800 Subject: [PATCH 60/73] docs(scripts): name the event-matrix collector's client-face blind spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated matrix under-reports client-face listeners because the program is seeded from the host aggregate alone, so `session/preset-changed` lists `ui-command` without `ui-skill` — the same shape as the existing `connection/reset` and `models/changed` rows. Record it where the collector lives, with what closing it actually takes. --- scripts/gen-doc-graphs.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 26e9233f82..cbb73fdcce 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -734,7 +734,18 @@ type CallSiteIndex = Map() private readonly fileCallSites = new Map() From 37ebe87087c75d628e551775d90247ae211dbe23 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 15:27:10 +0800 Subject: [PATCH 61/73] fix(tool-tasks): claim completion notices only for the mount's own scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the task registry to the host plane put every preset's `tool-tasks` listener on ONE `LocalTaskService`. `settle()` computes a single snapshot and walks every registered listener with no scope filter, and it marks `reported` only when a waiter is present — so a task settling without a waiter reached each mount's listener with `reported` false and every one of them injected the same completion into the same owner. Three shipped presets carry `tool-tasks`, and a preset file edit adds a second generation of the same mount, so an agent read N copies of one notice as model-visible durable context. A mount now claims an owner only when the owner's scope chain reaches the mount's own scope. An unscoped mount is the host-plane instance that serves every agent, which keeps the TUI composition and every existing test intact. Registry-side ownership was the alternative: mark `reported` once the first listener claims it. It is wrong because `onTaskDone` is not a notice-only seam — the `dsh-tasks` invariant companion registers a validating listener — so first-claim-wins would silence observers that are not delivering anything. The regression test mounts two scoped `tool-tasks` over one registry and settles an unowned-wait task, which is the only path that reaches the notice listeners at all: the shipped-composition e2e uses `wait: true`, and a waiter marks `reported` before settlement, so that test structurally cannot cover it. Also corrects the standing-mounts Agent Note, which still listed `tasks-local` among the stateful PRESET plugins. Refs #2141 --- ...08-08-per-preset-standing-mounts.i18n.yaml | 4 +- .../2026-08-08-per-preset-standing-mounts.md | 2 +- ...026-08-08-per-preset-standing-mounts.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 2 + packages/tasks/tool-tasks/README.zh.md | 2 + packages/tasks/tool-tasks/package.json | 2 + packages/tasks/tool-tasks/src/index.ts | 12 ++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 49 +++++++++++++++++++ packages/tasks/tool-tasks/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 17 files changed, 88 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index d8c55c9f0a..410e209e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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/architecture/2026-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 -2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 +2026-08-08-per-preset-standing-mounts.md: c2792454f90a88cd6fba36eed8e36104e5fffea4 +2026-08-08-per-preset-standing-mounts.zh.md: 47668c8c2c424eb188aa14bf55986d27bcfb8ee0 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 834d645f5f..c2792454f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -16,7 +16,7 @@ A preset is one composition per PROCESS, not one per session. The roster mounts Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap -- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. `tasks-local` shared that property and has since left the preset plane entirely: producers outside its realm (`tool-bash`, `tool-pty`, a non-continuable `tool-subagent`) resolve the registry with `ctx.get`, which an entry-local realm hides from them, so it is composed on the host plane and only the model-facing `tool-tasks` row stays per preset. - Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: : true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. - A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 45ce0f4e7d..47668c8c2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -16,7 +16,7 @@ Status: implemented 常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: -- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。`tasks-local` 同样具备该性质,且此后已完全离开 preset 平面:realm 之外的生产方(`tool-bash`、`tool-pty`、非 continuable 的 `tool-subagent`)以 `ctx.get` 解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的 `tool-tasks` 行仍留在各 preset 中。 - preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: : true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 - 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 60c6e85cca..680f6afa42 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 -config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca +config-catalog.md: f4d275393dd918f1391d33db19222e4e62e80b96 +config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18980d22c6..f4d275393d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2332,7 +2332,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a43c561806..9b3cf689c8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2333,7 +2333,7 @@ export interface Config { } ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 54453b3411..229c8d1cd6 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: c41db02165740b19a9ef751e6f50316a76df28c3 -module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa +module-graph.md: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98 +module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd diff --git a/docs/module-graph.md b/docs/module-graph.md index c41db02165..0f76bfd2dd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -947,6 +947,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1390,7 +1391,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9071dbc0f2..7fa0eb7266 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -949,6 +949,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1392,7 +1393,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 24e6b6892a..8c97357246 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-tasks/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/tasks/tool-tasks/README.md -README.md: 6e8e889c2330d6991cb384674b011e4d2e988268 -README.zh.md: 355b6736b476fb2434f17ea3857544f32c40adb3 +README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8 +README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 6e8e889c23..1b63ba7124 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,6 +20,8 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task (:

/ package.json # from upstream; set "private": true, keep name/exports/type - tsconfig.json # extends ../../tsconfig.base.json (see shape below) + tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them ``` @@ -31,7 +31,7 @@ vendor// `package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). -Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build-shape divergence from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. +Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build difference from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. ## 2. Register it in the root configs @@ -42,7 +42,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts` | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | -Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. +Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build configuration differs from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. ## 3. Mind the manifest guard diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index 2927837a28..d16ec10564 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -9,7 +9,7 @@ ``` vendor// package.json # from upstream; set "private": true, keep name/exports/type - tsconfig.json # extends ../../tsconfig.base.json (see shape below) + tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them ``` @@ -31,7 +31,7 @@ vendor// `package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 -vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地的构建形态与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 +vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地构建与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 ## 2. 在根配置中注册 @@ -42,7 +42,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显 | `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 | | `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) | -以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor//tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 +以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建配置与根默认值不同时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor//tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 ## 3. 注意 manifest 守卫 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 6e83450ec3..ac600e8c6d 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.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/cookbook/extension-cookbook.md -extension-cookbook.md: aba220ec6dc3cf3d0f99edd0e47ffa6069499b47 -extension-cookbook.zh.md: 34e8fc0fa3d2d57136616d66cfb4f3f6de20605e +extension-cookbook.md: 95ba269a5d62e14cfde487d5a3aaca5db493657e +extension-cookbook.zh.md: e3fbe09f1ec09568e3b259aee361d33ba3e62140 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index aba220ec6d..95ba269a5d 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -2,11 +2,11 @@ English | [中文](extension-cookbook.zh.md) -Reference shapes for the harness extension surface. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-point map. +Reference patterns for harness extensions. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-point map. ## A tool plugin -A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` args, result shaping, the `run_in_background` pattern) lives in [adding-a-tool.md](adding-a-tool.md) — that guide is the source of truth for the tool shape. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed sugar for first-party tools. +A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` arguments, result construction, the `run_in_background` pattern) lives in [adding-a-tool.md](adding-a-tool.md) — that guide is the source of truth for tool definitions. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed helper for first-party tools. ## A hook plugin (permission-gate example) @@ -64,7 +64,7 @@ export function apply(ctx: Context) { A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, and maps protocol requests to `followup()` or `cancel()`. A low-level prompt request returns its durable enqueue receipt; it does not acquire a result by correlating `MessageId` with `turn/end`. Publish whole-agent status separately. An automation method may wait from its receipt through the next idle and summarize that explicitly owned interval, while a UI normally keeps observing the open-ended event stream. Tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. -[`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) owns the exact method and lifecycle contract. +[`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) defines the exact methods, event order, and lifecycle contract. ```ts import type { Context } from 'cordis' diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 34e8fc0fa3..e3fbe09f1e 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -2,11 +2,11 @@ [English](extension-cookbook.md) | 中文 -harness 扩展表面的参考形态。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展点映射由[架构文档](../architecture.md)负责。 +harness 扩展的参考模式。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展点映射由[架构文档](../architecture.md)负责。 ## 工具插件 -工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果塑形、`run_in_background` 模式)见 [adding-a-tool.md](adding-a-tool.md)——该指南是工具形态的真源。`ctx.tools.register()` 也直接接受原始 JSON Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是为第一方工具提供的类型化语法糖。 +工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果构造、`run_in_background` 模式)见 [adding-a-tool.md](adding-a-tool.md)——该指南是工具定义的真源。`ctx.tools.register()` 也直接接受原始 JSON Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是第一方工具使用的类型化辅助函数。 ## 钩子插件(以权限门禁为例) @@ -64,7 +64,7 @@ export function apply(ctx: Context) { *协议驱动*将协议对端接入 `ctx.agents`;它可以服务于 UI 或自动化客户端。stdio 驱动拥有 stdout,通过工厂创建或恢复 agent(智能体),并将协议请求映射为 `followup()` 或 `cancel()`。底层提示词请求返回其持久入队回执;它不会通过关联 `MessageId` 与 `turn/end` 获得结果。整个 agent 的状态应单独发布。自动化方法可以从回执等待到下一次 idle,并概括这一显式拥有的区间;UI 通常则会持续观察开放式事件流。通过 `AgentHandle.dispose()` 拆除 agent,以使 dispose(资源释放)达到完全停稳。 -[`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 拥有精确的方法和生命周期约定。 +[`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 定义确切的方法、事件顺序和生命周期约定。 ```ts import type { Context } from 'cordis' diff --git a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml index 331373fb1e..8ceb56b26b 100644 --- a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml +++ b/docs/cookbook/maintaining-dsh-code-review.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/cookbook/maintaining-dsh-code-review.md -maintaining-dsh-code-review.md: c8517054434f4b090c67455cda0a992c2c3173ee -maintaining-dsh-code-review.zh.md: 3d26d540a41661aa45b41d6738118f36d904ccf3 +maintaining-dsh-code-review.md: a8c2a66c065aaec5c03f0ab6965377d1d1eb14bd +maintaining-dsh-code-review.zh.md: c72323c4ab0c31fa30aa3ef0e8ea41b129ad0c1d diff --git a/docs/cookbook/maintaining-dsh-code-review.md b/docs/cookbook/maintaining-dsh-code-review.md index c851705443..a8c2a66c06 100644 --- a/docs/cookbook/maintaining-dsh-code-review.md +++ b/docs/cookbook/maintaining-dsh-code-review.md @@ -20,7 +20,7 @@ Each run stores its artifacts on the operator's machine. The saved diff, candida When a run produces a candidate, a macOS notification arrives with a `dsh-code-review-promote ` hint. -1. **Read the diff on its own merits.** Do not defer to "the reviewers approved" — the maintainer contract is that the operator is the final judgment. Look for checklist bloat, historical prose, unsupported extrapolation from a single incident, and duplicated coverage with existing skill or authoritative-doc content. +1. **Read the diff on its own merits.** Do not defer to "the reviewers approved"; the maintainer contract is that the operator makes the final decision. Look for checklist bloat, historical prose, unsupported extrapolation from a single incident, and duplicated coverage with existing skill or authoritative-doc content. ```sh ls ~/dsh-code-review-outputs/ # every candidate ever produced diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md index 3d26d540a4..c72323c4ab 100644 --- a/docs/cookbook/maintaining-dsh-code-review.zh.md +++ b/docs/cookbook/maintaining-dsh-code-review.zh.md @@ -20,7 +20,7 @@ 某次运行产出候选版本时,macOS 会发出一条带 `dsh-code-review-promote ` 提示的通知。 -1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受:维护者约定规定最终判断由操作员作出。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 +1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受;维护者约定规定由操作员作出最终决定。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 ```sh ls ~/dsh-code-review-outputs/ # every candidate ever produced diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 9e1143231e..12177e3d35 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.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/cordis-primer.md -cordis-primer.md: 4bcb2c9979994ca70f92031cbdc5dd22df9c1977 +cordis-primer.md: c95909a4a1deab9407efedbb990ef13be6e43a16 cordis-primer.zh.md: a18b8b37af19a610b71babbe5e67f96bb09e81b1 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 4bcb2c9979..c95909a4a1 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -23,7 +23,7 @@ Every event can have one of the following dispatch mode and can only be dispatch | `parallel` | Yes | all listeners observe the event in parallel | No | | `serial` | Yes | listeners observe in registration order | Yes | -The mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. +The dispatch mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. ## Cordis Waterfall Semantics diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 1fe3585ba1..9bf649ab29 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.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/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 4359dfe4883f12e9cb242cf3009827fd7864768c -01-first-plugin.zh.md: 9965f4ddb75fa338ba7fd9d564bd4a32fced7b93 +01-first-plugin.md: 260026329443f9a5b8860d11a6527dbd687eb44c +01-first-plugin.zh.md: 69dedb898c7ea29f99233f07126cd413fa0ddbe2 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 4359dfe488..2600263294 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -52,7 +52,7 @@ There is no framework bootstrap code in your file: a plugin describes what it co ## The two other plugin shapes -A function is the most common shape, but Cordis accepts three: +A function is the most common form, but Cordis accepts three: ```ts import { Service, type Context } from 'cordis' diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 9965f4ddb7..69dedb898c 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -52,7 +52,7 @@ hello from my first plugin ## 其他两种插件形态 -函数是最常见的形态,但 Cordis 接受三种形态: +函数是最常见的形式,但 Cordis 接受三种形式: ```ts import { Service, type Context } from 'cordis' diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index fa810d635f..719e949ffe 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.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/cordis-tutorial/index.md -index.md: 7a0bb6f8c736bf31d655a7763cfb7039c343d1a2 -index.zh.md: e6f6dc0cccef3f44273655b98b695bdc4632e95a +index.md: 307c12854b3075cfd4dd5ea8a19806c58b4e998d +index.zh.md: a0107b7d15272e6ef8d526b9c0e03a99275644d6 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index 7a0bb6f8c7..307c12854b 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -53,6 +53,6 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: - **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. - **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. -Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. +Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object fields a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index e6f6dc0ccc..a0107b7d15 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -53,6 +53,6 @@ node --import tsx ../../vendor/cordis/bin.js - **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 - **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 -第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 +第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 68f62583c2..18b28ca58c 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.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/defensive-patterns.md -defensive-patterns.md: afb462e120892eafe676d8b7feef273c2d0e42df -defensive-patterns.zh.md: ab5f689d47f89b6930b749824c69325490bd4586 +defensive-patterns.md: 368c9876f1a4e7042b003f6acfb30af3b2daf402 +defensive-patterns.zh.md: c7d4c1bf37ef17947913ac4011624d04ffd8c1a3 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index afb462e120..368c9876f1 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -10,7 +10,7 @@ A result can be several things at once — a process can time out AND exit 0 bec ## Honor public contracts on BOTH sides -When an implementation boundary receives several representations of one outcome, normalize them before crossing the public contract. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. +When an implementation receives several representations of one outcome, normalize them before returning through the public API. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. ## Async state is not synchronous state @@ -20,7 +20,7 @@ When an implementation boundary receives several representations of one outcome, A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. -## Contain callback exceptions at the boundary +## Contain callback exceptions in the dispatcher A user-supplied listener that throws must not reject the promise it runs inside or starve the listeners after it. Wrap the dispatch loop in try/catch and log; one bad subscriber never breaks core lifecycle. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index ab5f689d47..c7d4c1bf37 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -10,7 +10,7 @@ ## 公共约定两侧都要遵守 -当一个实现边界接收到同一结果的多种表示时,应在跨越公共约定前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止 finish chunk 暴露模型请求失败;middleware 与消费方缺陷仍会抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化约定;通过真实消费方覆盖每种来源形式。 +当一个实现收到同一结果的多种表示时,应在通过公共 API 返回前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止型 finish 分片暴露模型请求失败;middleware 缺陷与消费方缺陷仍会以异常形式抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化后的约定;通过真实消费方覆盖每种来源形式。 ## 异步状态不是同步状态 @@ -20,7 +20,7 @@ 如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器和通知注册表,使迟到的完成事件保持静默。 -## 在边界处隔离回调异常 +## 在分发器中隔离回调异常 用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 84cff8fa96..da29debe08 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: 8b6c148d87fdd6288695b0029b9dfcc0139d2144 -development.zh.md: 55ca013cc0dd901500e340a7447057de27665f61 +development.md: 8e565f21c6e2ede7dab7dbda3c4b18b77ce0920f +development.zh.md: d9c0fbfbb663334b8f7e2ca11d4e8d9a0652c22e diff --git a/docs/development.md b/docs/development.md index 8b6c148d87..8e565f21c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ English | [中文](development.zh.md) -The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts. +The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts. ## Setup tutorial @@ -51,7 +51,7 @@ The repository uses isolated Host and Client aggregates. An ordinary package is | `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes | | `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes | | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | -| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | +| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order. The root build follows the generated dependency order: @@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil TypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. -Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. @@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional ### Git integrations -The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary. +The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts. The installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states. @@ -156,10 +156,10 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel ### Documenting types verbatim (`ts type-equiv`) -The [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: +The [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. diff --git a/docs/development.zh.md b/docs/development.zh.md index 55ca013cc0..d9c0fbfbb6 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -2,7 +2,7 @@ [English](development.md) | 中文 -搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。 +搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。 ## 搭建教程 @@ -51,7 +51,7 @@ pnpm run typecheck | `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 | | `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 | | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | -| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: @@ -59,7 +59,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。 根构建按生成依赖排序: @@ -75,7 +75,7 @@ pnpm run build:web TypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。 -静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 @@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional ### Git 集成 -当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。 +当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。 安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。 @@ -156,10 +156,10 @@ pnpm run demo:acp ### 逐字记录类型(`ts type-equiv`) -[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: ```json { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 3bac30f742..a2caf7b784 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 4b7a8794808cc02daac7031f4981a765c97ca81a -event-producer-consumer.zh.md: 976f41d7798e9182b60366d77e546d79c4a66a13 +event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360 +event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4b7a879480..b78171ce51 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 976f41d779..c044385bf9 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -46,12 +46,12 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/graph-atlas.i18n.yaml b/docs/graph-atlas.i18n.yaml index 0cd73d4f39..09b1b58178 100644 --- a/docs/graph-atlas.i18n.yaml +++ b/docs/graph-atlas.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/graph-atlas.md -graph-atlas.md: 5b831520fb4e1c5d49d83738ba979ce04ce4f69f -graph-atlas.zh.md: 1f9a30124284549248bbb21f926772d5cdc2598a +graph-atlas.md: bf2aeba1210709cdda68e0ea7d611528f3191744 +graph-atlas.zh.md: 780e5295f74f10ee4fe762e280e5c1c820c481c4 diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 5b831520fb..bf2aeba121 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -3,7 +3,7 @@ # Documentation Graph Index -These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md). +These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md). The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md). diff --git a/docs/graph-atlas.zh.md b/docs/graph-atlas.zh.md index 1f9a301242..780e5295f7 100644 --- a/docs/graph-atlas.zh.md +++ b/docs/graph-atlas.zh.md @@ -5,7 +5,7 @@ [English](graph-atlas.md) | 中文 -这些图构成生成目录之上的关系层。你可以借助它们了解包拓扑、能力 seam、事件流、面向模型的工具、应用组合以及运行时生命周期路径。精确签名和类型结构仍以[子系统页面](subsystems/core.md)(类型和生成的 `cordis-surface` 区域)及[工具目录](tool-catalog.md)为准。 +这些图展示生成目录未包含的关系。可以用它们查找包之间的关系、能力 seam、事件流、面向模型的工具、应用组合和运行时生命周期路径。精确签名和类型定义仍以[子系统页面](subsystems/core.md)(类型和生成的 `cordis-surface` 区域)及[工具目录](tool-catalog.md)为准。 本索引背后的流程决策记录在[文档图 Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md)中。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index d84f04b02a..45e4077203 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/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 docs/i18n/README.md -README.md: 042fe71e796340c5c653e1f671c53a95a8496a38 -README.zh.md: d2d3b84e98cf4b761ae0174459b91236a71fb187 +README.md: af6a35294bc23adcd6214747f78a28a69ae443d1 +README.zh.md: 74cb98932460d014dab26d3b48cd81142e0f7bf8 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 042fe71e79..af6a35294b 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). +This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). ## The pairing contract @@ -17,7 +17,7 @@ This repo's documentation is read by people and agents both inside and outside t Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). - When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. + When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. - **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. - **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). @@ -37,7 +37,7 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. -The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. +The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. ## Scope and exclusions diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index d2d3b84e98..74cb989324 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 ## 配对约定 @@ -17,7 +17,7 @@ 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 - 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何无法确定的情形都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 + 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 - **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。 - **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 @@ -37,7 +37,7 @@ 这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 -把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 +门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 ## 范围与排除 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 1b8a0e3f1a..eb8a4b673b 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -14,9 +14,9 @@ 依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-spine-demo`,它的职责是组装整套实体主干。 -> This document covers **behavior**; type shapes live in [subsystems/](../subsystems/core.md), the per-event/service reference in the generated regions of [subsystems/](../subsystems/core.md), per-package contracts in the package READMEs ([map](../../packages/README.md)). +> This document covers **behavior**; type definitions live in [subsystems/](../subsystems/core.md), the per-event/service reference lives in the generated regions of [subsystems/](../subsystems/core.md), and package contracts in the package READMEs state each package's required configuration and behavior ([map](../../packages/README.md)). -本文档描述整体行为逻辑;类型定义存放于 [subsystems/](../subsystems/core.md);各类事件、服务的详细参考见 [subsystems/](../subsystems/core.md) 中的生成区块;各包(package)的对外约定写在相应的 README 中([索引](../../packages/README.md))。 +本文档描述整体行为逻辑;类型定义存放于 [subsystems/](../subsystems/core.md);各类事件、服务的详细参考见 [subsystems/](../subsystems/core.md) 中的生成区块;相应的 README 说明每个包(package)要求的配置和行为([索引](../../packages/README.md))。 ## ② 防御模式规则 @@ -46,9 +46,9 @@ 自带自动跳过逻辑,仅用于保障无密钥 CI 环境、无权限贡献者不会被流程拦截,不代表可以以此为由削减真实接口测试投入。 -> **Prefer the real implementation over a mock** — Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. +> **Prefer the real implementation over a mock** — Mock only genuinely expensive or non-deterministic dependencies (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. -**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的边界模块做 mock(LLM(大语言模型)适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。 +**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的依赖做 mock(LLM(大语言模型)适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。 ## ④ 机制描述 @@ -58,9 +58,9 @@ ## ⑤ 政策声明 -> The gate's limit, stated plainly: a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound. It checks hashes and shape; it cannot judge whether the two sides actually say the same thing — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. +> The gate's limit, stated plainly: a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound. It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. -门禁的边界很明确:通过门禁只说明两侧文件当前的 blob hash 与伴随记录吻合,并且结构签名一致,也就是说,这组内容曾被确认一致;它不代表这次确认可靠。门禁无法判断两种语言是否真正表达了相同的意思;这部分约定要由评审人把关。即使译文粗糙、表意有误,重新记录配对后仍能通过门禁,但绝不能通过人工评审。 +门禁的限制很明确:通过门禁只说明两侧文件当前的 blob hash 与伴随记录吻合,并且 Markdown 结构签名一致,也就是说,这组内容曾被确认一致;它不代表这次确认可靠。评审人必须检查两种语言是否真正表达了相同的意思。即使译文粗糙、表意有误,重新记录配对后仍能通过门禁,但绝不能通过人工评审。 ## ⑥ Agent Note 论证 @@ -84,4 +84,4 @@ - 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 - 母语重写不等于删减:原文每个语义成分都要落地。 - 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent、mock、LLM 保留英文,cancellation 译「取消」)。 -- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写——这是行文规则的硬边界,Pass 2 逐句核验的重点。 +- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写;Pass 2 必须逐句核验。 diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index d8c6d95f73..b4ade80981 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -70,7 +70,7 @@ A lower-priority rule may refine but never override a higher-priority requiremen - The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it. - Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions. - Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction. -- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them. +- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning. - Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`. - Keep the author's register: concise stays concise, detailed stays detailed. @@ -127,7 +127,7 @@ A terminology table is provided below. Follow it strictly: ## Output Format -Return exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence. +Return exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence. The outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping. @@ -152,10 +152,10 @@ The outer section tags are framing. If Markdown inside any section body contains ## Self-Review Instructions -After writing ``, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections. +After writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections. **Structure** -- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source? +- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source? - Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source? - Are inline code spans and machine-readable tokens verbatim? - Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one? @@ -169,7 +169,7 @@ After writing ``, verify it in two directions. First re-read it in **Tone & Style** - Does every sentence read as if originally written by a native technical author? -- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing? +- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing? - Are actors explicit where the target language needs them, without inventing responsibility? **Sentence Structure** @@ -228,9 +228,9 @@ Below are representative examples of common problems and their corrections. Foll - Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。` ### Overly literal → Meaningful rendering -- Source: `awkward phrasing is easier to hear without the source anchoring you` -- Bad: `没有源文锚着,别扭的表述更容易被听出来` -- Good: `不对照原文时,更容易察觉别扭的表达` +- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source` +- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意` +- Good: `不对照原文阅读译文时,更容易察觉别扭的表达` ### Terminology — do not translate what should be kept in English - Source: `typed service seams, and explicit extension points` diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7a8f73a488..ee0b5cbdd6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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/persistence-catalog.md -persistence-catalog.md: f1dd0f6635bbb2ed2bbf679fdab2664cef08906d -persistence-catalog.zh.md: 7a0f66b5622fbc9527947019da442b21a1b67b9a +persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d +persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f1dd0f6635..f44569d3ba 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -474,7 +474,7 @@ Source: [`packages/interaction/permission/src/index.ts:50`](../packages/interact 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7a0f66b562..21ed29a3da 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -476,7 +476,7 @@ export type SessionEvent = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index e538f244b9..63484a6ba0 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.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/postmortem/0001-acp-default-export-drops-inject.md -0001-acp-default-export-drops-inject.md: 2d36f24fa54814e39345d7fe68792023c2cf0194 -0001-acp-default-export-drops-inject.zh.md: 6ae7d45f58e09f205a5653f7c6d306014d4d393e +0001-acp-default-export-drops-inject.md: f8474bde0b81b24573f813d9a0fb017962751f49 +0001-acp-default-export-drops-inject.zh.md: 1e64f123d1dd0b5e7e81d3a8c4a5e6f77e6411ff diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 2d36f24fa5..f8474bde0b 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -18,7 +18,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Timeline -- The bridge (RFC 010) landed with a full unit suite (codec, in-memory transport, property-based protocol-shape, failure paths, HMR), a key-gated real-API e2e, and a no-key stdout-purity e2e. All green, 100% coverage. +- The bridge (RFC 010) landed with a full unit suite for the codec, in-memory transport, generated protocol messages, failure paths, and HMR; a key-gated real-API e2e; and a no-key stdout-purity e2e. All green, 100% coverage. - A real Zed session immediately failed on `session/new` with `cannot get property "agents" without inject`. - Investigation initially pursued a Cordis "traceable/shadow" theory (plausible, and the mechanism is real — see Bug #2), then instrumented the actual fiber walk in vendored `reflect.ts` and ran the real subprocess. The trace showed the throw at `apply()` line 179 *at plugin load time*, on the ROOT fiber with no shadow — falsifying the shadow theory for `session/new`. - Root cause #1 found: a stray `export default apply`. Removing it fixed `session/new`. @@ -26,7 +26,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Root cause #1 — `export default apply` drops the plugin's `inject` (broke `session/new`) -`packages/acp/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: +`packages/acp/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports, as every other plugin in the repo does (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: ```ts ignore-check export const name = 'acp' diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index 6ae7d45f58..1e64f123d1 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -18,7 +18,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 ## 时间线 -- bridge(RFC 010)落地时附带完整的单元测试套件(codec、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 +- bridge(RFC 010)落地时有一套完整的单元测试,覆盖 codec、内存传输、生成的协议消息、失败路径和 HMR(热模块替换);另有一个需要 key 的真实 API e2e 测试和一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 - 真实 Zed 会话在 `session/new` 上立即失败,报错 `cannot get property "agents" without inject`。 - 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 目录中的 `reflect.ts` 里对实际 fiber 遍历做了插桩,并运行了真实子进程。跟踪结果显示,异常在 `apply()` 第 179 行、*插件加载时*抛出,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 - 找到根因 #1:一行多余的 `export default apply`。删除后 `session/new` 修复。 @@ -26,7 +26,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 ## 根因 #1——`export default apply` 丢弃了插件的 `inject`(导致 `session/new` 崩溃) -`packages/acp/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出——与仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`tui` 等)形状相同。但它*还*多了一行其他插件都没有的代码: +`packages/acp/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出,仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`tui` 等)也是如此。但它*还*多了一行其他插件都没有的代码: ```ts ignore-check export const name = 'acp' diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index b10d882b59..e1e354fc6a 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.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/postmortem/0002-js-expression-disabled-filesystem-tools.md -0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: 3c18a48d3b7e925a6e75c2d3edb3ec642e72e1b3 +0002-js-expression-disabled-filesystem-tools.md: b2bd37ff2b5a6d01c585514dd83f7fa6604f8945 +0002-js-expression-disabled-filesystem-tools.zh.md: 7a26f8456c13ad22535cd04fdec061dccd2ed85d diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md index 30ff9d9208..b2bd37ff2b 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md @@ -29,7 +29,7 @@ The live confined default did not gain unintended filesystem access. A naive int ## Root cause -The implementation assumed `!!js` applied to an entire Loader entry. Its actual boundary is narrower: `Entry._resolveConfig()` interpolates only `entry.options.config`; `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic. +The implementation assumed `!!js` applied to an entire Loader entry. It applies only to `entry.options.config`: `Entry._resolveConfig()` interpolates that field, while `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic. The snapshot framework treated any deterministic transcript as valid behavior. Header pins verified the composed tool schemas, but the filesystem scenarios shared a pin from the default composition and therefore did not independently prove that their required tools were registered. Refresh rewrote the expected stdout and session logs before any semantic assertion rejected missing tools. @@ -42,6 +42,6 @@ The snapshot framework treated any deterministic transcript as valid behavior. H ## Lessons -- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify interpolation boundaries. +- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify exactly which fields are interpolated. - A snapshot refresh is fixture production, not correctness review. Semantic impossibilities such as a missing registered tool need assertions independent of the expected output. - Permission controls must describe only the capabilities they actually govern. Composition-time filesystem access cannot follow a runtime bash-only preset safely. diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 3c18a48d3b..7a26f8456c 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -29,7 +29,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 根因 -实现时假设 `!!js` 适用于整个 Loader 配置项。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 +实现时假设 `!!js` 适用于整个 Loader 配置项。实际只有 `entry.options.config` 使用它:`Entry._resolveConfig()` 对该字段进行插值,而 `Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 快照框架将任何确定性的 transcript(文本记录)视为有效行为。Header pin 验证了组合后的工具 schema,但文件系统场景共享来自默认组合的 pin,因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前,就已重写了预期的 stdout 和会话日志。 @@ -42,6 +42,6 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 教训 -- 语法上被接受的配置值不一定在该位置被求值;应记录并验证插值边界。 +- 语法上被接受的配置值不一定在该位置被求值;应记录并验证具体对哪些字段进行插值。 - 快照刷新是 fixture 的生产过程,不是正确性审查。诸如已注册工具缺失这类语义上不可能的结果,需要独立于预期输出的断言。 - 权限控制只应描述其实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml b/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml index ed77fc2f2e..4272783be3 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.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/postmortem/0003-web-agent-gui-feedback-loop.md -0003-web-agent-gui-feedback-loop.md: 13d13a607babfe7f5ddfdb6773c94f973bbef0db -0003-web-agent-gui-feedback-loop.zh.md: 44d4febc30344135932aad2394b3054587feca1d +0003-web-agent-gui-feedback-loop.md: 0d8c07d9aca3305ea6bc85134e8578ae1bd8f387 +0003-web-agent-gui-feedback-loop.zh.md: 07faa3a302d1b124efcbdc57446995aefeb8bd87 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.md index 13d13a607b..0d8c07d9ac 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.md +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.md @@ -31,7 +31,7 @@ No change in this investigation restarted or modified the read-only 3081 and 308 ## Root cause -The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly represented the user's selected Workspace, but the model mistook that project boundary for the application boundary. No durable contract related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. +The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly identified the user's selected Workspace, but the model treated that project directory as the application directory. No durable record related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. The wrong startup path looked legitimate because bare Vite returned HTTP 200. `window.__DSH_BOOT__` is injected only by the full host, so transport readiness did not imply application readiness. The first regression test repeated this mistake in another form: a timeout killed Vite and satisfied a nonzero-exit assertion. Live reproduction exposed that false positive. diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md index 44d4febc30..07faa3a302 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md @@ -31,7 +31,7 @@ Web agent 修改了 GUI 源码,却不知道由哪个 URL 和进程承载当前 ## 根因 -Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确表示了用户选择的 Workspace,但模型误把这个项目边界当成了应用边界。系统也没有持久约定将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 +Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确标识了用户选择的 Workspace,但模型把这个项目目录当成了应用目录。系统也没有持久记录将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 裸 Vite 返回 HTTP 200,使错误的启动路径看似合理。`window.__DSH_BOOT__` 只由完整宿主注入,因此传输层就绪不代表应用已就绪。首个回归测试以另一种方式重复了同样的错误:超时机制终止 Vite 后,非零退出断言仍会通过。真实复现暴露了这一误报。 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 99c0f514c4..445d2b5a45 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: b1b57466feeee7625b0b5de78e5f9ff220ddef32 -README.zh.md: 492051f013def0f196902d1105f84a5644057f3a +README.md: 7d66cfcf66ffb0bed9430892308934c1f10982f4 +README.zh.md: 90e2b28b15870387539500568bb85b525db63ef6 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index b1b57466fe..7d66cfcf66 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -6,8 +6,8 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | Page | Owns | |---|---| -| [core.md](core.md) | the `packages/core` control spine: the package-by-package loop map, agent creation and ownership (`AgentHandle`), the `Agent` handle with its delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) | -| [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation vocabulary — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` provider contract | +| [core.md](core.md) | how `packages/core` controls the agent loop: the package-by-package loop description, agent creation and ownership (`AgentHandle`), the `Agent` handle's delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) | +| [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation types — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol and adapter contract, `BlockAssembler`, and the `LlmAdapter` provider contract | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, TypeRT registries, and the Host Gateway/Client API boundaries | @@ -23,7 +23,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | | [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 | +| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, 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 | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | @@ -38,7 +38,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | -| [tasks.md](tasks.md) | the background-task runtime: branded `TaskId`s, the producer contract, consumer views, `ctx.tasks` service behavior | +| [tasks.md](tasks.md) | the background-task runtime: branded `TaskId`s, the producer contract, consumer views, and `ctx.tasks` service behavior | | [permission.md](permission.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | @@ -47,6 +47,6 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [workspace.md](workspace.md) | the workspace registry: `Workspace`/`WorkspaceId`, registration and resolution, the session `cwd` relationship | | [client-modules.md](client-modules.md) | the web plugin table: `dshClient` declarations, `WebBootGraph` wire composition, the bundle route and index tap | | [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed | -| [telemetry.md](telemetry.md) | the session-telemetry capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, the `telemetry/record` redact waterfall | +| [telemetry.md](telemetry.md) | the outbound session-reporting capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, and the `telemetry/record` redact waterfall | > Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services and events use each page's generated **Cordis surface** section. diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 492051f013..90e2b28b15 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -6,8 +6,8 @@ | 页面 | 负责内容 | |---|---| -| [core.md](core.md) | `packages/core` 控制主干:逐包循环地图、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄及其投递/取消/拦截约定,以及全仓通用类型模式(`…Map → 派生联合`、品牌化 id) | -| [llm-streaming.md](llm-streaming.md) | `packages/llm` 的对话词汇——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` 协议格式(wire format)+ 适配器约定(adapter contract)、`BlockAssembler`、`LlmAdapter` 提供方约定 | +| [core.md](core.md) | `packages/core` 如何控制 agent loop:逐包的循环说明、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄的投递/取消/拦截约定,以及全仓通用类型模式(`…Map → 派生联合`、品牌化 id) | +| [llm-streaming.md](llm-streaming.md) | `packages/llm` 的对话类型——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` wire protocol 和适配器约定(adapter contract)、`BlockAssembler`,以及 `LlmAdapter` 提供方约定 | | [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [typert.md](typert.md) | 远程调用描述符、lookup/Context 声明、TypeRT 注册表,以及 Host Gateway/Client API 边界 | @@ -23,7 +23,7 @@ | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | | [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | -| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 约定 | +| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 | | [attachment.md](attachment.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | | [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | @@ -38,7 +38,7 @@ | [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | | [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | -| [tasks.md](tasks.md) | 后台任务运行时:品牌化 `TaskId`、producer 约定、consumer 视图、`ctx.tasks` 服务行为 | +| [tasks.md](tasks.md) | 后台任务运行时:品牌化 `TaskId`、producer 约定、consumer 视图和 `ctx.tasks` 服务行为 | | [permission.md](permission.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | @@ -47,6 +47,6 @@ | [workspace.md](workspace.md) | 工作区注册表:`Workspace`/`WorkspaceId`、注册与解析、与会话 `cwd` 的关系 | | [client-modules.md](client-modules.md) | Web 插件表:`dshClient` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 | | [session-projection.md](session-projection.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 | -| [telemetry.md](telemetry.md) | 会话遥测能力 seam:`TelemetryRecord`/`TelemetrySeverity`、`TelemetryBackend` 约定、`telemetry/record` 脱敏 waterfall | +| [telemetry.md](telemetry.md) | 对外会话上报能力 seam:`TelemetryRecord`/`TelemetrySeverity`、`TelemetryBackend` 约定和 `telemetry/record` 脱敏 waterfall | > 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务与事件使用每页生成的 **Cordis surface** 小节。 diff --git a/docs/subsystems/bash.i18n.yaml b/docs/subsystems/bash.i18n.yaml index dbfc52bf6e..89b7a2feb6 100644 --- a/docs/subsystems/bash.i18n.yaml +++ b/docs/subsystems/bash.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/subsystems/bash.md -bash.md: d2797c1e3ff73fe8ecb5a053ed4a1d13b13298dd -bash.zh.md: b38a4f332978e94d2945f5fa49b38e8d7df8c9ca +bash.md: b7e7c25ac4e49c34e186ec63f2d84411d71631fc +bash.zh.md: 409a00f8e533f84852d56c1074319a8f0425fb37 diff --git a/docs/subsystems/bash.md b/docs/subsystems/bash.md index d2797c1e3f..b7e7c25ac4 100644 --- a/docs/subsystems/bash.md +++ b/docs/subsystems/bash.md @@ -136,7 +136,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The fields are owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` diff --git a/docs/subsystems/bash.zh.md b/docs/subsystems/bash.zh.md index b38a4f3329..409a00f8e5 100644 --- a/docs/subsystems/bash.zh.md +++ b/docs/subsystems/bash.zh.md @@ -136,7 +136,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。这些字段归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` diff --git a/docs/subsystems/code-runtime.i18n.yaml b/docs/subsystems/code-runtime.i18n.yaml index 47783d5f77..1ae9462b90 100644 --- a/docs/subsystems/code-runtime.i18n.yaml +++ b/docs/subsystems/code-runtime.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/subsystems/code-runtime.md -code-runtime.md: 6d3fa7fa72891897155cb9c611c7aa472629de4f +code-runtime.md: a40801313d1adbdd1d601d319c9e5a563db478b6 code-runtime.zh.md: 47516c7d21c24399498a05410ab9a46b61736fb4 diff --git a/docs/subsystems/code-runtime.md b/docs/subsystems/code-runtime.md index 6d3fa7fa72..a40801313d 100644 --- a/docs/subsystems/code-runtime.md +++ b/docs/subsystems/code-runtime.md @@ -36,7 +36,7 @@ interface CodeRunRequest { } ``` -The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract): +The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (matching `BashExecutor.run`'s resolve-on-failure contract): ```ts type-equiv /** diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index d2fe7303fc..7dc8602b93 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.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/subsystems/compaction.md -compaction.md: fdff28ac2ec83966a03050ba8d54fe0ee26c4fc6 -compaction.zh.md: e4227320d9829cac695dc4ba0c5779086f77194b +compaction.md: 8c2a987bc8423841fc23f81bde334c54bb5402c1 +compaction.zh.md: 42ebd7edcf9b64cb9ddcb9a81fef1fc0b2f834b3 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index fdff28ac2e..8c2a987bc8 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -20,7 +20,7 @@ The lock brackets the **whole** operation: `compact/start` is appended first, th The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored. -These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. +These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative fields. ## `CompactionResult` @@ -85,7 +85,7 @@ type ManualCompactionErrorCode = Pressure compaction runs at serial `agent/pre-step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. -The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. +The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for the tool-call/result pairing checks before and after a seq. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) defines their cache behavior. ## Tool-result pruning outcomes diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index e4227320d9..42ebd7edcf 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -20,7 +20,7 @@ 这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。 -这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 +这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威字段请循源码链接查看。 ## `CompactionResult` @@ -85,7 +85,7 @@ type ManualCompactionErrorCode = 压力压缩在串行 `agent/pre-step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 -该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包约定](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 +该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于检查 seq 之前与之后的工具调用/结果配对。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;[包约定](../../packages/compact/compact/README.md#tool-pairing-boundaries)定义其缓存行为。 ## 工具结果剪枝产出 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0b2b87a954..9915a99b63 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/subsystems/core.md -core.md: af27484160769156836f377e5b3aba2521280005 -core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a +core.md: 75f55fe5b1837576ba79564b9aee7e289f5f16f4 +core.zh.md: 7cd55b41c8f1358a89c6f35a4d3f733ccd8550ee diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af27484160..75f55fe5b1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -2,7 +2,7 @@ English | [中文](core.zh.md) -The **core** subsystem is [`packages/core`](../../packages/core/README.md) — the control spine every composition boots: the event-sourced session log, system-prompt assembly, the tool registry, the agent vocabulary, and the concrete loop that drives them. This page owns what the `agent`/`agent-loop` pair declares — how an agent is created and owned, and the `Agent` handle with its delivery, cancellation, and interception contracts — plus the two type patterns every subsystem follows; the group's dedicated pages and the rest of the folder are indexed in the [subsystems README](README.md). +The **core** subsystem is [`packages/core`](../../packages/core/README.md) — the packages every composition boots: the event-sourced session log, system-prompt assembly, the tool registry, the agent types, and the concrete loop that drives them. This page explains what the `agent`/`agent-loop` pair declares — how an agent is created and owned, and the `Agent` handle's delivery, cancellation, and interception contracts — plus the two type patterns every subsystem follows. The group's dedicated pages and the rest of the folder are indexed in the [subsystems README](README.md). ## The spine, package by package @@ -48,7 +48,7 @@ interface AgentHandle { `CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, seed boundary, origin classification, delegation depth), an optional `seed` replay prefix for forks, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. -`AgentFactory` is the creation contract behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers program against `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and their rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -206,11 +206,11 @@ The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, che ## Initiating Agent -The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns its lifetime and boundary rules. +The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules. ## Interception decisions -Pre-step decisions use the same identified `UserMessage` shape as durable user-role input. The entered batch is authoritative and preserves every message's id and source. Hook bridges map their native decision fields onto this typed result. +Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -232,7 +232,7 @@ type PreStepDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/pre-step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. +`agent/pre-step` is the only serial listener chain before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): @@ -245,13 +245,13 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`. -The `SessionEvent` envelope's exact conditional shape, the twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The `SessionEvent` envelope's exact conditional fields, the twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## `ToolDefinition` -The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. +The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed arguments), but it is the contract the registry holds and the loop dispatches through. -Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**. +Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall types, and the tool-presentation UI types are on **[tools.md](tools.md)**. ## Repo-wide type patterns @@ -259,7 +259,7 @@ Two patterns recur across every subsystem and are documented once, here. ### The `…Map → derived-union` pattern -Almost every extensible sum type in the harness follows one shape: an interface keyed by a discriminant tag (the `…Map`), from which the union is derived with `keyof`. Plugins add variants by **declaration merging** — no edit to the owning package. +Almost every extensible sum type in the harness follows one pattern: an interface keyed by a discriminant tag (the `…Map`), from which the union is derived with `keyof`. Plugins add variants by **declaration merging** — no edit to the owning package. ```ts ignore-check // The pattern, schematically: @@ -293,7 +293,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ### Branded IDs -IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs passed between packages are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 12935f4d88..7cd55b41c8 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -2,7 +2,7 @@ [English](core.md) | 中文 -**核心**子系统即 [`packages/core`](../../packages/core/README.md)——每个组合都会启动的控制主干:事件溯源的会话日志、系统提示词组装、工具注册表、agent 词汇,以及驱动它们的具体循环。本页拥有 `agent`/`agent-loop` 这对包所声明的内容——agent 如何被创建与拥有,以及 `Agent` 句柄及其投递、取消与拦截约定——外加每个子系统都遵循的两个类型模式;该组的专属页面与目录其余部分见[子系统 README](README.md)。 +**核心**子系统即 [`packages/core`](../../packages/core/README.md),包含每个组合都会启动的包:事件溯源的会话日志、系统提示词组装、工具注册表、agent 类型,以及驱动它们的具体循环。本页说明 `agent`/`agent-loop` 这对包所声明的内容:agent 如何被创建与拥有,以及 `Agent` 句柄的投递、取消与拦截约定;本页还说明每个子系统都遵循的两个类型模式。该组的专属页面与目录其余部分见[子系统 README](README.md)。 ## 主干逐包速览 @@ -50,7 +50,7 @@ interface AgentHandle { `CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、seed 边界、来源分类、委派深度)、fork 用的可选 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应物:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose 都会回滚事务,两个 id 均不发布。 -`AgentFactory` 是注册表背后的创建约定:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方面向 `ctx.agents` 编程,无需依赖具体循环包。确切的 `create`/`resume` 签名及其回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 @@ -206,17 +206,17 @@ type AgentCancelCause = cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录谁请求了取消,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 +[事件分类](../architecture.md#event)负责 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 ## 发起 Agent -`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;其生命周期与边界规则由 [initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)规定。 +`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)定义其生命周期和作用域规则。 ## 拦截决策 -pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。进入步骤的批次具有权威性,并保留每条消息的 id 和 source。钩子桥接层把其原生决策字段映射到这一类型化结果上。 +pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 类型。进入步骤的批次具有权威性,并保留每条消息的 `id` 和 `source`。钩子桥接层把其原生决策字段映射到这一类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -238,7 +238,7 @@ type PreStepDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/pre-step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 +`agent/pre-step` 是请求推导前唯一的串行监听器链。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): @@ -251,13 +251,13 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。 -`SessionEvent` 信封的确切条件形状、十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +`SessionEvent` 信封的确切条件字段、十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## `ToolDefinition` -唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的约定。 +唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会使用类型化参数构建),但它是注册表存储并由循环用于分发的约定。 -其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 +其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 类型,以及工具展示 UI 类型都在 **[tools.md](tools.md)** 中。 ## 全仓通用类型模式 @@ -265,7 +265,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ### `…Map → derived-union` 模式 -harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 +harness 中几乎所有可扩展的和类型都遵循同一模式:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 ```ts ignore-check // The pattern, schematically: @@ -301,7 +301,7 @@ declare module '@deepseek-ai/dsh-llm' { ### 品牌化 ID -跨越包边界的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 +在包之间传递的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 `Branded` 原语位于独立的纯类型包 [dsh-brand](../../packages/util/brand) 中(没有运行时代码,也不依赖 Harness 包),因此任何包都能品牌化其拥有的 id,而无需依赖无关的能力包。 diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 7e9e057c75..c0ca84eb9f 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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/subsystems/credentials.md -credentials.md: 0bc2224ac039addc795d3806e8c85004f2bb84a7 -credentials.zh.md: f236b0b2daef85784308f10dbcfc67db84c42234 +credentials.md: 1d168999d338ba43c92150b171f89b610850f694 +credentials.zh.md: af92f17b10c80d3f78c61e306c526a165e48381b diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 0bc2224ac0..1d168999d3 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -8,7 +8,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ## Identity -A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape. +A reference names one credential as a POSIX-style environment-variable name. The brand prevents callers from mixing credential references with other strings passed between packages or processes; construction validates the shell-identifier syntax. ```ts type-equiv /** Nominal reference to one credential: a POSIX-style environment-variable name. */ diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index f236b0b2da..af92f17b10 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -8,7 +8,7 @@ ## 标识 -引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。 +引用以 POSIX 风格环境变量名命名一条凭据。brand 防止调用方将凭据引用与在包或进程之间传递的其他字符串混用;构造时校验 shell 标识符语法。 ```ts type-equiv /** Nominal reference to one credential: a POSIX-style environment-variable name. */ diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index fac61bde63..34d90d18f2 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.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/subsystems/filesystem.md -filesystem.md: e0edad514b0c4d9108b18cc0b472600b29024c51 -filesystem.zh.md: 81c2d87b6a96740c7a99449a27c16331f5a0645a +filesystem.md: 3c154af0fa4ee6d28f2379c5392dcc9b194a2c99 +filesystem.zh.md: 5b8a136af156c63de72e260d5859d8bac7b827c5 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index e0edad514b..3c154af0fa 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -4,7 +4,7 @@ English | [中文](filesystem.zh.md) The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) records observed presence or absence and adds freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. -The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. +`dsh-fs-policy` is optional. Without it, the `FileSystem` Service Definition, a provider, and the `dsh-tool-fs` Consumer form the complete, unconstrained filesystem seam: `write` unconditionally creates or overwrites, and `edit` unconditionally replaces literal text. The policy plugin changes these operations by deciding the `fs/*` waterfalls. Removing it does not break the tool because the tool calls `ctx.fs` and dispatches events; it does not call policy methods. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). @@ -113,7 +113,7 @@ interface FsDirEntry { ## Write and edit guards (provider contract) -Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit both use the same optional `expected` field. ```ts type-equiv /** @@ -196,15 +196,15 @@ type FsObservation = ## Execution context (policy plugin) -The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. +The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` has the required fields, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. ```ts type-equiv /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. @@ -220,7 +220,7 @@ interface FsPolicyExec { ## Read outcome (consumer / read rendering) -A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. +A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The result the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` directly with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. `dsh-tool-fs`, the executor that owns the read, implements read windowing and constructs this result; the policy plugin does not. ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 81c2d87b6a..5b8a136af1 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -4,7 +4,7 @@ 可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 记录观测到的存在或缺失状态,并通过事件(而非服务)添加新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。 -该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam(`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个插件,通过裁决 `fs/*` waterfall(瀑布式事件)在上层*叠加*策略;移除它只会暴露裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 +`dsh-fs-policy` 是可选插件。没有该插件时,`FileSystem` 服务定义、一个提供方和 `dsh-tool-fs` 消费方组成完整且不受约束的文件系统 seam:`write` 无条件创建或覆盖,`edit` 无条件替换字面文本。策略插件通过裁决 `fs/*` waterfall(瀑布式事件)来改变这些操作。移除该插件不会破坏工具,因为工具调用 `ctx.fs` 并分发事件,而不调用策略方法。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 提供方源码:[`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) 与 [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)。策略源码:[`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts)。读取渲染源码:[`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts)。 @@ -113,7 +113,7 @@ interface FsDirEntry { ## 写入与编辑守卫(提供方约定) -`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 +`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 都使用同一个可选的 `expected` 字段。 ```ts type-equiv /** @@ -196,15 +196,15 @@ type FsObservation = ## 执行上下文(策略插件) -策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 +策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 包含必需的字段,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 ```ts type-equiv /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. @@ -220,7 +220,7 @@ interface FsPolicyExec { ## 读取结果(消费方 / 读取渲染) -文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具以 stat 的版本 emit 表示存在的 `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带防护的写入可以重新创建外部删除的目标,但不会授权 edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 +文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具 emit 表示存在的 `fs/observed`,并直接携带 stat 的版本),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带守卫的写入可以重新创建外部删除的目标,但不会授权 edit。拥有读取操作的执行器 `dsh-tool-fs` 实现读取窗口化并构造该结果;策略插件不执行这些操作。 ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml index cdd932726e..f610740a21 100644 --- a/docs/subsystems/goal.i18n.yaml +++ b/docs/subsystems/goal.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/subsystems/goal.md -goal.md: 6f54a5261cb44c3fda389e37cb689a00061ab241 -goal.zh.md: ea5a0fe7923648aead5e6f54ab31162a7abdb954 +goal.md: 93837e15ee3244671cacaed26983e8e4ca38bd56 +goal.zh.md: 8ea138bc80a400120b065f616182acb0906c68dd diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md index 6f54a5261c..93837e15ee 100644 --- a/docs/subsystems/goal.md +++ b/docs/subsystems/goal.md @@ -2,7 +2,7 @@ English | [中文](goal.zh.md) -Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). +Types shared by the event-sourced goal service and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the exact fields and variants from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). ## Identity and lifecycle @@ -142,7 +142,7 @@ interface GoalChanged { ## Service behavior -[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. +[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) defines the callable API and model-visible contract. diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md index ea5a0fe792..8ea138bc80 100644 --- a/docs/subsystems/goal.zh.md +++ b/docs/subsystems/goal.zh.md @@ -2,7 +2,7 @@ [English](goal.md) | 中文 -事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。 +事件溯源目标服务及其策略消费方共享的类型。[目标领域 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的确切字段和变体。 ## 标识与生命周期 @@ -142,7 +142,7 @@ interface GoalChanged { ## 服务行为 -[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用约定和面向模型的约定。 +[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 定义可调用 API 和面向模型的约定。 diff --git a/docs/subsystems/http-server.i18n.yaml b/docs/subsystems/http-server.i18n.yaml index 6f61102676..4c3975588b 100644 --- a/docs/subsystems/http-server.i18n.yaml +++ b/docs/subsystems/http-server.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/subsystems/http-server.md -http-server.md: b9795fe98b432b6ef5f7d01a4d3e115c809fe642 -http-server.zh.md: 8d55c48ae2882bd471ca4e64e011c7d79c4ebb40 +http-server.md: 232755ba6b77ec940ebe2cb7f19fd962018300f1 +http-server.zh.md: 59778e6455d4f32e30c3e77c88cd76a2ecc1845f diff --git a/docs/subsystems/http-server.md b/docs/subsystems/http-server.md index b9795fe98b..232755ba6b 100644 --- a/docs/subsystems/http-server.md +++ b/docs/subsystems/http-server.md @@ -2,7 +2,7 @@ English | [中文](http-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the web-shape HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.httpServer`, a named-route registry, index.html transform taps, and a single claimable fallback seat. It is not part of the agent-loop spine and not a capability seam — it knows no harness concepts, and every feature surface (the `/api` bridge, plugin bundles, the HMR event stream) is a route some other plugin registers ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). Web (browser) shape only: Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.httpServer`, a named-route registry, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -42,7 +42,7 @@ interface Config { ## The service -`HttpServerService` (`ctx.httpServer`) listens immediately on activation; a listen failure (EADDRINUSE…) throws out of init — a FAILED fiber the boot's fail-loud sweep reports. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws, because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, the OS-assigned value when `config.port` is 0. +`HttpServerService` (`ctx.httpServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). @@ -58,7 +58,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. ```ts cordis-catalog /** @@ -104,5 +104,5 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/http-server.zh.md b/docs/subsystems/http-server.zh.md index 8d55c48ae2..59778e6455 100644 --- a/docs/subsystems/http-server.zh.md +++ b/docs/subsystems/http-server.zh.md @@ -2,7 +2,7 @@ [English](http-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主 web 形态的 HTTP 载体:单个提供 `ctx.httpServer` 的 `node:http` 插件,由具名路由注册表、index.html 转换挂点与单一可认领的回退席位组成。它不属于 agent loop(智能体循环)主干,也不是能力 seam:它不了解任何 harness 概念,每个功能表面(`/api` 桥接、插件 bundle、HMR(热模块替换)事件流)都是由其他插件注册的一条路由([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md))。仅限 web(浏览器)形态:Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,不经过本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.httpServer` 的 `node:http` 插件,包含具名路由注册表、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -42,7 +42,7 @@ interface Config { ## 服务 -`HttpServerService`(`ctx.httpServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会从 init 抛出,形成一个 FAILED fiber,由启动的大声失败 sweep 上报。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯的 html 到 html 转换,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退);[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,`config.port` 为 0 时读到的是操作系统分配的值。 +`HttpServerService`(`ctx.httpServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯的 html 到 html 转换,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退);[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包(package)从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.md) 中。 @@ -58,7 +58,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. ```ts cordis-catalog /** @@ -104,5 +104,5 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index dfb482ba7e..052ec4fedd 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/subsystems/llm-streaming.md -llm-streaming.md: 9f92052d411e3bd4256db63df54eba7f4e313b18 -llm-streaming.zh.md: ab5540c9e7adce70f0462fb478e8f674d1f6bba4 +llm-streaming.md: 8ae4e8b376b4c4221e6179eb719fcf162f3031e4 +llm-streaming.zh.md: 7d244ab882521a90217873fc3cdee12cd5232db8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 9f92052d41..8ae4e8b376 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -2,7 +2,7 @@ English | [中文](llm-streaming.zh.md) -The conversation and streaming vocabulary of [`packages/llm`](../../packages/llm/README.md): the `Message`/`ContentBlock` shapes every request and durable history share, the fully-assembled model request, the raw `StreamChunk` protocol, the adapter contract every adapter must obey, and the shared assembler. The [core spine](core.md) holds and logs these values on every turn; this page declares them. +The conversation and streaming types from [`packages/llm`](../../packages/llm/README.md): the `Message`/`ContentBlock` variants every request and durable history share, the fully assembled model request, the raw `StreamChunk` protocol, the adapter contract every adapter must implement, and the shared assembler. The [core packages](core.md) hold and log these values on every turn; this page declares them. Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) @@ -79,12 +79,12 @@ interface MessageSourceMap { } ``` -Producer identity and content shape are independent. `kind` answers *who produced this*; the optional `form` a producer mixes in answers *what shape of information it is*, so several producers may share one presentation and one producer may emit more than one shape over a session. The vocabulary is semantic and grows one value at a time; an absent or unrecognized value is the documented default, presented as opaque content: +Producer identity and presentation form are independent. `kind` answers *who produced this*; the optional `form` answers *what kind of information this is*, and consumers decide how to present it. Several producers may share one form, and one producer may emit more than one form over a session. The values are semantic and grow one at a time; an absent or unrecognized value uses the documented default and is presented as opaque content: ```ts type-equiv /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -126,10 +126,10 @@ interface ContextSnapshotSection { ```ts type-equiv /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ @@ -184,13 +184,13 @@ type StreamChunk = Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `providerRetryAfterMs` is a validated positive delay requested by the provider, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics. ```ts type-equiv -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -205,7 +205,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. -- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. +- **Two sanctioned error paths, one `LlmFailure` type.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. After the call selects its adapter, the stream preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered turn; direct `ctx.llm.stream()` callers remain single-attempt. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. @@ -215,7 +215,7 @@ Every adapter MUST obey these, and every consumer may rely on them: ## `ResolvedRetryPolicy` -Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the exact serving registration's captured value after that call enters its final adapter boundary, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) owns the optional input shapes. +Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects that registration, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields. ## `AppIdentity` — app attribution @@ -533,7 +533,7 @@ interface ToolSchema { } ``` -The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md). +The model-facing `ToolSchema` is the wire type; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md). A provider a surface is still drafting has no route and no catalog, so interrogation is described separately: the request carries the draft the user is editing, and the reply is candidates a surface may adopt rather than a catalog it must serve. @@ -590,7 +590,7 @@ The loop builds each request from logged state. `EpochHeader` records call confi `agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. -On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. +On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history. The logged request snapshot ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). @@ -652,8 +652,8 @@ interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ab5540c9e7..7d244ab882 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -2,7 +2,7 @@ [English](llm-streaming.md) | 中文 -[`packages/llm`](../../packages/llm/README.md) 的对话与流式输出词汇:每个请求与持久历史共享的 `Message`/`ContentBlock` 形状、完整组装的模型请求、原始 `StreamChunk` 协议、每个适配器必须遵守的适配器约定(adapter contract),以及共享的 assembler。[核心主干](core.md)在每个轮次持有并记录这些值;本页声明它们。 +[`packages/llm`](../../packages/llm/README.md) 提供对话与流式输出类型:每个请求和持久历史共用的 `Message`/`ContentBlock` 变体、完整组装的模型请求、原始 `StreamChunk` 协议、每个适配器必须实现的适配器约定(adapter contract),以及共享的 assembler。[核心包](core.md)在每个轮次持有并记录这些值;本页声明它们。 源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) @@ -79,12 +79,12 @@ interface MessageSourceMap { } ``` -生产方标识与内容形态相互独立。`kind` 回答「由谁产生」;生产方可选混入的 `form` 回答「这是何种形态的信息」,因此多个生产方可以共用一种呈现,一个生产方在一次会话中也可以发出多种形态。该词汇表是语义的,逐个取值增长;未声明或无法识别的取值是有文档的默认,按不透明内容呈现: +生产方标识与呈现形式相互独立。`kind` 回答「由谁产生」;可选的 `form` 回答「这是什么类型的信息」,消费方决定如何呈现。多个生产方可以共用一种 `form`,一个生产方在一次会话中也可以发出多种 `form`。这些取值描述语义,并逐个增加;未声明或无法识别的值使用文档规定的默认值,按不透明内容呈现: ```ts type-equiv /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -126,10 +126,10 @@ interface ContextSnapshotSection { ```ts type-equiv /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ @@ -188,13 +188,13 @@ type StreamChunk = 每个抛出的失败或最终适配器的带内失败都会规范化为一种可序列化、提供方无关的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 ```ts type-equiv -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -209,7 +209,7 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **两条受支持的错误路径,共用一个 `LlmFailure` 类型。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。调用选定适配器后,流会保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的轮次;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 @@ -219,7 +219,7 @@ interface LlmFailure { ## `ResolvedRetryPolicy` -提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用进入最终适配器边界后,`llmRetryPolicyOf(stream)` 返回为其提供服务的确切注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选输入形状由[生成的配置目录](../config-catalog.md)规定。 +提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用选定该注册后,`llmRetryPolicyOf(stream)` 返回为该调用服务的注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。 ## `AppIdentity`:应用归属 @@ -541,7 +541,7 @@ interface ToolSchema { } ``` -面向模型的 `ToolSchema` 是协议格式;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 +面向模型的 `ToolSchema` 是协议类型;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 界面正在起草的提供方既没有路由也没有 catalog,因此询问被单独描述:请求携带用户正在编辑的草稿,回复是界面可以采纳的候选,而不是它必须服务的 catalog。 @@ -598,7 +598,7 @@ interface LlmDiscoveredModel { `agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 +在协议中,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史。已记录的请求快照会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 @@ -660,8 +660,8 @@ interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 9e0a373532..65925a1608 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.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/subsystems/persistence.md -persistence.md: 8f6872b77be8c7ae273e0fc1887dca30dbe1eb37 -persistence.zh.md: 25e72a69bd03cd5cc0715ae769055062466397dd +persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 +persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 8f6872b77b..0266d17393 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -211,7 +211,7 @@ interface SessionPersistenceSnapshot { Both implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row fields `(session_id, seq, type, time, data, source_event_seqs, surface_op)` map 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 25e72a69bd..ced8344016 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -211,7 +211,7 @@ interface SessionPersistenceSnapshot { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行字段 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 2286bad3fd..ccf85341d1 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.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/subsystems/plan.md -plan.md: 29a2473c440b654422c4c563820efa72847a820a -plan.zh.md: bcb327719102b90c1c7009d78cee0fa930cdc7a8 +plan.md: 749f1052119f25e34dcfa8e6939a97037dc3b6dc +plan.zh.md: 31a402651f5c15b9c0cc264a7a51a020b4e63d60 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 29a2473c44..749f105211 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -2,7 +2,7 @@ English | [中文](plan.zh.md) -Plan mode is logged per-agent collaboration state owned by [dsh-plan-mode](../../packages/plan/plan-mode) (`ctx.planMode`, `PlanModeService`): while active, a deployment-owned guidance section shapes each model request. It is **soft guidance**, deliberately independent of the [sandbox mode](sandbox.md) and [approval policy](approval.md) enforcement axes — those knobs never read or write plan state, and deployments needing a hard boundary combine them separately. The package is one optional capability, not part of the agent-loop spine; its surfaces are the `plan:policy` prompt section, the always-registered `exit_plan_mode` tool, and the `/plan` command. The [design note](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md) owns the rationale; the [package README](../../packages/plan/plan-mode/README.md) owns the model-experience and limitation detail. +Plan mode is logged per-agent collaboration state owned by [dsh-plan-mode](../../packages/plan/plan-mode) (`ctx.planMode`, `PlanModeService`): while active, a deployment-owned guidance section is included in each model request. Plan mode is **soft guidance**. [Sandbox mode](sandbox.md) and [approval policy](approval.md) enforce restrictions independently; neither reads or writes plan state, so deployments configure them separately. The package is optional, and the agent loop does not depend on it. It contributes the `plan:policy` prompt section and registers the `exit_plan_mode` tool and `/plan` command. The [design note](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md) owns the rationale; the [package README](../../packages/plan/plan-mode/README.md) owns the model-experience and limitation detail. Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/src/index.ts) @@ -10,11 +10,11 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. `foldPlanMode(events, end?)` returns the last logged value in the prefix, or `false` when there is none — the state in force is always a pure fold of the session log, so resume, fork, and compaction recover it with no live mirror, and UIs observe committed flips through `session/event`. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). -## Pending intent and the step-boundary flush +## Pending selections and the pre-step append -Because every session event is turn-enclosed, a user selection is held as pending intent until the next step boundary — the next request derivation, in whichever turn it occurs (selection never forces continuation, so an intent recorded after a turn's final step lands in a later turn). `set(agent, active)` records the pending selection (a no-op when the target equals the logged-or-already-pending state), and `get(agent)` returns `{ active: boolean; pending?: boolean }` — the logged state shaping the current step, plus the optimistic selection awaiting a boundary. +Because every session event is turn-enclosed, a user selection remains pending until the next accepted in-turn pre-step appends it before request derivation, in whichever turn that occurs. A selection never forces continuation, so one made after a turn's final accepted pre-step is appended in a later turn. `set(agent, active)` records the pending selection (a no-op when the target equals the logged-or-already-pending state), and `get(agent)` returns `{ active: boolean; pending?: boolean }`: the logged state used to assemble the current step plus the selected state waiting to be appended. -The sole flush point is a prepended `agent/step` listener — the loop's in-turn interception point that runs before every request derivation, including turn 1 step 1 and request-recovery retries. Prompt admission itself never flushes: it happens pre-turn, where a `plan/mode` append would land outside any open turn, so a selection made at the prompt is landed by the first step boundary inside the turn it starts. The prepend means the flush runs before the downstream `agent/step` listener chain. A flush failure is contained — plan policy can never block a turn — and the failed append stays pending for a later boundary. A flushed user selection also narrates the switch as one plugin-sourced `user/message` notice, but only when the last logged request header described the other state, so the model is told exactly when its context changed and never redundantly. A pending selection made while idle is process-local and lost on exit before the next boundary ([README limitation](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work)). +The only append point while an agent is running is a prepended `agent/pre-step` listener. It observes every proposed request step, including turn 1 step 1 and request-recovery retries, calls downstream listeners first, and appends only after they accept the step. Prompt admission happens before a turn and cannot append `plan/mode`, so a selection made at the prompt is appended by the first accepted in-turn pre-step of the turn it starts. An append failure cannot block the turn, and the selection remains pending for a later accepted in-turn pre-step. An appended user selection also records one plugin-sourced `user/message` notice, but only when the last logged request header described the other state, so the model is told exactly when its context changed and never redundantly. A selection made after a turn's final accepted pre-step remains process-local and is lost if the process exits before another accepted in-turn pre-step ([README limitation](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work)). ## Configuration @@ -26,17 +26,17 @@ interface PlanModeConfig { } ``` -A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than silently shaping nothing. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at order 50; inactive plan mode contributes no text. +A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than being ignored. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at order 50; inactive plan mode contributes no text. ## The exit tool and the `/plan` command -[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so crossing the boundary changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-interaction seam](user-interaction.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that flushes after the step — plan guidance holds for the rest of the assistant's tool batch, and the tool result itself narrates the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. +[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-interaction seam](user-interaction.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. -When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive — which also cancels a not-yet-flushed pending entry before plan mode ever reaches a request. +When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive, which also cancels a pending entry before it is appended and becomes visible to a request. ## The service -`ctx.planMode` owns the logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodeservice). +`ctx.planMode` owns the logged plan state, applies and narrates selected state at step start, and owns the `plan:policy` section, the `/plan` command, and the stable exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodeservice). @@ -50,11 +50,12 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeService` -`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. ```ts cordis-catalog /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -62,25 +63,25 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index bcb3277191..31a402651f 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -2,7 +2,7 @@ [English](plan.md) | 中文 -计划模式是 [dsh-plan-mode](../../packages/plan/plan-mode) 拥有的、记录到日志的逐 agent(智能体)协作状态(`ctx.planMode`,`PlanModeService`):激活期间,一段部署持有的指引段落会影响每个模型请求。它是**软性指引**,有意独立于[沙箱模式](sandbox.md)与[审批策略](approval.md)这两条强制执行轴:那些旋钮(knob)从不读写计划状态,需要硬边界的部署另行组合两者。该包(package)是一项可选能力,不属于 agent loop(智能体循环)主干;它的对外表面是 `plan:policy` 提示词段落、始终保持注册的 `exit_plan_mode` 工具和 `/plan` 命令。[设计说明](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)负责决策依据;[包 README](../../packages/plan/plan-mode/README.md) 负责模型体验与限制细节。 +计划模式是 [dsh-plan-mode](../../packages/plan/plan-mode) 拥有的、记录到日志的逐 agent(智能体)协作状态(`ctx.planMode`,`PlanModeService`):激活期间,每个模型请求都会包含一段部署持有的指引。计划模式是**软性指引**。[沙箱模式](sandbox.md)与[审批策略](approval.md)分别强制限制;两者都不读写计划状态,因此部署需要分别配置它们。该包(package)是可选项,agent loop(智能体循环)不依赖它。它贡献 `plan:policy` 提示词段落,并注册 `exit_plan_mode` 工具和 `/plan` 命令。[设计说明](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)负责决策依据;[包 README](../../packages/plan/plan-mode/README.md)负责模型体验与限制细节。 源码:[`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/src/index.ts) @@ -10,11 +10,11 @@ `plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.md):持久且可回放,绝不进入模型 transcript(文本记录)。`foldPlanMode(events, end?)` 返回前缀中最后一条已记录值,没有时返回 `false`:生效状态始终是会话日志的纯折叠,因此恢复、fork 与压缩(compaction)无需实时镜像即可将其复原,UI 通过 `session/event` 观察已提交的切换。完整事件声明见[持久化日志事件目录](../persistence-catalog.md)。 -## 待定意图与步骤边界冲刷 +## 待生效选择与 pre-step 追加 -由于每个会话事件都位于轮次之内,用户的选择会作为待定意图保留到下一个步骤边界——即下一次请求派生,落在哪个轮次就在哪个轮次生效(选择绝不强制续行,因此在某轮最后一步之后记录的意图会在之后的轮次落地)。`set(agent, active)` 记录待定选择(目标值与已记录或已在待定中的状态相同时不做任何事),`get(agent)` 返回 `{ active: boolean; pending?: boolean }`,即影响当前步骤的已记录状态,加上正在等待边界的乐观选择。 +由于每个会话事件都位于轮次之内,用户选择会保持待生效状态,直到下一个被接受的轮内 pre-step 在派生请求之前追加该选择,无论该 pre-step 位于哪个轮次。选择不会强制续行,因此在某轮最后一个被接受的 pre-step 之后作出的选择会在之后的轮次追加。`set(agent, active)` 记录待生效选择(目标值与已记录或已在等待的状态相同时不做任何事),`get(agent)` 返回 `{ active: boolean; pending?: boolean }`:用于组装当前步骤的已记录状态,以及等待追加的已选状态。 -唯一的冲刷点是一个前置(prepend)注册的 `agent/step` 监听器——agent loop 的轮内拦截点,在每次请求派生之前运行,包括第 1 轮第 1 步和请求恢复重试。提示词提交本身绝不冲刷:它发生在轮次开启之前,此时追加 `plan/mode` 会落在任何开启的轮次之外,因此在提示词处做出的选择由它开启的轮次内的第一个步骤边界落地。前置注册意味着冲刷先于下游的 `agent/step` 监听器链运行。冲刷失败会被收容(计划策略绝不能阻塞轮次),追加失败的选择保持待定,等待后续边界。已冲刷的用户选择还会以一条插件来源的 `user/message` 通知叙述这次切换,但仅当最后记录的请求头描述的是另一种状态时才叙述,因此模型恰好在上下文变化时被告知,且绝不重复。空闲时做出的待定选择只存在于进程内,进程在下一个边界之前退出即丢失([README 限制](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work))。 +agent 运行时,唯一的追加点是前置(prepend)注册的 `agent/pre-step` 监听器。它会观察每个候选请求步骤,包括第 1 轮第 1 步和请求恢复重试;它先调用下游监听器,只在下游接受该步骤后追加。提示词提交发生在轮次开启之前,无法追加 `plan/mode`,因此在提示词处作出的选择由它开启的轮次内第一个被接受的 pre-step 追加。追加失败不能阻塞轮次,且该选择会继续等待之后被接受的轮内 pre-step。追加用户选择时还会记录一条插件来源的 `user/message` 通知,但仅当最后记录的请求头描述的是另一种状态时才记录,因此模型恰好在上下文变化时收到通知,且绝不重复。在某轮最后一个被接受的 pre-step 之后作出的选择只存在于进程内;如果进程在另一个被接受的轮内 pre-step 之前退出,该选择会丢失([README 限制](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work))。 ## 配置 @@ -26,17 +26,17 @@ interface PlanModeConfig { } ``` -`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是静默地不产生任何指引。计划模式激活期间,确切的 `section` 文本以 order 50 渲染为 `plan:policy` [系统提示词段落](system-prompt.md);未激活的计划模式不贡献任何文本。 +`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是被忽略。计划模式激活期间,确切的 `section` 文本以 order 50 渲染为 `plan:policy` [系统提示词段落](system-prompt.md);未激活的计划模式不贡献任何文本。 ## 退出工具与 `/plan` 命令 -[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此跨越边界只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-interaction.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待定退出,在该步骤之后冲刷:计划指引在 assistant 本批工具调用的剩余部分继续生效,而工具结果本身叙述这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 +[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-interaction.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待定退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 -当 [`ctx.commands`](commands.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在计划模式尚未进入任何请求之前,取消尚未冲刷的待定条目。 +当 [`ctx.commands`](commands.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。 ## 服务 -`ctx.planMode` 拥有已记录的计划状态、边界处的应用与叙述、`plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodeservice)。 +`ctx.planMode` 拥有已记录的计划状态,在步骤开始时应用并叙述选中的状态,还拥有 `plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodeservice)。 @@ -50,11 +50,12 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeService` -`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. ```ts cordis-catalog /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -62,25 +63,25 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index e3e42cdc9e..bd07ed71fb 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/subsystems/session-projection.md -session-projection.md: 4cbe0babb22406f7a48f0c19e982bb4757b4f44d -session-projection.zh.md: 5eada67a6eed914021e284fc5eabf203125b4b83 +session-projection.md: 6fdcfc64a5265c36f4396d91ee689c5f1cd1da18 +session-projection.zh.md: 3ca65ba40be32ed11e319c05410c035683f46488 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 4cbe0babb2..6fdcfc64a5 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -45,7 +45,7 @@ interface ProjectionDefinition { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -86,7 +86,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous — a carrier reads it in the same tick as its page slice, which is what makes `asOfSeq` one consistent cut — and every value passes its unit's schema before leaving (an accidentally-async `view` returns a Promise, which fails that boundary parse loudly). The change feed fires once per unit whose state *reference* changed, per committed event: the same-reference discipline in `apply` is the gate. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. Every value passes its unit's schema before return; an accidentally async `view` returns a Promise, which schema validation rejects. The change feed fires once per unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register(definition: ProjectionDefinition): () => void diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 5eada67a6e..3ca65ba40b 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -45,7 +45,7 @@ interface ProjectionDefinition { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -86,7 +86,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 是完全同步的:载体在切出页面切片的同一 tick 内读取它,`asOfSeq` 之所以是一个一致切面正系于此;且每个值在离开前都要经过其单元的 schema 校验(误写成异步的 `view` 会返回 Promise,让这道边界解析当场大声失败)。变更流对每个已提交事件、每个状态*引用*发生变化的单元各触发一次:`apply` 的同引用纪律就是那道闸门。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。每个值在返回前都会通过其单元的 schema 校验;如果 `view` 被误写为异步函数,它会返回 Promise,schema 校验将拒绝该值。对于每个已提交事件,变更流会为每个状态*引用*已变化的单元触发一次;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register(definition: ProjectionDefinition): () => void diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index 4ba7a3392b..e69b3ef46f 100644 --- a/docs/subsystems/session-query.i18n.yaml +++ b/docs/subsystems/session-query.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/subsystems/session-query.md -session-query.md: 3b6d99af58ffb7c3e7b58cc4a3e9143732382861 -session-query.zh.md: c23e9ad53ef6a3e4a8e50a892824068f33e4e15b +session-query.md: 7a414c89124dde9972396f77281f50bf1643d716 +session-query.zh.md: e8b98709c98bbd013730d4bf3c9807a418680d7d diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 3b6d99af58..7a414c8912 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -456,7 +456,7 @@ async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFi /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise @@ -465,7 +465,7 @@ async readSurface(sessionId: SessionId): Promise * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index c23e9ad53e..e8b98709c9 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -456,7 +456,7 @@ async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFi /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise @@ -465,7 +465,7 @@ async readSurface(sessionId: SessionId): Promise * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index a73c858289..a7f5e0fe0b 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.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/subsystems/session-reference.md -session-reference.md: f539a59b8d26182aff9746b6d6a39a86ba15cb45 -session-reference.zh.md: 1035614141936af1f94bdabe35b3104be319a762 +session-reference.md: 29fd85c74c54b0542a241b02cd31fc6c93012ae8 +session-reference.zh.md: 2a551de5d7bd08ed70b56035b617b2d69c9d6244 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index f539a59b8d..29fd85c74c 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -2,7 +2,7 @@ English | [中文](session-reference.zh.md) -Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) owns canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. +Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 1035614141..2a551de5d7 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -2,7 +2,7 @@ [English](session-reference.md) | 中文 -结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 +结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index e9d7a2f936..7ba2ae289d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/subsystems/session.md -session.md: 6fb0cec4fd222ceafbd5b4111fe56f22505058ad -session.zh.md: d33a71e92e2bd9fd9fb7e9194255b7c1f5f0af77 +session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df +session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 6fb0cec4fd..0b78e51ebf 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -345,7 +345,7 @@ interface SurfaceFoldResult { ## `Session` public API -The body-stripped declaration keeps the plain class's detached factory, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` section](#ctxsessions--sessionstore). +The body-stripped declaration keeps the plain class's detached factory, state accessors, append method, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` section](#ctxsessions--sessionstore). ```ts public-api /** @@ -404,8 +404,8 @@ declare class Session { static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d33a71e92e..d1e91f684a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -177,7 +177,7 @@ interface EpochHeader { ### 路由容量事件:`request/context` -请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是由 `headerEquals` 逐字段比较的重建约定:容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 +请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是 `headerEquals` 逐字段比较的重建约定。容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 ```ts type-equiv /** Registration-bound metadata for one resolved model route. */ @@ -347,7 +347,7 @@ interface SurfaceFoldResult { ## `Session` 公共 API -去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 小节](#ctxsessions--sessionstore)记录。 +去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、append 方法和历史投影。存储操作仍由生成的 [`ctx.sessions` 小节](#ctxsessions--sessionstore)记录。 ```ts public-api /** @@ -406,8 +406,8 @@ declare class Session { static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index a257e0e33e..3fa99a4ecf 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.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/subsystems/settings.md -settings.md: 9256bf9436d2e77093fc8c6a3728b62fc4e8d67f -settings.zh.md: 720eb9c2718c1fa14a148cced806c224634dba69 +settings.md: fc10f72c0ed59a982817eb65ecc410e039a2cbb3 +settings.zh.md: 96bb2d8b0cd65e1ebd812e000fe3b96e8b315493 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 9256bf9436..fc10f72c0e 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -8,7 +8,7 @@ Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/sett ## Identity -A namespace names one plugin-owned section of the user document. The brand keeps namespaces from mixing with other cross-boundary ids; construction validates the lowercase kebab-case shape. +A namespace names one plugin-owned section of the user document. The brand prevents callers from mixing settings namespaces with other ids passed between packages or processes; construction validates lowercase kebab-case syntax. ```ts type-equiv /** Nominal id of one registered settings namespace. */ @@ -79,14 +79,14 @@ interface SettingsScope { watch(callback: (next: T, prev: T) => void | Promise): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 720eb9c271..96bb2d8b0c 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -8,7 +8,7 @@ ## 标识 -namespace 命名用户文档中一个归插件所有的分节。brand 使其不与其他跨边界 id 混用;构造时校验小写 kebab-case 形态。 +namespace 命名用户文档中一个归插件所有的分节。brand 防止调用方将设置 namespace 与在包或进程之间传递的其他 id 混用;构造时校验小写 kebab-case 语法。 ```ts type-equiv /** Nominal id of one registered settings namespace. */ @@ -79,14 +79,14 @@ interface SettingsScope { watch(callback: (next: T, prev: T) => void | Promise): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 9b3d8a19d6..1aaffa63cc 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.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/subsystems/storage.md -storage.md: 3234fdf9587dc2853b4f9ec4205d230aae4b633a -storage.zh.md: fb13ec96099f2173478aecf1b91799237755414c +storage.md: fd5a8d7eaeb545ef307434b211556633bba8b503 +storage.zh.md: 4ce5ebb2ad59315d2a60d57de087f3050c323722 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 3234fdf958..fd5a8d7eae 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -29,10 +29,10 @@ interface StorageForms {} /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional data-shape facets; `kv` is the sole facet. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, format version, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects `version-mismatch`; one that cannot be parsed as the unit rejects `malformed-medium` (no migration, pre-release stance). [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) asserts every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores document-per-row in one database, the route for high-churn domains. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only group today. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, format version, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects `version-mismatch`; one that cannot be parsed as the unit rejects `malformed-medium` (no migration, pre-release stance). [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index fb13ec9609..4ce5ebb2ad 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -29,10 +29,10 @@ interface StorageForms {} /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并暴露可选的数据形状 facet;`kv` 是唯一的 facet。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、格式版本、表名清单,以及是否存在全局单例槽位——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。介质上记录的版本与之不同时拒绝 `version-mismatch`;无法按该 unit 解析的介质拒绝 `malformed-medium`(不做迁移:预发布立场)。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件对每个后端断言其中每一条款。[json 后端](../../packages/storage/storage-json/README.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.md)在单个数据库中按一行一文档存储,是高频更新领域的路由选择。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;目前 `kv` 是唯一一组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、格式版本、表名清单,以及是否存在全局单例槽位——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。介质上记录的版本与之不同时拒绝 `version-mismatch`;无法按该 unit 解析的介质拒绝 `malformed-medium`(不做迁移:预发布立场)。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e98438900d..ddbc9ec3db 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subsystems/subagent.md -subagent.md: 99c54e0696c9c3585c50285ecb69b14c90d83c11 -subagent.zh.md: c5a79247a81f5174662f2b5d1ed2d3c20cf6c672 +subagent.md: 961a16f58cb936205290d23ce6b9d94cb94607d5 +subagent.zh.md: ac97d27d9824ca62701e11ae99264adbddafbbd8 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..961a16f58c 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -2,7 +2,7 @@ English | [中文](subagent.zh.md) -The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. +The subagent seam lets an agent delegate work to a child agent. Like [bash](bash.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service providers are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). @@ -113,7 +113,7 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## Continuable children and activations -A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. +A **continuable background subagent** is one durable child Session with at most one process-local **Activation**, the period when a reconstructed child Agent is resident. An Activation is not a request, result, cancellation, or Task: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. ```text persisted Session @@ -299,8 +299,9 @@ interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..ac97d27d98 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -2,7 +2,7 @@ [English](subagent.md) | 中文 -subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 +subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service provider 是六个兄弟包:`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 @@ -113,7 +113,7 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## 可继续子 agent 与激活 -**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 +**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**,即被重建的子 Agent 处于驻留状态的时段。Activation 不是请求、结果、取消或 Task:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 ```text persisted Session @@ -299,8 +299,9 @@ interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index 91ff1b49d6..c24ae31019 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.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/subsystems/system-prompt.md -system-prompt.md: 5397858ea9991efad06e045118b96a90386f2285 -system-prompt.zh.md: defd8fae73834ba543ae1f45d15ca4253a5abe40 +system-prompt.md: bdc0e994fb8e784a19814574c405d8cc3dce2d11 +system-prompt.zh.md: db6932b18f4721020fed567d49727f863eb06608 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 5397858ea9..bdc0e994fb 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -2,7 +2,7 @@ English | [中文](system-prompt.zh.md) -The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page pins the literal cross-package shapes that plugins implement or pass. +The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page records the exact cross-package types that plugins implement or pass. Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts). diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index defd8fae73..db6932b18f 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -2,7 +2,7 @@ [English](system-prompt.md) | 中文 -[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 +[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录注册、排序、作用域与渲染行为;本页记录各插件实现或传递的确切跨包类型。 源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index c229d0a5c0..5956c1fc4f 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.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/subsystems/tasks.md -tasks.md: d3e92891a6736a85519b97775ebe7aa6f12a5ae2 -tasks.zh.md: fe54d7a6482743a8f2ef31b143edb25afe0a478b +tasks.md: de331045d6cbec64c1305b0a20f0c821e9578469 +tasks.zh.md: f33da2a7d0e09d094110c89f88b6f7508400da9b diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index d3e92891a6..de331045d6 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -2,7 +2,7 @@ English | [中文](tasks.zh.md) -Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). +Types shared by long-running producers, `ctx.tasks`, and task controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). ## Ids and status @@ -57,7 +57,7 @@ interface TaskStart { } ``` -`TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. +`TaskHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -151,7 +151,7 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` listeners, and when `attachSurface` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index fe54d7a648..f33da2a7d0 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -2,7 +2,7 @@ [English](tasks.md) | 中文 -长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。 +长时间运行的生产方、`ctx.tasks` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的确切字段和变体。 ## ID 与状态 @@ -57,7 +57,7 @@ interface TaskStart { } ``` -`TaskHooks.done` 是完全停稳边界。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 +`TaskHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -151,7 +151,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部提供方。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index c994c46baa..f5cda71a1d 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.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/subsystems/telemetry.md -telemetry.md: 93869f2344eeedfee344735191af842fef188886 -telemetry.zh.md: 50642f1ed917ea52ee152e333f70753c12fba583 +telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb +telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 93869f2344..5ea5c67210 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -2,7 +2,7 @@ English | [中文](telemetry.zh.md) -Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md). +Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts. Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -60,9 +60,8 @@ Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-sta ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -77,8 +76,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -105,7 +104,7 @@ interface TelemetryBackend { } ``` -`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side. +`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture. ## The redact waterfall: `telemetry/record` @@ -123,7 +122,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -142,7 +141,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index 50642f1ed9..bd8fc8acc4 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -2,7 +2,7 @@ [English](telemetry.md) | 中文 -对外的会话上报拆分为一项[能力 seam](../capability-seams.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。 +对外会话上报是一项[能力 seam](../capability-seams.md):其 Service Definition([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)和 handoff 游标;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队和丢失策略。[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定。 源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -60,9 +60,8 @@ interface TelemetryRecord { ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -77,8 +76,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -105,7 +104,7 @@ interface TelemetryBackend { } ``` -`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常;后端在其构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧。 +`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常。后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理。 ## 脱敏 waterfall:`telemetry/record` @@ -123,7 +122,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -142,7 +141,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index ed9d60978f..3f7e33a795 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.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/subsystems/tools.md -tools.md: 692bafa02e37e1c1918fda31c766ad32f7c7cdba -tools.zh.md: 81aabddd20e2a0d09d904f4f8521d65c2622351f +tools.md: f8d86704a2237219530c8c23b46a68383458e1cf +tools.zh.md: 87269e5532b0cdfb0a38501d7b98c9986665df1d diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 692bafa02e..f8d86704a2 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -2,7 +2,7 @@ English | [中文](tools.zh.md) -The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine; the model-facing [`ToolSchema`](llm-streaming.md#the-model-request-and-result) wire shape is declared with the model request. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary. +The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the pipeline-authoring type shared by the core packages; the model-facing [`ToolSchema`](llm-streaming.md#the-model-request-and-result) wire type is declared with the model request. This page documents every `ToolDefinition` field, the typed schema DSL that builds it, the guarded execution types, and the UI-presentation types. Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) @@ -148,7 +148,7 @@ type InferArgs = InferProperties `defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue`. Schema records contain only own enumerable string keys, and schema arrays are dense intrinsic arrays, so inference, compilation, and validation observe the same declaration. Inference stays exact through 16 container levels and then widens to `JsonValue`; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement. -Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire. +Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` constructs the model-facing projection when building a request, so execution and presentation share one resolved definition without leaking callbacks onto the wire. ## `ToolRestriction` — one scope's live global filter @@ -248,7 +248,7 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may reshape the durable event's copy of the content (the program's value and the model contract are untouched): +Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may change the durable event's copy of the content (the program's value and model-visible result remain untouched): ```ts type-equiv /** @@ -306,7 +306,7 @@ interface ToolDispatchExecution extends Omit { `ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A `ToolDispatchExecution` wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity. -A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. +A `ToolGuard` is scope-aware final pre-dispatch policy. Its return type deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. ```ts type-equiv /** @@ -416,7 +416,7 @@ type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'bo ```ts type-equiv /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ interface JsonSchemaNode { @@ -565,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) @@ -590,23 +590,24 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) #### `tools/code-dispatch-log` — waterfall -Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -616,7 +617,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) @@ -709,5 +710,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 81aabddd20..87269e5532 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -2,7 +2,7 @@ [English](tools.md) | 中文 -[dsh-tools](../../packages/core/tools) 的工具流水线。[core.md](core.md) 介绍了 `ToolDefinition`(唯一被提升到主干的流水线编写类型);面向模型的 [`ToolSchema`](llm-streaming.md#the-model-request-and-result) 协议格式(wire format)形状与模型请求一起声明。本页拥有完整的 `ToolDefinition`、用于构建它的类型化 schema DSL、受保护的执行形状,以及 UI 展示词汇。 +[dsh-tools](../../packages/core/tools) 的工具处理流程。[core.md](core.md) 介绍了核心包共用的流程编写类型 `ToolDefinition`;面向模型的 [`ToolSchema`](llm-streaming.md#the-model-request-and-result) 协议类型与模型请求一起声明。本页记录 `ToolDefinition` 的每个字段、用于构建它的类型化 schema DSL、带守卫的执行类型和 UI 展示类型。 源码:[`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) @@ -148,7 +148,7 @@ type InferArgs = InferProperties `defineTool({ name, description, parameters, output, execute, … })` 将参数推导与 `parameterSchemaSpecToJsonSchema()` 和 `validateArgs()` 绑定,并将 `execute`/`render`/`presentationMeta` 与 `InferValue` 绑定。Schema 记录只包含自有且可枚举的字符串键,schema 数组是稠密的内建数组,因此推导、编译与校验观察到的是同一份声明。精确推导保持到 16 层容器,之后放宽为 `JsonValue`;运行时校验仍会继续遍历完整 schema。`valueSchemaSpecToJsonSchema()` 通过同一套已强制执行的原始子集编译输出声明。参数不匹配时抛出 `ToolArgsError`(`INVALID_ARGS`);函数体或后置策略产生的值无效时抛出 `ToolOutputError`(`INVALID_TOOL_OUTPUT`)。两者都经由常规工具错误路径处理。原始 JSON Schema 默认保持开放;不支持的关键字会被拒绝,而不会在未强制执行的情况下获准进入。 -注册是一个受信任的同进程约定。注册表以 readonly 输入借用类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在模型边界处物化显式的面向模型投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 +注册是一项受信任的同进程约定。注册表以 readonly 输入借用已类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在构建请求时生成面向模型的投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 ## `ToolRestriction` — 单个作用域的实时全局过滤器 @@ -248,7 +248,7 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以改写持久事件所存的内容副本(程序取得的值与模型约定均不受影响): +Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以更改持久事件所存的内容副本(程序取得的值和模型可见结果均不受影响): ```ts type-equiv /** @@ -306,7 +306,7 @@ interface ToolDispatchExecution extends Omit { `ToolExecutionToken` 是不透明的运行时 `Symbol`,仅用于身份比较。策略执行前,`execute()` 会物化并冻结参数、拒绝非 JSON 输入并分配 token。身份字段、调用方必需的 signal 和可选的 parent token 均保持 readonly。`ToolDispatchExecution` 包装层可以替换 signal 但不能移除;注册表会在调用工具函数体前重新融合调用方的 signal。最终观察者接收冻结的执行身份。 -`ToolGuard` 是感知作用域的最终预分派策略。其形状有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 +`ToolGuard` 是感知作用域的最终预分派策略。其返回类型有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 ```ts type-equiv /** @@ -416,7 +416,7 @@ type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'bo ```ts type-equiv /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ interface JsonSchemaNode { @@ -565,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) @@ -590,23 +590,24 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) #### `tools/code-dispatch-log` — waterfall -Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -616,7 +617,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) @@ -709,5 +710,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/user-interaction.i18n.yaml b/docs/subsystems/user-interaction.i18n.yaml index 296bc8b766..0ea5d31f17 100644 --- a/docs/subsystems/user-interaction.i18n.yaml +++ b/docs/subsystems/user-interaction.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/subsystems/user-interaction.md -user-interaction.md: a19155ae06af0133ae468c004b2e3b66f1de3fb8 -user-interaction.zh.md: c7aa9cafc9865191cda4089ad2beb214a33b00ef +user-interaction.md: 1ba4372d2c8ce2d8eeab46ce3aa4acdb11092ad3 +user-interaction.zh.md: 3e7e92d90216516ce1e38c69af88ed71701f1843 diff --git a/docs/subsystems/user-interaction.md b/docs/subsystems/user-interaction.md index a19155ae06..1ba4372d2c 100644 --- a/docs/subsystems/user-interaction.md +++ b/docs/subsystems/user-interaction.md @@ -8,7 +8,7 @@ Source: [`packages/interaction/user-interaction/src/index.ts`](../../packages/in ## Question options -`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. +`AskUserQuestionOption` contains one selectable choice. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. ```ts type-equiv /** One selectable answer offered to the user. */ @@ -22,15 +22,15 @@ interface AskUserQuestionOption { ## Presentation intent -`AskUserQuestionIntent` is the optional declaration that a question IS a decision of a known shape. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent shapes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads one answer shape either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. +`AskUserQuestionIntent` optionally declares a known decision kind. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent changes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads the same answer fields either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. ```ts type-equiv /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/docs/subsystems/user-interaction.zh.md b/docs/subsystems/user-interaction.zh.md index c7aa9cafc9..3e7e92d902 100644 --- a/docs/subsystems/user-interaction.zh.md +++ b/docs/subsystems/user-interaction.zh.md @@ -8,7 +8,7 @@ ## 问题选项 -`AskUserQuestionOption` 是可选择项的形状。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 +`AskUserQuestionOption` 包含一个可供选择的选项。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 ```ts type-equiv /** One selectable answer offered to the user. */ @@ -22,15 +22,15 @@ interface AskUserQuestionOption { ## 呈现意图 -`AskUserQuestionIntent` 是一项可选声明:某个问题本身就是一次已知形状的决定。它按 `kind` 打标签,因此意图可以扩充;不认识某个标签的 UI 渲染通用选项列表。意图只塑造呈现 —— 遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名肯定选项,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上。 +`AskUserQuestionIntent` 可选地声明一种已知的决定类型。它按 `kind` 打标签,因此可以增加新的意图;不认识某个标签的 UI 渲染通用选项列表。意图只改变呈现方式——遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的回答字段相同。`approve` 指名肯定选项,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上。 ```ts type-equiv /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index 8a11d774e6..bf0eef9ce4 100644 --- a/docs/subsystems/web.i18n.yaml +++ b/docs/subsystems/web.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/subsystems/web.md -web.md: 2a7cc499b42dd8b45ce6783a35a2fadd8df1183d -web.zh.md: 6857f2a06a7d90702b6626409b4d8db914b3c465 +web.md: a411fc804133b012b77b7232e653decdb0f09c3d +web.zh.md: 595c315cf36dbbdf5b9a26870d5aeb1bbc0bb405 diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index 2a7cc499b4..a411fc8041 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -125,7 +125,7 @@ Selection never depends on registration, config, or HMR order: a capability has ## Errors -`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by `WebService` selection and the shared contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. +`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by the shared `WebService` contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. ## The service @@ -178,7 +178,7 @@ registerFetchProvider(provider: WebFetchProvider): () => void * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index 6857f2a06a..595c315cf3 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -125,7 +125,7 @@ type WebFetchBody = ## 错误 -`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。由 seam 统一定义的错误代码来自 `WebService` 的选择逻辑和共享约定:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 +`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。共享的 `WebService` 约定会抛出与 seam 无关的错误代码:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 ## 服务 @@ -178,7 +178,7 @@ registerFetchProvider(provider: WebFetchProvider): () => void * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index f1ddf20d11..b18eeced08 100644 --- a/docs/subsystems/workflow.i18n.yaml +++ b/docs/subsystems/workflow.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/subsystems/workflow.md -workflow.md: 4c552b31435963d4133a31f2e98f8a913305c1f8 -workflow.zh.md: 53ef84134b00e323bd5331ff30b18012124b5241 +workflow.md: 22dcaad608cc2ca7f407b8837fc3856abcc43555 +workflow.zh.md: 7ccd47f414ad574f2daa8e74f6cfb65abfbe06c2 diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 4c552b3143..22dcaad608 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -2,7 +2,7 @@ English | [中文](workflow.zh.md) -The workflow seam — an agent running a model-written orchestration SCRIPT that fans out subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). Unlike the subagent registry it takes the bash shape: ONE engine implementation per context provides `ctx.workflows`; there is no named-provider registry (a second engine is a plugin swap, not a co-resident). +The workflow seam lets an agent run a model-written orchestration SCRIPT that starts subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent loop, so its types and operations live here rather than in [core.md](core.md). Like bash, it permits ONE engine implementation per context to provide `ctx.workflows`; there is no named-provider registry (a second engine replaces the first through plugin configuration rather than running beside it). Service Definition: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The Service provider is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing Consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). @@ -10,13 +10,13 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work ## The start request -What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). +What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine validates `meta` against its schema and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script starts is attributed to it, and cwd, lineage, and depth pass through the [subagent seam](subagent.md). ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -24,7 +24,7 @@ What a caller asks for when starting a run. The ordinary workflow tool builds th interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 53ef84134b..7ccd47f414 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -2,7 +2,7 @@ [English](workflow.md) | 中文 -工作流 seam 允许 agent(智能体)运行由模型编写的编排脚本,并由该脚本扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 +工作流 seam 允许 agent(智能体)运行由模型编写、会启动 subagent 的编排脚本。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop,因此其类型和操作记录在此处,而非 [core.md](core.md)。与 bash 一样,每个上下文只允许一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎通过插件配置替换第一个,而不与它同时运行)。 Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。Service provider 是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的 Consumer 是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 @@ -10,13 +10,13 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor ## 启动请求 -本节定义调用方启动一次运行时提交的请求。普通工作流工具会根据模型的 `{ script, meta, args }` 调用和发起调用的 agent 构建该请求;专用消费方还可以为本次运行选择引擎级 `subagentProvider`,并将 `maxTotalAgents` 调低,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据;引擎会校验 `meta` 的形状,并在任何工作开始前大声拒绝无效数据。引擎绝不会通过对脚本文本求值来获取它们。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 +本节定义调用方启动一次运行时提交的请求。普通工作流工具会根据模型的 `{ script, meta, args }` 调用和发起调用的 agent 构建该请求;专用消费方还可以为本次运行选择引擎级 `subagentProvider`,并将 `maxTotalAgents` 调低,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据;引擎会用 schema 校验 `meta`,并在任何工作开始前拒绝无效数据。引擎绝不会通过对脚本文本求值来获取它们。`parent` 是必填字段——脚本启动的每个子 agent 都归属于它,cwd、谱系与深度通过 [subagent seam](subagent.md) 传递。 ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -24,7 +24,7 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 8809d62cd3..2b608ec739 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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/testing.md -testing.md: 62ac375844b7f05970e944278fd44ac60f68b4b1 -testing.zh.md: 6353b2799b5760d8c45534d0264bf41be063d659 +testing.md: f5e8a478ec86c29c52f4127c51682c1c44fd23a7 +testing.zh.md: bd1fa7d23263d7c6e3bed65ef4ed09576ca47cc1 diff --git a/docs/testing.md b/docs/testing.md index 62ac375844..f5e8a478ec 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,7 +6,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers -- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). +- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). @@ -30,8 +30,8 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## Test the real entry path -- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. -- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. +- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external services or nondeterministic inputs, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. +- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green when a default export replaces the required named exports — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/examples/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. ## Test resolution: source plane only @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 6353b2799b..bd1fa7d232 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -6,7 +6,7 @@ ## 层级 -- **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性约定回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 +- **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 @@ -30,8 +30,8 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 测试真实入口路径 -- 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部/不确定边界,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 -- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 +- 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部服务或非确定性输入,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 +- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在默认导出替换必需的具名导出时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 - 「真实入口路径」指已发布的产物:包的 `bin` 所运行的是构建后的 `lib/bin.js`,并由普通 `node` 执行,从而暴露 tsx 会掩盖的失败(等待稳定时的竞态、模块解析、被吞掉的加载失败)。同样的规则适用于非 index 运行时入口(worker-thread 的同级文件 `lib/worker.cjs`),也适用于多个 bundle 共享的单例模块(`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`)。保持构建产物冒烟测试绿色(`packages/examples/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零状态退出。 ## 测试解析:仅限源码 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期变体或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index 037e063125..b629e4029e 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.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/tool-execution-pipeline.md -tool-execution-pipeline.md: 16a4461b6c995f666882635dddb25e1a6cf79c73 -tool-execution-pipeline.zh.md: 3b5226c5ea8038c0fc5154cd37a77d92f6e3d0e1 +tool-execution-pipeline.md: 6c925a404d7a161838e72ce4b03b7f2cad29d313 +tool-execution-pipeline.zh.md: 15627023d3be6ac2b3aae70c2ef01ef9f1077d3e diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 16a4461b6c..6c925a404d 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -3,7 +3,7 @@ # Tool Execution Pipeline -This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them. +This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward. ```mermaid flowchart TD diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 3b5226c5ea..15627023d3 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -5,7 +5,7 @@ [English](tool-execution-pipeline.md) | 中文 -此图展示了策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染如何在不改变循环的前提下各就其位。可转换的扩展点是 `tools/pre-execute`、`tools/execute` 和 `tools/post-execute` waterfall(瀑布式事件);围绕这些扩展点的边界则由所有者强制执行,包括单调守卫、由定义自身控制的 `finalizeContent`,以及 `tools/result`。 +此图展示策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染在不改变循环的情况下何时运行。`tools/pre-execute` waterfall(瀑布式事件)首先运行,随后是单调守卫,然后运行 `tools/execute` 和 `tools/post-execute` waterfall;这三个 waterfall 可以改写一次调用。由定义自身控制的 `finalizeContent` 和 `tools/result` 在此之后运行。 ```mermaid flowchart TD diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 2bb1d0ce7d..0d89e9622e 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.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/user/develop/basic/index.md -index.md: efedb07c8d757ef1f90d99fe1bf503a35c0f1a37 -index.zh.md: 2293a6086dc80fa77c88ef734ae17576ea513a10 +index.md: 7fe66bb19ddb978a4b5a96768b62151b97bca0ec +index.zh.md: 59f4e5b58b6cf1fbc15de8fafb5f4b0db2e220d6 diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index efedb07c8d..7fe66bb19d 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -26,7 +26,7 @@ export function apply(ctx: Context) { } ``` -That is the complete shape. +That is the complete configuration. ## Create the plugin file diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 2293a6086d..59f4e5b58b 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -26,7 +26,7 @@ export function apply(ctx: Context) { } ``` -这就是完整结构。 +这就是完整配置。 ## 创建插件文件 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index b387f7a0b6..d849ac4ae0 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.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/user/develop/basic/publish.md -publish.md: 1d1179a78c4d3a7e9e7055e3f5ee41381e28147e -publish.zh.md: d683762b78920dc754b6871ed7bd0caf3ee9afd4 +publish.md: 7657654b1467c14b22e0eb6372c2bc4e77db2f38 +publish.zh.md: 7af2ae3a06cc74597d5cbd6fddd46fbab069e287 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 1d1179a78c..7657654b14 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -33,7 +33,7 @@ hello-plugin/ } ``` -The patch file has the same shape as the `--patch` overlays you have been writing — a YAML array of patch entries — except plugin rows reference the package by name instead of a relative source path, so Node resolution finds the installed code: +The patch file is a YAML array of patch entries, like the `--patch` overlays you have been writing, except plugin rows reference the package by name instead of a relative source path so Node resolution finds the installed code: ```yaml - insert: @@ -41,7 +41,7 @@ The patch file has the same shape as the `--patch` overlays you have been writin name: dsh-hello-plugin ``` -A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. That is the correct shape for a library that plugin packages import rather than a plugin users enable. +A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable. ### The profile manifest diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index d683762b78..7af2ae3a06 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -33,7 +33,7 @@ hello-plugin/ } ``` -patch 文件的形状与你一直在写的 `--patch` overlay 相同——一个 patch 条目的 YAML 数组——只是插件行按包名而不是相对源码路径引用这个包,这样 Node 的模块解析才能找到已安装的代码: +patch 文件与一直在写的 `--patch` overlay 一样,是一个 patch 条目的 YAML 数组;区别是插件行按包名而不是相对源码路径引用这个包,这样 Node 的模块解析才能找到已安装的代码: ```yaml - insert: @@ -41,7 +41,7 @@ patch 文件的形状与你一直在写的 `--patch` overlay 相同——一个 name: dsh-hello-plugin ``` -没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。这正是「供插件包 import 的库」应有的形状,区别于「供用户启用的插件」。 +没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。如果一个库供插件包 import,而不是供用户启用,就使用这种包格式。 ### profile manifest diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index 24ae066e89..fc15dfeb2f 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.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/user/develop/practice/index.md -index.md: 6ae9fd152ca2e3b82bdb1ea9b3aa3d348ebfce66 -index.zh.md: 0056a81402761310fe5f0463b83762d98ed9745f +index.md: 1eb33e17ab6c5d0a2b37ff97d5948dfbcba497ca +index.zh.md: 31afa80407f81f571615b5ed68a9370775f5188f diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index 6ae9fd152c..1eb33e17ab 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -12,8 +12,8 @@ When a capability is general enough to need replaceable providers, such as Bash The Bash execution capability consists of: -- **Service Definition** (`dsh-bash`) — defines the Cordis service and Bash request/result vocabulary -- **Service provider** (`dsh-bash-local`) — supplies local command execution +- **Service Definition** (`dsh-bash`) — defines the Cordis service and Bash request and result types +- **Service provider** (`dsh-bash-local`) — executes commands on the local machine - **Consumer** (`dsh-tool-bash`) — exposes the capability as a model-callable tool ``` @@ -43,7 +43,7 @@ The Service Definition and tool remain unchanged while the provider changes. ### Evolve independently -- The Service Definition changes rarely after its contract stabilizes. +- The Service Definition changes rarely after callers depend on its contract. - Service providers can improve performance and security independently. - Consumers can change how they present the capability to the model. diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 0056a81402..31afa80407 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -12,8 +12,8 @@ 以 Bash 执行能力为例: -- **Service Definition** (`dsh-bash`):定义 Cordis 服务以及 Bash 请求/结果词汇 -- **Service provider** (`dsh-bash-local`):提供本地命令执行 +- **Service Definition** (`dsh-bash`):定义 Cordis 服务以及 Bash 请求和结果类型 +- **Service provider** (`dsh-bash-local`):在本地计算机上执行命令 - **Consumer** (`dsh-tool-bash`):将该能力公开为模型可调用的工具 ``` @@ -43,8 +43,8 @@ ### 独立演进 -- Service Definition 的约定稳定后很少改动 -- Service provider 可以独立优化性能和安全性 +- 调用方开始依赖 Service Definition 的约定后,Service Definition 很少改动。 +- Service provider 可以独立优化性能和安全性。 - Consumer 可以调整能力向模型呈现的方式。 ### 依赖解耦 diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 55277809ef..fc6744dea1 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/web-styling.md web-styling.md: 5296cc7f83f712532262eadda6da098ae9f63ec7 -web-styling.zh.md: bec623911269cbd7020c26d5574ee88ca074b5d8 +web-styling.zh.md: ed0906dae28a5a31f2e8e094b634ce6d9b518f31 diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index bec6239112..ed0906dae2 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -8,7 +8,7 @@ [`ui-theme`](../packages/client/ui-theme/README.md) 负责 `--dsw-*` 静态色阶、语义别名、排版、动效、渐变、阴影、滚动条样式以及明暗主题偏好。[`ui-layout`](../packages/client/ui-layout/README.md) 将解析后的主题快照应用到文档。功能包使用语义别名,不得另行定义全局主题。 -全局样式表归 `ui-theme/src/styles/` 所有。组件样式以 CSS Modules 形式放在组件旁。当某个值属于组件自身的布局或呈现约定时,组件可以定义局部自定义属性;共享颜色、排版、层级和动效属于主题包。 +全局样式表归 `ui-theme/src/styles/` 所有。组件样式以 CSS Modules 形式放在组件旁。当某个值属于该组件的布局或呈现约定时,组件可以定义局部自定义属性;共享颜色、排版、层级和动效属于主题包。 ## 组件规则 diff --git a/examples/AGENTS.md b/examples/AGENTS.md index a1f87ac8fb..c1dfd47b4d 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -8,7 +8,7 @@ Extract reusable logic into `packages/`, where per-file coverage and README gate Each example has both: -- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches Loader/export-shape failures hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches invalid Loader exports that hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md). Keyless process smokes use `@deepseek-ai/dsh-loader-smoke` for Loader launch resolution; terminal tests wrap that launch in a pseudo-terminal. Tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..9eb71d8948 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -236,7 +236,7 @@ const SCENARIOS: Scenario[] = [ // and then `migrate:packed-session-fixtures`, which canonicalizes the live // log's eager-drain-packed rows into the maximal-run layout replay produces. // The recorded fixture's `request/header` config and `request/context` are - // normalized to the replay-produced minimal shape (the live adapter logs + // normalized to the minimal fields produced during replay (the live adapter logs // model capabilities like maxTokens/reasoningEffort that llm-replay has no // data for), and its tool-result paths are canonicalized to `/` separators. { diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index b94a78aa30..9f2bbf5f96 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -20,7 +20,7 @@ import { cleanupAcpExampleTest } from './cleanup.ts' * * Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as * an ACP subprocess and drive initialize + session/new — the real-Loader-path - * guard (postmortem 0001) for THIS tree's export shapes, including the + * guard (postmortem 0001) for THIS tree's exports, including the * sandbox executor AND the approval service. No prompt is sent, so neither the * model nor a sandbox runner is ever exercised. * @@ -74,8 +74,8 @@ function launchExampleAcpAgent( requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) - // The scripted machine policy selects the requested option; an - // unexpected request shape cancels (fail closed, never grants). + // The scripted machine policy selects the requested option. If that + // option is absent, the policy cancels (fail closed, never grant). if (option === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, @@ -107,7 +107,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa const { client } = spawned // A dummy key boots the adapter; no prompt is ever sent, so no model call // and no sandbox runner probe happen. This drives the fiber tree the same - // way an ACP caller would, which catches a broken export/inject shape. + // way an ACP caller would, which catches broken exports or injection. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) expect(init.protocolVersion).toBe(PROTOCOL_VERSION) expect(init.agentCapabilities).toEqual({ diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index ba75ce9294..f855c958bf 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -67,7 +67,7 @@ describe('headless-agent keyless smoke', () => { it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => { // The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the // claim true — a wrapper-template change fails here until the fixture is - // regenerated, so the assembled smoke can never exercise a stale shape. + // regenerated, so the assembled smoke can never exercise stale generated fields. const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url)) const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-')) try { diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index 7e8e5248e4..d1de74a676 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: 792bb31b668b427c8734286878a9ec98071190d8 -README.zh.md: 51020f5288c4fbd245914280b8e7e4772e8cad69 +README.md: 58da672030eaf2ddf70ee92d506de300efcd9650 +README.zh.md: 0a2f109f9458ec7e1aba50e7fc9b6fd0fca15dbd diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 792bb31b66..58da672030 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -10,7 +10,7 @@ These third-party configurations are provided as interoperability examples only. DSH parses the selected Cordis overlay, starts a configured stdio command or connects to a configured Streamable HTTP URL, discovers MCP tools, and exposes them as `mcp____`. DSH does **not** download the server, initialize its database, choose its model or embedding provider, create a cloud account, migrate vendor data, or supervise a separate HTTP service. For stdio, the generic client launches and stops the child with the DSH plugin lifecycle; for HTTP, the upstream service must already be running. -The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. +The stdio bridge deliberately removes ambient variables whose names usually identify credentials and all `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. ## Choose one @@ -95,7 +95,7 @@ A new DSH session is required; a Host restart is not. Restart or HMR is needed o ## Bring another MCP server -Copy the same generic shape and use a unique `id` and `serverName`: +Copy the same entry fields and use a unique `id` and `serverName`: ```yaml - insert: diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 51020f5288..0a2f109f94 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -10,7 +10,7 @@ DSH 解析选中的 Cordis overlay,启动已配置的 stdio 命令或连接已配置的 Streamable HTTP URL,发现 MCP 工具,并以 `mcp____` 的形式公开这些工具。DSH **不负责** 下载服务器、初始化其数据库、选择模型或 embedding 提供方、创建云端账户、迁移提供方数据,也不监管独立的 HTTP 服务。对于 stdio,通用客户端会随 DSH 插件生命周期启动和停止子进程;对于 HTTP,上游服务必须已经运行。 -stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据的变量和 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 +stdio 桥接器在启动子进程前会主动移除环境中名称通常表示凭据的变量和所有 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 ## 选择一个 @@ -95,7 +95,7 @@ Engram 负责存储和项目选择:它默认使用 `~/.engram`,从 DSH 工 ## 接入其他 MCP 服务器 -复制相同的通用结构,并使用唯一的 `id` 和 `serverName`: +复制相同的条目字段,并使用唯一的 `id` 和 `serverName`: ```yaml - insert: diff --git a/examples/web-cordis/README.i18n.yaml b/examples/web-cordis/README.i18n.yaml index d2004a499c..d0cc1f4992 100644 --- a/examples/web-cordis/README.i18n.yaml +++ b/examples/web-cordis/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 examples/web-cordis/README.md -README.md: 21fe0a210b2e591a96dc254014a0f91ed9afa2ba -README.zh.md: b3fecb4312dbcbaeff460a21f1d2db6b5288f9ad +README.md: 5d46db7e9d4416f0e6327f7e119bc863ffbdc8a6 +README.zh.md: 9074d8a2dc19b851d85e4fe037f588f70d8da4c7 diff --git a/examples/web-cordis/README.md b/examples/web-cordis/README.md index 21fe0a210b..5d46db7e9d 100644 --- a/examples/web-cordis/README.md +++ b/examples/web-cordis/README.md @@ -18,4 +18,4 @@ Start the ACP automation server instead: pnpm run demo:cordis acp ``` -Both commands require `DEEPSEEK_API_KEY`. The [Cordis tool reference](../../packages/self-modification/tool-cordis/README.md) owns the tool, lifecycle, and safety contracts. +Both commands require `DEEPSEEK_API_KEY`. The [Cordis tool reference](../../packages/self-modification/tool-cordis/README.md) defines the tool arguments, lifetime, cleanup, and safety contracts. diff --git a/examples/web-cordis/README.zh.md b/examples/web-cordis/README.zh.md index b3fecb4312..9074d8a2dc 100644 --- a/examples/web-cordis/README.zh.md +++ b/examples/web-cordis/README.zh.md @@ -18,4 +18,4 @@ pnpm run demo:cordis pnpm run demo:cordis acp ``` -这两条命令都需要 `DEEPSEEK_API_KEY`。工具、生命周期和安全约定由 [Cordis 工具参考](../../packages/self-modification/tool-cordis/README.md)定义。 +这两条命令都需要 `DEEPSEEK_API_KEY`。[Cordis 工具参考](../../packages/self-modification/tool-cordis/README.md)定义了四类约定:工具参数、存续时间、清理行为和安全性。 diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md index 29bf66fa9d..48f1267c63 100644 --- a/native/landlock-run/AGENTS.md +++ b/native/landlock-run/AGENTS.md @@ -1,10 +1,10 @@ # AGENTS.md -This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. +This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and implements its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. ## Pre-release stance -The project is pre-1.0. Prefer the correct public shape over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. +The project is pre-1.0. Prefer the correct public API over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. ## Runtime safety rules @@ -47,4 +47,4 @@ pnpm test # entry tests everywhere; launcher tests need linux + built ## Documentation -User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implemented shape belongs in [docs/architecture.md](docs/architecture.md). +User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implementation belongs in [docs/architecture.md](docs/architecture.md). diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md index 33c71d6893..72d2f85cff 100644 --- a/native/landlock-run/docs/architecture.md +++ b/native/landlock-run/docs/architecture.md @@ -9,7 +9,7 @@ The family is one entry package plus per-platform binary packages: - **Entry package** (`@deepseek-ai/node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. - **Platform packages** (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. -Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent. +Because the CLI parser and binary are versioned together in one package family, the parser cannot fall behind that binary version. Preventing that mismatch is why the package split exists. There is no shared loader package: platform packages have nothing to load. If a second tool ever needs shared JS, extract it then, not preemptively. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md index ec459eb655..16ee74de97 100644 --- a/native/landlock-run/docs/packaging.md +++ b/native/landlock-run/docs/packaging.md @@ -1,6 +1,6 @@ # Packaging -The package family uses the same broad shape as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend dimension — each platform package carries exactly the static executables its `prebuilds.json` declares. +The package family uses the same layout as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend division — each platform package carries exactly the static executables its `prebuilds.json` declares. ## Published packages diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 6cff1b6327..8f669e253a 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -2,17 +2,17 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md#conventions). -- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Plugin exports:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Optional services use `ctx.get(name)`.** Reserve `ctx.` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). -- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). +- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external services or nondeterministic inputs and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). -- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. -- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). +- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement point; otherwise fold it while preserving rollback, callback containment, and quiescence. +- **Design Service Definitions for all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). - **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. - **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. - **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. -- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. -- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. +- **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. +- **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 240a707c65..dc299f49c3 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -125,7 +125,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { proc = this.startArgv(spec, confined.argv) } catch (error) { // LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async - // `done` rejection; this covers alternatives that throw that shape synchronously. + // `done` rejection; this covers alternatives that throw the same error synchronously. if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { throw new SandboxUnavailableError(mode, String(error)) } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 5d51b559d4..cec63092de 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0 -README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c +README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94 +README.zh.md: 10165486712fc078cdf1f4147522397a15c88955 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 49c75bac1b..be03bceb39 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -14,12 +14,12 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it | +| `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | | `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of rows from the same file and patch layers is preceded by a `# ==` comment naming them, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | @@ -57,4 +57,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins. -- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. +- **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 93adc52c11..1016548671 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -14,12 +14,12 @@ | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | +| `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | | `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来自同一文件且经相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | @@ -57,4 +57,4 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生辅助组件;没有该辅助组件的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境发现以启动为界**:`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 -- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 +- **用户 patch 会替换匹配到的整个配置**:按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 256ea34299..fa23e6f8da 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -306,7 +306,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ /** * Parse one loader patch list: a top-level YAML array of * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and - * `insert` lists, `!!js` expressions allowed). Every shape failure throws, + * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, * because a patch file that cannot be applied at all is a misconfiguration; a * single patch whose target row is absent stays a per-entry Loader warning, so * one overlay shared across surfaces does not have to match every tree. @@ -397,12 +397,12 @@ export function renderConfigDump( throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`) } const baseLabel = basename(absoluteConfigPath) - // The YAML boundary yields untyped rows; the include validates entry shape + // YAML parsing yields untyped rows; the include validates each entry // at mount, and the dump prints whatever the file holds, so `EntryOptions` // here is structural trust in the same file `boot()` would include. const base = parsed as Parameters[0] - // snapshot_k = ONE application of layers 1..k flattened — boot's exact call - // shape for that prefix. snapshot_N is therefore the mounted composition. + // snapshot_k = ONE application of layers 1..k flattened, using the exact + // arguments boot passes for that prefix. snapshot_N is the mounted composition. // The patches are cloned per call: applyEntryPatches detaches the entry // list but pushes `insert` rows by reference from the patch list, so // sharing patch objects across snapshot calls would leak a later diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index d951a87cf0..de44d58882 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -267,7 +267,7 @@ export function readProfileManifest(binName: string, dir: string): ProfileManife } catch (error) { throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`) } - // File boundary: the shape check below validates what the parse type asserts. + // The field checks below validate the file data before trusting the parse type. const parsed = JSON.parse(raw) as ProfileManifest | null if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 395b8b2cf6..f8f5e10856 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -14,24 +14,24 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). -7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. +7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for. ## Reactive read and contract-currency discipline -How live data reaches render code, and what may cross a business boundary: +How live data reaches render code, and what UI domains may share: 1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. 2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. 3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration. -4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are exceptions pending migration to slots). +4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. ## Export discipline (client plugin packages) -The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments): +The `/client` entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments): -1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. +1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile. 3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. @@ -44,7 +44,7 @@ The `/client` surface of a UI plugin package is a contract face, not a convenien The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): 1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. -2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. +2. **Render machinery** (`web-react`, shell-only glue): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. 3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. Non-negotiables across the layers: @@ -62,7 +62,7 @@ Non-negotiables across the layers: ## Directory regime (plugin packages) -One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. +One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. ## Styling @@ -99,9 +99,9 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com ## New component checklist -1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. +1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. -3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery. +3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. 4. Tokens only in CSS; Chinese product copy; English comments. 5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index f9ce3024f1..da3e6eed6d 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/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/hmr/README.md -README.md: 454c03cc3cd11722943efd025d164d9ca8233d25 +README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2 README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index 454c03cc3c..9228292547 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request. - **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. - **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically. -- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary. +- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; only reconnect refreshes it. diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 86d87df60f..c2c8d4e942 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/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/modules/README.md -README.md: 1d327c7252f4b3001ad758b7a4db01e9907c3060 -README.zh.md: a97672b909c98367e8c1287e3b341fb612f2d110 +README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace +README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 1d327c7252..7b4c9b72e7 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change. +- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change. - **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index a97672b909..6420f6324f 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -20,5 +20,5 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包, ## 已知限制与暂缓事项 -- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。 +- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。 - **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 82070f9206..d53be39e0d 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -43,7 +43,7 @@ declare module 'cordis' { } } -/** package.json `dshClient` declaration shape (file boundary — validated field by field). */ +/** package.json `dshClient` declaration fields, validated one by one after reading the file. */ interface DshClientDeclaration { inject?: string[] platform: string @@ -138,7 +138,7 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi const fallback = (client as Record).default if (typeof fallback === 'string') return fallback } - throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`) + throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`) } /** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */ diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 29b5a5f3b6..d7252cd195 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url)) -/** Rebase a physical lib-relative source onto the browser's repository-shaped URL tree. */ +/** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */ function browserSourcePath(source: string, sourcemapPath: string): string { if (!source.startsWith('.')) return source const physicalSource = resolvePath(dirname(sourcemapPath), source) @@ -71,7 +71,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * plus the browser client bundle. Client packages emit both halves during the * Client pass by default; packages needed for Host reflection may opt into the * earlier Host pass. A package-level tsdown.config.ts REPLACES the root - * workspace shape, so the lib half must be restated here — dropping it leaves + * workspace layout, so the lib half must be restated here — dropping it leaves * the package without lib/index.js and the host Loader cannot import its node * half. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load @@ -253,8 +253,8 @@ function clientConfig(id: string, entry: string): UserConfig { outputOptions: { entryFileNames: 'client.js', // The map is served from /plugins//client.js.map. The - // browser resolves its local sources back into the repository-shaped - // /packages///src tree; sourcesContent keeps them usable + // browser resolves its local sources back into URLs that mirror the + // /packages///src directories; sourcesContent keeps them usable // without exposing that tree as an HTTP route. sourcemapPathTransform: browserSourcePath, banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`, diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index acf611bbca..58d54302ad 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/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-command/README.md -README.md: db785e769cb40235a77d05b4b66d096896a35d8a -README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a +README.md: e49ce89804886a11f102fcaf60316e8044965c10 +README.zh.md: 8bd5afd7d0a173980f476cb96f8115525602b0b4 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index db785e769c..e49ce89804 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md). +Client command API (`ctx.command`): the session-keyed command-directory cache, the `/` command source with `matchSpace`/`matchEnter` decision hooks, three-kind dispatch (`execute` / `popupSelect` / `leadingInput`), and popupSelect registration for business packages. The [web command Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md) records the decision. -`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. +`src/client/contract.ts` is the fixed business contract: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-contained — the shell component belongs to this package and business packages never see it. A contribution is a client-owned command (a host-name collision fails loud); a decoration adds a bare-invocation popup to an EXISTING host command. The host keeps its catalog row, argument claim (space / argued Enter), and lifecycle logging, and a decorated name with no host row in the session's directory never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`. `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. @@ -12,7 +12,7 @@ Menu queries fuzzy-match ordered, case-insensitive subsequences of command names `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. -The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration. +The `/client` entrypoint exports the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the fixed contract types; the shell component itself is internal to the overlay registration. ## Model Experience diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index f0f23319a8..8bd5afd7d0 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。约定:[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 +客户端命令 API(`ctx.command`):以会话为 key 的命令目录缓存、带 `matchSpace`/`matchEnter` 决策钩子的 `/` 命令 source、三类派发(`execute`/`popupSelect`/`leadingInput`),以及面向业务包的 popupSelect 注册。[Web 命令 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)记录了这项决策。 -`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 +`src/client/contract.ts` 是固定的业务 API 约定:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 自己提供 popup 数据——外层组件归本包所有,业务包永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则为**已存在的** host 命令添加裸调用 popup。host 保留目录行、带参 claim(space / 带参 Enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行,则永不触发。命令类型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 @@ -12,7 +12,7 @@ `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 -`/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的约定类型;壳组件本身是 overlay 注册的内部实现。 +`/client` 入口导出插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及固定的约定类型;外层组件本身是 overlay 注册的内部实现。 ## 模型体验 diff --git a/packages/client/ui-command/src/client/directory.ts b/packages/client/ui-command/src/client/directory.ts index a7cdca7fd8..0e4fd916b2 100644 --- a/packages/client/ui-command/src/client/directory.ts +++ b/packages/client/ui-command/src/client/directory.ts @@ -1,7 +1,7 @@ /** * Command-directory cache keyed by session: one entry per served catalog — * every session is agent-backed, so `command.list({sessionId})` is the only - * address shape. Each entry keeps the single-flight / soft-hard invalidation + * request fields. Each entry keeps the single-flight / soft-hard invalidation * / epoch-guard behavior of the original global cache; the session-key axis * is the only extra dimension. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 13edd00c37..6ebee98c95 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -516,7 +516,7 @@ export function InputBar({ } pushPlain(draft.length) if (deco.hint !== null) { - // Claim tokens are shaped `/name ` (trailing space); trim to the bare name. + // Claim tokens have the `/name ` format (trailing space); trim to the bare name. const commandName = input?.claim?.token.slice(1).trim() ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2c041e0eae..d9b185b8fd 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -38,7 +38,7 @@ const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] -/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ async function settle(invoke: () => Promise): Promise { try { await invoke() diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index c2a8741464..5bf2c744a3 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -7,7 +7,7 @@ * Per-session storage follows the client service pattern (SlashService / * CommandService): a lazy service-internal map whose entry is deleted by the * owning scope's disposer. The host `dsh-scope` ScopedLayers registry does - * not transplant here: it derives scope from the host carrier mechanism + * does not belong here: it derives scope from the host carrier mechanism * (object-keyed), while client scopes tag contexts with branded SessionId * strings, and it models global+shadow named registries — this is a * per-session singleton with no global layer to merge. diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ec20685def..c03e091a5e 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 9841ced87ae345c685c59e96a7b9088d474181f5 -README.zh.md: bb1445fbc8093d356ce838948b8338fa04919063 +README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec +README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 9841ced87a..e0c5728d47 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation @@ -31,5 +31,5 @@ None; this package neither assembles nor sends a provider request. - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. -- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. +- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index bb1445fbc8..fe11e6cdd1 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 @@ -31,5 +31,5 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, - **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 -- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 +- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 50cbad2549..16fb544dc0 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -196,7 +196,7 @@ let loadCount = 0 * Subscribe to lazy-grammar load completions; `listener` fires after a * {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a * caller that rendered its plain fallback while the grammar loaded can - * re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with + * re-highlight. Uses the `useSyncExternalStore` subscribe signature; pair it with * {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function. * @param listener - invoked (no args) on each grammar-load completion. * @returns a disposer that removes the listener. diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index 8f9d9f8c75..e8bada18b0 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -86,8 +86,8 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u /** * Question domain face over the carrier: render identity and questions - * transparently forwarded; answer/cancel own the wire encoding (the ok value - * shape and the cancelled error) and turn a rejected carrier receipt into a + * transparently forwarded; answer/cancel own the wire encoding (the success + * fields and the cancelled error) and turn a rejected carrier receipt into a * thrown error. Components mint one per carrier via useMemo (never inside a * select — a per-dispatch mint would churn identity and break memoization). */ diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts index 08d6389686..1c9393e6f4 100644 --- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts @@ -77,9 +77,9 @@ export interface SearchCardModel { /** * Whether every file group in a matches view is structurally valid: the wire * frame carries `shape` and `card` as strings the host schema checks, but not the - * grouped shape, so a version mismatch or loose producer could deliver + * grouped `files` fields, so a version mismatch or loose producer could deliver * `shape: 'matches'` with a missing or malformed `files`. Rendering that would - * crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the + * crash {@link SearchBlock} at `.reduce`/`.map`; invalid fields select the * generic path instead. * @param files - the candidate `files` field off the untrusted result view. * @returns whether `files` is a valid {@link SearchFileGroup} array. @@ -136,12 +136,13 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { // The recovery footer only matters when the tool capped the result: an // uncapped card holds every match/path, so the raw text adds nothing the card // does not already show. When capped, the raw result's `Full … stored at …` - // locator is the only path to the dropped rows, so surface it. + // locator is the only way to retrieve the omitted rows, so include it. const recovery = result.truncated ? flattenContent(block.content) : undefined if (result.shape === 'matches') { // `files` rides the untrusted wire frame: the host schema checks `card`/`shape` - // strings but not the grouped shape, so validate it before SearchBlock, which - // would crash on a missing/malformed `files`. An invalid shape falls to generic. + // strings but not the grouped `files` fields, so validate them before + // SearchBlock, which would crash on a missing or malformed `files`. + // Invalid fields select the generic view. if (!isValidFiles(result.files)) return null return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 8f45bf3a49..880938567f 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -21,8 +21,8 @@ function isAnswer(value: unknown): value is AnswerEntry { return typeof value === 'object' && value !== null } -/** Answered-count summary off the result JSON (a skipped question has - * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */ +/** Answered-count summary from the result JSON (a skipped question has + * empty `selected` and no `custom`); null when answer fields are invalid. */ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null { let parsed: unknown try { diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index 2556cfd8b4..111189aede 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -41,7 +41,7 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null { // Mid-stream truncation or malformed model JSON: fall back to the generic summary. return null } - // Valid JSON with an invalid shape (null root, non-array todos, null items — + // Valid JSON with invalid todo fields (null root, non-array todos, null items — // a rejected tool/call retains such args verbatim): same generic fallback. if (typeof parsed !== 'object' || parsed === null) return null const todos = (parsed as { todos?: unknown }).todos diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index cbe0d0ac55..67a8419bd3 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -234,7 +234,7 @@ export interface PendingCall { reject(error: Error): void } -/** Constructor shape for one program-visible binding rejection class. */ +/** Constructor type for one program-visible binding rejection class. */ export type BindingErrorConstructor = new (memberName: string, message: string) => Error /** diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index ec1fa015ed..ffd3fd22a8 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -32,13 +32,13 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa case 'assistant/message': case 'tool/call': case 'tool/result': - fail('time-context reading must be appended at a prompt boundary') + fail('time-context reading must be appended during prompt assembly') break default: break } } - fail('time-context reading must be appended at a prompt boundary') + fail('time-context reading must be appended during prompt assembly') } /** Validate one plugin-attributed time reading against its session position and timestamp. */ diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 59ffa66a89..b5f0385b3f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -129,20 +129,20 @@ describe('time-context invariants', () => { const session = preparing(1, 2) session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) - .toThrow(/at a prompt boundary/) + .toThrow(/during prompt assembly/) }) - it('rejects a reading outside a prompt boundary', async () => { + it('rejects a reading outside prompt assembly', async () => { const ctx = await setup() const ended = preparing(1, 1) ended.append('step/end', { turn: 1, step: 1 }) - expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/) + expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/during prompt assembly/) const notEntered = Session.create(SessionId('time-invariant-turn-only')) notEntered.append('turn/start', { turn: 1 }) - expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/at a prompt boundary/) + expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/during prompt assembly/) expect(() => { ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading())) - }).toThrow(/at a prompt boundary/) + }).toThrow(/during prompt assembly/) }) it.each([ diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index df32eaf80c..f27b2f4622 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -503,8 +503,8 @@ export class Session { /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 6f1757ff28..23b5936e08 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -184,7 +184,7 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly; known names + * Invalid fields fail at load and unknown names fail at assembly; known names * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 38a9c2ede3..0c5e1fee44 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: f3d1b4741c7fde64669794d079c36a18e633c0c1 -README.zh.md: d3054372095ef0cfdabc6cf80e0faa41a3b12d4c +README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e +README.zh.md: d7766b432c5a319d214da80e3df438489519be92 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f3d1b4741c..2c9833c350 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -36,7 +36,7 @@ Cancellation is cooperative and quiescent. Every typed invocation supplies a cal ### Live events -The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. +The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` event; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types @@ -120,7 +120,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). - **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `:code:`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. -- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. +- **Result size**: intermediate binding values cross the worker process whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that limit. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. ### Parallel execution @@ -146,7 +146,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). +Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). ##### Code Mode SDK instructions diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d305437209..d7766b432c 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -36,7 +36,7 @@ tools: ### 实时事件 -实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后到达仅观测的 `tools/result` 边界;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和故障收容约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。 +实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后发布仅供观测的 `tools/result` 事件;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和失败隔离约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。 ### 关键类型 @@ -120,7 +120,7 @@ ctx.tools.register(defineTool({ - **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 - **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `:code:`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 - **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。 -- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 +- **结果大小**:中间绑定值会完整传入 worker 进程,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该上限。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 ### 并行执行 @@ -146,7 +146,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 4f094e4ade..e7c13b2168 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -28,7 +28,7 @@ export const SDK_SECTION_ORDER = 150 * strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring * `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the * semantics the same language's SDK instructions promise, so the model never - * receives a TypeScript-shaped schema beside a Python SDK (or vice versa). + * receives a TypeScript schema beside a Python SDK (or vice versa). */ interface RunCodeFlavor { /** The tool `description` the model sees for this language. */ @@ -338,8 +338,8 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge exec.signal.addEventListener('abort', onOuterAbort, { once: true }) let dispatches = 0 - // The per-run scheduler, reusing the NATIVE concurrency contract through - // the registry's staged view (the loop scheduler's own boundary) — and the + // The per-run scheduler uses the registry's staged interface and follows + // the same concurrency rules as the native loop. It also follows the // native loop's SEQUENCING: every ordered stage (the dispatch-start // append, prepare = pre-execute/guards, finalize/finish = post-execute, // context deferral, the settle append) runs inside ONE driver lane, so @@ -369,7 +369,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge } const pendingQueue: PendingDispatch[] = [] const inFlight = new Set>() - /** Tracked settle-event side work (log shaping + append), drained at run settlement. */ + /** Tracked settle-event side work (log-content listener + append), drained at run settlement. */ const logWork = new Set>() const commitQueue: PendingDispatch[] = [] let exclusiveActive = false @@ -394,7 +394,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge driverRun = (async () => { try { for (;;) { - // Arm before inspecting state so a settle or submission landing + // Create the wakeup promise before inspecting state so a settle or submission arriving // between the checks and the await below cannot be lost. const signal = new Promise((resolve) => { wake = resolve }) const commitHead = commitQueue[0] @@ -449,7 +449,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge // entries, awaits the live pool, and drains the ordered commit lane — // including a commit already in progress when the program returned. await drive() - // Every settle's shaped append lands inside the open run_code turn + // Every settle event is appended inside the open run_code turn // (tasks self-remove on settlement). while (logWork.size > 0) await Promise.allSettled([...logWork]) } @@ -483,10 +483,10 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge | { kind: 'post-result' | 'final-result'; exec: ToolRunContext; result: ToolExecutionResult } | undefined const settle = (result: ToolExecutionResult): void => { - // The program gets its value NOW: log shaping (e.g. a spill - // backend) must never delay the binding or occupy a dispatch - // slot. The shaped append is tracked side work; the run's - // settlement drains logWork so every settle event still lands + // The program gets its value NOW: the log-content listener (for + // example, a spill backend) must never delay the binding or occupy + // a dispatch slot. The event append is tracked side work; the run's + // settlement drains logWork so every settle event is still appended // inside the open turn (shapeDispatchLog is contained, so this // chain cannot reject). resolve(result.isError @@ -495,9 +495,9 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge const agent = exec.agent if (agent === undefined) return const task: Promise = (async () => { - // The durable copy may be reshaped (e.g. spilled to a preview + - // locator) by the log-shaping waterfall; the program's value - // and the model contract are untouched. + // The listener may replace the durable copy with a preview and + // locator; the program's value and model-visible result are + // untouched. const logged = await shapeDispatchLog({ exec, agent, subCallId, name, isError: result.isError, // The registry deep-froze this projection at result @@ -560,16 +560,16 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge for (const context of result.additionalContexts ?? []) { exec.deferContext(context) } - // Like the context forwarding above, cross-boundary facts travel - // on the nested result and the composite forwards them: only a - // successful nested result can carry the terminal marker + // The composite forwards `additionalContexts` above and + // `concludesTurn` here from the nested result. Only a successful + // nested result can carry the terminal marker // (ToolExecutionFailure types it never), so a policy-converted // failure cannot stop the turn through a recovering program. if (result.concludesTurn) exec.concludeTurn() settle(result) - // Backpressure on the shaped-append side channel: pending log - // tasks (each retaining a full result while a slow backend - // stores it) are bounded by the pool cap — beyond it the + // Backpressure on pending event-append tasks: each task retains + // a full result while a slow backend stores it, so the pool cap + // bounds their count. Beyond the cap, the // ordered lane waits, so later sub-calls cannot start and // pending I/O/memory cannot grow without bound. while (logWork.size > maxParallel) await Promise.race(logWork) @@ -578,7 +578,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge wakeup() void drive() }) - // A budget expiry or outer cancel that lands while this call was in + // A budget expiry or outer cancel that occurs while this call was in // flight already aborted the dispatch; stop the program now rather // than hand it a result from a run that is over. if (runOver()) { @@ -661,7 +661,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge Object.defineProperty(definition, 'parameters', { enumerable: true, // Recompile through the same spec→schema projection defineTool used, so - // the emitted shape can never drift from the validated one. + // the emitted schema always matches the validated specification. get: () => parameterSchemaSpecToJsonSchema({ code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription }, description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a44df0863f..a1653e7d16 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -160,13 +160,14 @@ declare module 'cordis' { */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -1183,8 +1184,8 @@ export class ToolRegistry extends Service { /** * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch * and return the content the bridge should log on `tool/code-dispatch`. - * Contained: a throwing listener falls back to the unshaped content — log - * shaping must never fail the dispatch or lose the settle event. Private: + * Contained: when a listener throws, the method logs the original settled + * content; that failure must not fail the dispatch or omit the settle event. Private: * the ONE consumer is the `run_code` bridge this registry constructs, which * receives it as a capability parameter (the `requireRuntime` idiom) — the * waterfall, not this invoker, is the public extension point. @@ -1196,7 +1197,7 @@ export class ToolRegistry extends Service { () => Promise.resolve(dispatch.content), ) } catch (error: unknown) { - this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`) + this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`) return dispatch.content } } diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 9b6ca88d93..9191bcfbfa 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -7,7 +7,7 @@ * * Unsupported or misplaced keywords reject rather than being accepted without * enforcement. Consumers that require an object root apply - * {@link assertObjectJsonSchema} at their own boundary. + * {@link assertObjectJsonSchema} before accepting input. * @module dsh-tools/json-schema */ @@ -25,7 +25,7 @@ type JsonSchemaScalarType = Exclude /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ export interface JsonSchemaNode { diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index e4b241b75f..4898ec80e1 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -733,7 +733,7 @@ export function jsonSchemaToPy(schema: unknown): string { /** The fixed model-facing usage contract rendered above the declarations. */ const SDK_INSTRUCTIONS = `## Writing code for run_code -Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing shapes — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: +Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing argument and return types — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: - Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue. diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 34758b840f..1794e7e36e 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -59,7 +59,7 @@ async function setup(options: SetupOptions = {}) { return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } } -/** Mint one production-shaped agent scope that can register scoped tool policy. */ +/** Mint an agent scope configured like production that can register scoped tool policy. */ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { const agent = { id: SessionId(name) } as Agent let scope!: Scope @@ -407,7 +407,7 @@ describe('mode-aware wire contribution', () => { }) it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => { - // Any reader of the definition without a mounted runtime lands here; the + // Any reader of the definition without a mounted runtime uses this fallback; the // shipped one is the tool-catalog generator, which boots the registry under // `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime // returns undefined there, so the flavor getter degrades to the TS default @@ -663,7 +663,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { expect(stages).toEqual(['post-enter:writer', 'post-exit:writer']) }) - it('run settlement drains a commit already in progress: the settle event lands inside the turn', async () => { + it('run settlement drains a commit already in progress: the settle event is appended inside the turn', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const gated = registerGated(ctx, 'safe_read', true) const { agent, events } = fakeAgent() @@ -921,10 +921,10 @@ describe('the run_code dispatch bridge', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' }) }) - it('a throwing tools/code-dispatch-log listener is contained: the unshaped content is logged', async () => { + it('a throwing tools/code-dispatch-log listener is contained: the original settled content is logged', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) registerEcho(ctx) - ctx.on('tools/code-dispatch-log', () => { throw new Error('shaper exploded') }) + ctx.on('tools/code-dispatch-log', () => { throw new Error('log-content listener failed') }) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { const value = await request.bindings[0]!.functions.echo!({ value: 'x' }) @@ -1593,7 +1593,7 @@ describe('per-agent presentation', () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it - // (the per-preset standing-mount shape — no per-agent declaration at all). + // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:code-like') standing.scope.ctx.tools.presentAs('code') const joined = await mintAgentScope(ctx, 'joined-agent') diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index cdb4bd6eb8..e7800253f3 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -344,7 +344,7 @@ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same reviewed contract as settings-local, deliberately mirrored (prefer symmetry for parallel values); the two providers own different documents and - failure policies, so extracting the shape would couple their teardown + failure policies, so extracting a shared helper would couple their teardown semantics across packages for a handful of lines. */ /** Queue one exclusive document operation behind every earlier one. */ private enqueue(operation: () => Promise): Promise { diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index d6f23acf04..f10fd8fb47 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/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/e2b/subprocess-e2b/README.md -README.md: bb0ac1d5f1f3dbfd0f021d5fbebbab13dcee37ee -README.zh.md: d511df72284b047626ece628b702a2ac8d2f873d +README.md: e27582e5603e430e1467cb6e47c6f12bd1a0b886 +README.zh.md: 788c6273f1f2f30795d8d1ea09b481a85b88a2f6 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index bb0ac1d5f1..e27582e560 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -38,6 +38,6 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes. A background process could rewrite `pid`/`exit-code` or read a not-yet-consumed `environment` file. The adapter validates published values and refuses group ids whose negative form is unsafe to signal (`<= 1`), but real isolation needs an E2B per-command user or an out-of-band control channel. - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol. - **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. A same-UID untrusted process already in the sandbox could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive to close the gap. -- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`. +- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values equal to `128 + signal`. - **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence. - **Linux utility and E2B transport semantics are assumed** — there is no Windows, escaped-session recovery, or network-partition fidelity layer. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index d511df7228..788c6273f1 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -38,6 +38,6 @@ E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具: - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开。后台进程可以改写 `pid`/`exit-code`,或读取尚未被消费的 `environment` 文件。适配器会验证已发布的值,并拒绝取负后不安全的进程组 ID(`<= 1`),但真正的隔离需要 E2B 提供按命令用户或带外控制通道。 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。 - **初始环境探测会继承沙箱默认值**:E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。一个已在沙箱内运行的同 UID 不可信进程可以检查该短时存在的控制 shell;因此,该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语才能弥合该缺口。 -- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。 +- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括等于 `128 + signal` 的值。 - **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。 - **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、逃逸会话恢复或网络分区的保真层。 diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md index e9cb3d2b51..ee6bf61598 100644 --- a/packages/experimental/AGENTS.md +++ b/packages/experimental/AGENTS.md @@ -2,10 +2,10 @@ These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale. -- All Cordis plugin packages whose whole public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. +- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. - Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph. - Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles. -- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define narrower internal contracts but make no public release promise. +- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise. - Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements. - Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies. - Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/packages/fs/fs-policy/src/types.ts b/packages/fs/fs-policy/src/types.ts index 9ee742a7f7..f2bd7187d3 100644 --- a/packages/fs/fs-policy/src/types.ts +++ b/packages/fs/fs-policy/src/types.ts @@ -1,9 +1,9 @@ /** * Vocabulary for the fs-policy plugin: the minimal execution-context - * shape used to derive an observed-state owner by narrowing the opaque `object` + * fields used to derive an observed-state owner by narrowing the opaque `object` * actor the `fs/*` events carry. * - * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit request types) is * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state * owner structure on top of it. * @@ -12,10 +12,10 @@ /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 49548499ac..c7e8ec7ee4 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -118,7 +118,7 @@ export function buildGrepCommand(input: GrepInput): string[] { /** * The uniform malformed-output failure: raw `rg --json` is an internal - * transport, so a shape surprise is a search failure, not a partial result. + * transport, so missing or invalid response fields cause a search failure, not a partial result. */ function malformedRecord(detail: string, cause?: unknown): SearchError { return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 7b2c3ae596..a7b82bdd06 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -24,7 +24,7 @@ interface EditInput { } /** - * The `edit` tool's validated argument shape: the base parameters plus the two + * The `edit` tool's validated arguments: the base parameters plus the two * escalation fields, advertised only under a confining `ctx.fs` (absent from * the schema otherwise, so the validator rejects them before `execute`). */ diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 9a74dfc387..ba96e32cd1 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -43,7 +43,7 @@ ${verb} file } /** - * The `write` tool's validated argument shape: the base parameters plus the + * The `write` tool's validated arguments: the base parameters plus the * two escalation fields, advertised only under a confining `ctx.fs` (absent * from the schema otherwise, so the validator rejects them before `execute`). */ diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 6360e61a10..6396c7f75b 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -72,7 +72,7 @@ function nonNegativeInteger(value: unknown, field: string): number { /** Decode one canonical blocker explanation. */ function decodeBlockReason(value: unknown): GoalBlockReason { if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') { - throw new Error('goal change goal.blockedReason has an invalid shape') + throw new Error('goal change goal.blockedReason must have exactly code and message fields') } if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) { throw new Error('goal change goal.blockedReason.code must be lower-kebab-case') @@ -102,7 +102,7 @@ function decodeSnapshot(value: unknown): GoalSnapshot { ? 'blockedReason,id,maxGoalRounds,objective,phase,revision' : 'id,maxGoalRounds,objective,phase,revision' if (Object.keys(value).sort().join(',') !== expectedKeys) { - throw new Error('goal change goal has an invalid shape') + throw new Error(`goal change goal for phase ${phase} must have exactly ${expectedKeys} fields`) } return { id: GoalId(value['id']), @@ -117,7 +117,7 @@ function decodeSnapshot(value: unknown): GoalSnapshot { /** Decode and validate one ref. */ function decodeRef(value: unknown): GoalRef { if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') { - throw new Error('goal clear tombstone has an invalid shape') + throw new Error('goal clear tombstone must have exactly id and revision fields') } if (typeof value['id'] !== 'string' || value['id'].length === 0) { throw new Error('goal clear tombstone id must be a non-empty string') @@ -139,7 +139,7 @@ export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { if (value['operation'] === 'clear') { const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version'] if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { - throw new Error('goal clear change has an invalid shape') + throw new Error(`goal clear change must have exactly ${allowed.sort().join(',')} fields`) } return { kind: 'goal/change', @@ -155,7 +155,7 @@ export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { } const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version'] if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { - throw new Error('goal snapshot change has an invalid shape') + throw new Error(`goal snapshot change must have exactly ${allowed.sort().join(',')} fields`) } const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt') const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt') diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 3c642d2a8c..0c0a8e8373 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -502,8 +502,8 @@ describe('GoalService mutations', () => { session.append('goal/change', change) session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) - expect(() => ctx.goals.get(agent)).toThrow('invalid shape') - expect(() => ctx.goals.get(agent)).toThrow('invalid shape') + expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') + expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') }) }) @@ -609,13 +609,13 @@ describe('goal replay validation', () => { expect(() => foldGoal(session.events)).toThrow('not the next admitted round') }) - it('rejects unsupported versions, operations, and top-level shapes', () => { + it('rejects unsupported versions, operations, and extra top-level fields', () => { expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version') expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid') - expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape') + expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change must have exactly') expect(() => decodeGoalChange({ kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true, - })).toThrow('clear change has an invalid shape') + })).toThrow('clear change must have exactly') }) it('rejects invalid create and missing-current mutation sequences', () => { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 4fe2b7806e..c30ee60ec2 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: d5afd21033afd8291c8059fb225deee3965f8b65 -README.zh.md: cde0b4fd286579f5b389bc601750ca8303b35f15 +README.md: 64f6ae7bcd92735f821c8f8d2b3b93203dbac17e +README.zh.md: 680fcee730674a21b5c2407247ce46b1a01cf6f3 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d5afd21033..64f6ae7bcd 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 `{nativeOpen?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. +The API gateway shared by every client consists of the TypeScript API 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 `{nativeOpen?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. ## The shared Agent default (`agent-default-model` Settings section) @@ -30,9 +30,9 @@ Question responses are validated against their pending request before the first Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. 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.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. +`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`. -Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. +Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt assembly. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. 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. @@ -68,7 +68,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Pending-interaction state is host-side** — the wire shape is POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. +- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. 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. - **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cde0b4fd28..680fcee730 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` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 +所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 ## 共享 Agent 默认值(`agent-default-model` Settings 分节) @@ -30,9 +30,9 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 +`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。 -会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 +会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 待处理的 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 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 @@ -60,7 +60,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 模型体验 -无。该包定义客户端与宿主间的协议约定和载体,其中没有任何内容会进入模型请求。 +无。该包定义客户端与宿主间的 wire 约定和载体,其中没有任何内容会进入模型请求。 #### KV Cache 影响 @@ -68,7 +68,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与暂缓事项 -- **待处理交互状态位于宿主侧**:协议形状为 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 +- **待处理交互状态位于宿主侧**:wire 使用 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 - **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 diff --git a/packages/host/apiproxy/src/api/approvals.schema.ts b/packages/host/apiproxy/src/api/approvals.schema.ts index 6c6e90fb17..2790d98a97 100644 --- a/packages/host/apiproxy/src/api/approvals.schema.ts +++ b/packages/host/apiproxy/src/api/approvals.schema.ts @@ -10,7 +10,7 @@ import type { ApprovalResponsePayload } from './approvals.ts' import type { Wire } from './rpc.schema.ts' import { sessionIdSchema } from './sessions.schema.ts' -/** ApprovalRequestId: one brand cast after shape validation (the only cast point in this domain). */ +/** ApprovalRequestId: one brand cast after schema validation (the only cast point in this domain). */ export const approvalRequestIdSchema = z.string().min(1) as unknown as z.ZodType /** Approval answer payload (the result.value slot of a client-response). */ diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts index 89bfa76aa2..c135c82e5a 100644 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ b/packages/host/apiproxy/src/api/commands.schema.ts @@ -33,7 +33,7 @@ export const commandExecuteRequestSchema = z.object({ line: z.string(), }) satisfies z.ZodType>> -/** CommandId: one brand cast after shape validation (the only cast point in this domain). */ +/** CommandId: one brand cast after schema validation (the only cast point in this domain). */ export const commandIdSchema = z.string().min(1) as unknown as z.ZodType /** command.execute response value: pure admission — outcomes ride the logged diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index fc841f9edb..c06efb9057 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -15,7 +15,7 @@ import { } from './sessions.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' -/** Question shape validated strictly against core dsh-user-interaction. */ +/** Question fields validated strictly against core dsh-user-interaction. */ export const askUserQuestionItemSchema = z.object({ id: z.string(), question: z.string(), diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 145b0500cf..f591283aec 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -23,8 +23,8 @@ export type Wire = T extends readonly (infer E)[] ? Wire[] : T /** - * RpcId: one brand cast after shape validation (the only cast point in this - * file). No min-length: the id is an opaque echo token, and rejecting shapes + * RpcId: one brand cast after schema validation (the only cast point in this + * file). No min-length: the id is an opaque echo token, and rejecting values * here would only turn a correlatable error report into a client-side parse * failure (the handler substitutes a sentinel when a request's id is unreadable). */ diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index e0ce444acf..81e150bc20 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -23,7 +23,7 @@ import { truncateUnicodeCodePoints, } from './session-search.ts' -/** SessionId: one brand cast after shape validation (the only cast point in this domain). */ +/** SessionId: one brand cast after schema validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType /** MessageId: one brand cast after non-empty string validation. */ diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index 49b198d446..22a7a47e27 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/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/directory-picker-auto/README.md -README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6 -README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944 +README.md: b1bbe4f97cdb88d8cf9bfe435c0eb6517554338b +README.zh.md: dc67456e9b86636522406bf6a57929b24793dade diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index f1715566c8..b1bbe4f97c 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -16,6 +16,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments. +- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments. - **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot. - **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once. diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index 9fc8e539d4..dc67456e9b 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -16,6 +16,6 @@ ## 已知限制与暂缓事项 -- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。 +- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会从 `127.0.0.1` 到达,系统会判定 `native`,并把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即选择安全的交互。 - **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。 - **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。 diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index aeb36e44d7..5ba3a2bed1 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/directory-picker/README.md -README.md: 3749b238b56578ec68610bc13550760aa084bad6 -README.zh.md: bc77a9c6e1d76e00926774dc518fce42b2860735 +README.md: d90f939aca57b6bc520bb96b56b8a7738b69a522 +README.zh.md: 40d82b3d60ab7d27100133385a73f31d8cb3c26a diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 3749b238b5..d90f939aca 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself. +The web GUI host's workspace-directory picker is a capability seam. The abstract `DirectoryPicker` service (`ctx.directoryPicker`) is its Service Definition. Its only method, `capability()`, returns a discriminated union describing how an operator selects a directory. Backends differ in user interaction, not just implementation: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` provides listing and creation operations for an in-app browser, which works for remote clients that cannot reach an OS chooser ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map, and a new backend adds its variant there through declaration merging. For an unknown kind, consumers hide directory picking rather than fail. The capability object must be stable for the service lifetime. Each backend package also has a browser entrypoint that registers the matching interaction in ui-workspace's directory-flow slots, so one composition row selects both the host capability and the client flow. A composition that should choose at runtime mounts [`-auto`](../directory-picker-auto/README.md), which inspects the host once at boot and mounts the matching backend row. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). @@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note. +- **No multi-root support** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the DirectoryPicker Agent Note. diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index bc77a9c6e1..40d82b3d60 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一约定方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam,无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。 +web GUI 宿主的工作区目录选择是一项能力 seam。抽象的 `DirectoryPicker` 服务(`ctx.directoryPicker`)是其 Service Definition。该服务只提供一个方法:`capability()`,它返回一个可辨识联合类型,说明操作者如何选择目录。后端之间的用户交互不同,不只是实现不同:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器使用的列举与创建操作,也能服务于无法访问 OS 对话框的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生,新后端通过声明合并在其中加入自己的变体。遇到未知 kind 时,消费方会隐藏目录选择入口,而不是失败。能力对象在服务生命周期内必须保持稳定。每个后端包还提供 browser 入口,在 ui-workspace 的 directory-flow slot 中注册匹配的交互,因此一项组合配置会同时选择宿主能力与 client 流程。需要在运行时选择交互的组合挂载 [`-auto`](../directory-picker-auto/README.md),它在启动时检查一次宿主情况,并挂载匹配的后端行。 浏览原语失败时会抛出带类型的 `DirectoryPickerError`(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 @@ -16,4 +16,4 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker` ## 已知限制与暂缓事项 -- **约定未定义多根目录词汇**——浏览约定每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。 +- **不支持多根目录**——浏览约定每次列举只公开一条祖先链;按部署限定可浏览根(以及在盘符根的上一级枚举 Windows 各盘符根目录)等到出现需要它的消费方再做,见 DirectoryPicker Agent Note。 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index a9e5d9e48a..ecefc4db11 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/webserver/README.md -README.md: 569c3f0c19db2c308beaef35baaf915fd39768cd -README.zh.md: 3aee06487743764bf2cb837360bb1ac9f0268508 +README.md: c41001fba3a69bfd7c00550d0be602e3fc2e0474 +README.zh.md: 061bed977e456ba6c3cd38f5ad3d30fe0c9354ab diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 569c3f0c19..c41001fba3 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. -The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). 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 knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell. A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 3aee064877..061bed977e 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 +Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换;fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 -该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 +该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认值)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。 监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index fbd275eeed..2ff04379e3 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -50,12 +50,11 @@ export interface Config { } /** - * The web-shape HTTP carrier service. Activation listens immediately (route - * registration order carries no request-facing semantics: named routes are - * composed to be disjoint, and the fallback seat answers anything not yet - * claimed during the boot window — 404 until its owner registers). A listen - * failure throws out of init — a FAILED fiber the boot's fail-loud sweep - * reports. + * The browser HTTP carrier service. Activation listens immediately. Route + * registration order does not affect requests because configured named routes + * must be distinct, and the fallback handler answers anything not yet claimed + * during startup with 404 until its owner registers. A listen failure rejects + * initialization, and the boot process reports the failed fiber. */ export class HttpServerService extends Service { static Config: z = z.object({ @@ -224,8 +223,8 @@ export class HttpServerService extends Service { }) }) - // Node does not include upgraded sockets in closeAllConnections(), so the - // service tracks and destroys them as part of the same ownership boundary. + // Node does not include upgraded sockets in closeAllConnections(). The service + // owns them with the other connections, so it tracks and destroys them explicitly. this.ctx.effect(() => async () => { const serverClosed = new Promise((resolve) => { this.server.close(() => { resolve() }) diff --git a/packages/interaction/permission/src/invariant.ts b/packages/interaction/permission/src/invariant.ts index b1290b7307..3bd102645f 100644 --- a/packages/interaction/permission/src/invariant.ts +++ b/packages/interaction/permission/src/invariant.ts @@ -11,7 +11,7 @@ export const name = 'permission-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) diff --git a/packages/interaction/user-interaction/README.i18n.yaml b/packages/interaction/user-interaction/README.i18n.yaml index 4537cfd7da..55b9514b60 100644 --- a/packages/interaction/user-interaction/README.i18n.yaml +++ b/packages/interaction/user-interaction/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/interaction/user-interaction/README.md -README.md: cf000dd59754dfe2f14395c33384bad5bda76910 -README.zh.md: 67167649e29afe99667ab6c863127d27d4bceb48 +README.md: a1fe8e63011b0726e67f8b873b0b67f4af2e890a +README.zh.md: a6a0750bd91a316ebfeaef7859d5079f7ee8b616 diff --git a/packages/interaction/user-interaction/README.md b/packages/interaction/user-interaction/README.md index cf000dd597..a1fe8e6301 100644 --- a/packages/interaction/user-interaction/README.md +++ b/packages/interaction/user-interaction/README.md @@ -26,7 +26,7 @@ When a request carries an agent, `ask()` authenticates its exact identity throug ### Presentation intent -`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. +`intent` declares that a question IS a known kind of decision, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read the same answer fields either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. ## Role diff --git a/packages/interaction/user-interaction/README.zh.md b/packages/interaction/user-interaction/README.zh.md index 67167649e2..a6a0750bd9 100644 --- a/packages/interaction/user-interaction/README.zh.md +++ b/packages/interaction/user-interaction/README.zh.md @@ -26,7 +26,7 @@ ### 呈现意图 -`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 +`intent` 声明某个问题本身就是一种已知决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只改变呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的回答字段相同。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 ## 职责 diff --git a/packages/interaction/user-interaction/src/types.ts b/packages/interaction/user-interaction/src/types.ts index 51edfc6bf7..81be220592 100644 --- a/packages/interaction/user-interaction/src/types.ts +++ b/packages/interaction/user-interaction/src/types.ts @@ -1,5 +1,5 @@ /** - * Wire-safe question/answer shapes, free of cordis/service imports so browser + * Wire-safe question and answer types, free of cordis/service imports so browser * type chains (apiproxy api → client) can consume them without loading this * package's Context augmentation. * @module @deepseek-ai/dsh-user-interaction/types @@ -14,11 +14,11 @@ export interface AskUserQuestionOption { } /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ export type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 840a8c2865..4011ff5ba9 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: 7151fdf5b63f48e625d00a92dc42aa24b7de2f31 -README.zh.md: 0bfd5c706e01dd4448edb9cf0eec812831f68093 +README.md: f6a1eefe6083d801009a5b788a07b58d6e696a5a +README.zh.md: f4c5ddd6dbe05ae709145cfac341f17a716bac82 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 7151fdf5b6..f6a1eefe60 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -173,7 +173,7 @@ Conversion preserves logical request order without adding text, while the select #### What the model sees -pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings. +pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. The adapter passes parsed tool arguments to the harness as raw JSON strings. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0bfd5c706e..f4c5ddd6db 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -173,7 +173,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。已解析工具参数以原始 JSON 字符串形式通过 harness 边界传递。 +pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。适配器把解析后的工具参数作为原始 JSON 字符串传给 harness。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 4e0cf3c092..90cf145975 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -145,12 +145,12 @@ export type PiAiReasoningEfforts = Partial = z.object({ /** * Keys are the offered levels, values their wire spellings. A valueless key * (`off:`) survives validation because schemastery passes nullable data - * through before any member schema runs — `z.const(null)` only shapes the - * error for non-null wrong values and what a configuration surface renders. + * through before any member schema runs — `z.const(null)` only controls the + * error for non-null wrong values and what a configuration UI renders. * Only resolution decides which levels may leave the value empty, so the * diagnostic can name the route and model. The assertion narrows * schemastery's `Dict`, which types every literal key as required; dict - * validation is per-present-key, so the runtime shape is the partial record. + * validation checks only present keys, so the runtime value is a partial record. */ const reasoningEfforts = z.dict( z.union([z.string(), z.const(null)]), @@ -237,7 +237,7 @@ export function assertServiceable(config: Config): void { resolveProfiles(config.providers) } -/** Reject a pre-release profile shape, naming the replacement. */ +/** Reject removed pre-release profile fields and name their replacements. */ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void { const legacy = source as PiAiProviderProfile & { provider?: unknown diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8a7dec1265..75d8d6f364 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,8 +184,8 @@ export interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ export abstract class LlmAdapter { /** diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 2673072fa0..7863e66d58 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -30,8 +30,8 @@ export interface ToolMessageSource { } /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -69,10 +69,10 @@ export interface ContextSnapshotSection { /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index ad7e8f66ba..70528bf53a 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -1,6 +1,6 @@ /** * Canonical provider-neutral message and streaming vocabulary for the loop, - * session log, and plugins. Adapters alone translate provider wire shapes; + * session log, and plugins. Adapters alone translate provider wire messages; * mapped interfaces make the content, source, and finish unions extensible. */ @@ -21,13 +21,13 @@ export type { UserMessage, } from './message.ts' -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ export interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -89,7 +89,7 @@ export interface ContentBlockMap { 'tool-result': ToolResultBlock } -/** The block `type` tag vocabulary; widens as plugins merge new shapes into {@link ContentBlockMap}. */ +/** The block `type` tag vocabulary; widens as plugins add entries to {@link ContentBlockMap}. */ export type ContentBlockType = keyof ContentBlockMap /** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */ export type ContentBlock = ContentBlockMap[ContentBlockType] diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index d3b024a2ee..a5ac463fb2 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -206,7 +206,7 @@ export class TokenMeterService extends Service { if (state.stepStart === undefined || state.stepStart.turn !== event.data.turn || state.stepStart.step !== event.data.step) { - throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`) + throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start event`) } nextStepStart = undefined break @@ -223,7 +223,7 @@ export class TokenMeterService extends Service { if (stepStart === undefined || stepStart.turn !== event.data.turn || stepStart.step !== event.data.step) { - throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`) + throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start event`) } // assistant/message is surface-mandatory at every append/seed boundary. diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 2a9323474e..3e0f9559e9 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: c404cfa73024804bc9f166cfb84fa5f87f723459 -README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410 +README.md: d7e19cc473695455df667cfd717703c2c303aafa +README.zh.md: e89b75df184d2283452ab069a2d559650f15bfef diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index c404cfa730..d7e19cc473 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes. +Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy enforce restrictions independently and do not read or write plan state. ## Durable state `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next accepted in-turn pre-step while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries are covered; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). +`ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). ## Model and human surfaces @@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other entry points may ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, so a failed handler cannot leave a recorded command without its plan selection). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Configuration @@ -91,8 +91,8 @@ Mode transitions do not change the tool catalog; plan arguments and review resul ## Known Limitations and Deferred Work -- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls. -- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it. +- Plan mode guides rather than enforces; deployments that need enforced restrictions must configure sandbox and approval controls independently. +- A selection made after the turn's final accepted pre-step is lost if the process exits before another accepted in-turn pre-step, so the UI must reapply it. - Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option. - A live child owned by another agent cannot open the `exit_plan_mode` review. The failed call tells the child to include the unresolved decision in its final result; durable fork lineage alone does not prevent a session resumed as a runtime root from opening the review. - Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow. diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 275a876698..e89b75df18 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行维度。 +按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略各自强制执行限制,且不读写 plan 状态。 ## 持久状态 `plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 -`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择,并等待下一个被接受的轮内 pre-step;返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界都在覆盖范围内;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 +`ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个 prompt 之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条追加路径皆然)。 ## 模型与人类交互 @@ -16,13 +16,13 @@ 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 -组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择到达请求边界之前将其取消。 +组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。 Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 @@ -91,8 +91,8 @@ mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩 ## 已知限制与暂缓事项 -- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 -- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 +- Plan mode 只进行引导,而不强制执行;需要强制限制的部署必须分别配置沙箱与批准控制。 +- 如果进程在另一个被接受的轮内 pre-step 之前退出,某轮最后一个被接受的 pre-step 之后作出的选择会丢失,因此 UI 必须重新应用它。 - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 - 由另一个 agent 所有的存活子级无法打开 `exit_plan_mode` 审阅。该调用失败时会提示子级在最终结果中包含尚未解决的决策;仅有持久化 fork 谱系并不会阻止恢复为运行时根的会话打开该审阅。 - 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 00234424ff..86da4d4935 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -1,20 +1,21 @@ /** * Plan mode is logged per-agent collaboration state: while active, a - * deployment-owned guidance section shapes each model request, and + * deployment-owned guidance section is included in each model request, and * `exit_plan_mode` presents the completed plan for user review, while the - * `/plan off` command lets a user leave directly. Plan mode is independent of - * sandbox mode and approval policy; those enforcement axes do not read or - * write plan state. + * `/plan off` command lets a user leave directly. Sandbox mode and approval + * policy enforce restrictions independently and do not read or write plan + * state. * * The state in force is folded from the session log (`plan/mode`, last one * wins), so resume and fork restore it without a live mirror. User selections - * are held as pending intent until an in-turn step boundary. The service - * projects pending intent into the proposed step assembly, then flushes it + * remain pending until the next accepted in-turn pre-step. The service includes + * the selected state in the proposed step assembly, then appends `plan/mode` * from `agent/pre-step` only when the step is accepted. Same-step request * retries reuse their assembly. * - * The exit tool remains registered while plan mode is inactive so crossing a - * boundary changes only the prompt section, not the request tool catalog. + * The exit tool remains registered while plan mode is inactive, so entering + * or leaving plan mode changes only the prompt section, not the request tool + * catalog. * * Agent Note: * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -97,7 +98,7 @@ function firstHeading(plan: string): string | undefined { /** * Validate deployment-owned plan guidance. Missing, blank, non-string, or - * unknown fields fail at plugin load rather than silently shaping nothing. + * unknown fields fail at plugin load rather than being ignored. * * @param config Raw plugin config. * @returns A detached validated config. @@ -176,7 +177,7 @@ function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefi } /** - * `ctx.planMode`: owns logged plan state, boundary application and narration, + * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, * the `plan:policy` section, the `/plan` command, and the stable exit tool. * UIs observe committed flips through `session/event`; there is no live mirror. */ @@ -187,7 +188,7 @@ export class PlanModeService extends Service { private readonly section: string /** - * Latest selection per session awaiting an in-turn request-boundary flush. + * Latest selection per session awaiting the next accepted in-turn pre-step. * `narrate` is true for user selections and false for the exit tool, whose * result already narrates the transition. */ @@ -197,10 +198,10 @@ export class PlanModeService extends Service { super(ctx, 'planMode') this.section = resolveConfig(config).section let disposed = false - // Pre-step is outside Session.append publication, so its log-only mode - // event can land between turns or inside an open turn without re-entering - // the session. A failed append remains pending for a later boundary, and - // policy cannot block the step. + // Pre-step is outside Session.append publication, so it can append the + // log-only mode event inside an open turn without re-entering the session. + // A failed append remains pending for a later accepted in-turn pre-step, + // and policy cannot block the step. ctx.on('agent/pre-step', async ( { agent, signal }, next, @@ -212,7 +213,7 @@ export class PlanModeService extends Service { try { this.onBoundary(agent.session) } catch (error) { - ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error) + ctx.logger.warn('dsh-plan-mode: failed to append selected plan mode at step start: %o', error) return decision } return !pending.narrate || narration === undefined @@ -234,8 +235,9 @@ export class PlanModeService extends Service { // The plan projection unit (session-projection RFC): a pure double-event // fold serving clients the whole {active, pending} value. `command/run` // records the user's logged /plan selection (the handler calls `set()` - // before any failing path, so log and run-plane cannot fork); `plan/mode` - // is the boundary commit that resolves it. Pending is thereby a pure + // before any failing path, so a failed handler cannot leave the recorded + // command without its plan selection); `plan/mode` records that selection + // and clears it. Pending is thereby a pure // replay quantity: host restarts, other tabs, and cold reads all recover // it from the log alone. The unit child activates only when a projection // registry is composed (headless assemblies stay unaffected). @@ -280,8 +282,9 @@ export class PlanModeService extends Service { case 'cancelled': return { kind: 'success', text: 'Plan mode entry cancelled.' } case 'noop': - // Repeat the queued wording while an exit still awaits its - // boundary; only a truly inactive session reads idempotent. + // Repeat the queued wording while an exit still awaits the + // next accepted pre-step; only a truly inactive session reads + // idempotent. return foldPlanMode(agent.session.events) ? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' } : { kind: 'success', text: 'Plan mode is already inactive.' } @@ -357,8 +360,8 @@ export class PlanModeService extends Service { } throw cause }) - // A review may outlive this plugin fiber. Without boundary listeners, - // an approved result could never land, so fail and keep planning. + // A review may outlive this plugin fiber. Without its pre-step listener, + // an approved selection could never be appended, so fail and keep planning. if (disposed) { throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again') } @@ -371,7 +374,8 @@ export class PlanModeService extends Service { : `The user chose to keep planning; their feedback: ${feedback}`) } // Keep plan guidance for the rest of this assistant tool batch. The - // silent intent flushes after the step, before the next assembly. + // silent selection is appended at the next accepted in-turn pre-step, + // before its request assembly. this.pendingIntents.set(agent.session, { active: false, narrate: false }) return { approved: true } }, @@ -390,7 +394,8 @@ export class PlanModeService extends Service { } /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -402,20 +407,20 @@ export class PlanModeService extends Service { } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' { const session = agent.session @@ -439,7 +444,7 @@ export class PlanModeService extends Service { return 'committed' } - /** Flush one pending selection before the next request assembly. */ + /** Append one pending selection before the next request assembly. */ private onBoundary(session: Session): void { const pending = this.pendingIntents.get(session) if (pending === undefined) return @@ -449,8 +454,8 @@ export class PlanModeService extends Service { return } session.append('plan/mode', { active: target }) - // Delete only after append succeeds so a later boundary can retry a failed - // durable write. + // Delete only after append succeeds so a later accepted in-turn pre-step + // can retry a failed durable write. this.pendingIntents.delete(session) } diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index eafd5f0aff..a3c10d2252 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -11,8 +11,8 @@ /** * The plan projection's wire value. `active` is the logged state in force * (the last `plan/mode`, inactive before the first); `pending` is true while - * a logged `/plan` selection (`command/run`) awaits its request-boundary - * `plan/mode` commit and targets a state other than `active`. Capability + * a logged `/plan` selection (`command/run`) targets a state other than + * `active` and no later `plan/mode` event has recorded that state. Capability * absence (plan-mode not composed) is the key's absence, never a value. */ export interface PlanProjection { diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index c504ff5df6..b1e546d8b7 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -69,7 +69,7 @@ describe('plan projection unit', () => { expect(bench.values()).toEqual({ plan: { active: false, pending: false } }) }) - it('a logged /plan selection reads pending until the boundary commit resolves it', async () => { + it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 13fa52ce9d..ba2e21b8e9 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-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/sandbox/sandbox-local/README.md -README.md: 23d3a32451c105c71c0a7399ed051288b70753f3 -README.zh.md: 1890771faf8cab6b1842f973a999c7a9cf2dbb11 +README.md: 4d9e8275ba3fe0c1f49555b61e319f52194244bc +README.zh.md: 8a755e6c5b0c266538277bbbcd118fd24ab164f3 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 23d3a32451..4d9e8275ba 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -35,4 +35,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. -- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. +- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-compatible profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 1890771faf..8a755e6c5b 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -35,4 +35,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list - **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 -- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 +- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现与 bwrap 兼容的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 42a150b855..fc19a8dbea 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -42,7 +42,7 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './pr /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the runner argv; bwrap-shaped profile arguments are appended. A + * Override the runner argv; bwrap-compatible profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and * probing. A runner that starts but refuses its profile must be identifiable by * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 90b8bf65fd..20fd176af6 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -13,7 +13,7 @@ export const name = 'sandbox-policy-invariant' export const inject = ['invariants'] /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) { fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`) diff --git a/packages/scaffold/client/src/api.ts b/packages/scaffold/client/src/api.ts index 6e76efa417..d615caece5 100644 --- a/packages/scaffold/client/src/api.ts +++ b/packages/scaffold/client/src/api.ts @@ -203,7 +203,7 @@ export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] { return typeof input === 'string' ? [{ type: 'text', text: input }] : input } -/** Validate a wire `session.event` envelope to the shape the typed result exposes. */ +/** Validate the fields in a wire `session.event` envelope before returning the typed result. */ function validatedSessionEvent(value: unknown): SessionEvent { if (!isRecord(value) || typeof value.type !== 'string') { throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`) diff --git a/packages/scaffold/helper/src/documents/tsconfig-file.ts b/packages/scaffold/helper/src/documents/tsconfig-file.ts index 368b93eb43..2f61e10951 100644 --- a/packages/scaffold/helper/src/documents/tsconfig-file.ts +++ b/packages/scaffold/helper/src/documents/tsconfig-file.ts @@ -70,7 +70,7 @@ export class TsConfigFile extends ProjectFile { )) } - /** Validate JSONC and the project-reference shape. */ + /** Validate JSONC and the project-reference fields. */ override validate(): void { const value = parseConfig(this.text) if (value.references === undefined) return diff --git a/packages/scaffold/helper/src/features/define-feature.ts b/packages/scaffold/helper/src/features/define-feature.ts index 84b020591c..720f15ecd6 100644 --- a/packages/scaffold/helper/src/features/define-feature.ts +++ b/packages/scaffold/helper/src/features/define-feature.ts @@ -114,7 +114,7 @@ function configDiagnostics( if (!expected || Object.keys(expected).length === 0) return undefined return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key]) ? [] - : [`${key} has an incompatible value shape`]) + : [`${key} has fields or value types that do not match the expected config`]) } function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] { diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts index 93d51c5ffe..6fec1e84e3 100644 --- a/packages/scaffold/helper/src/features/feature.ts +++ b/packages/scaffold/helper/src/features/feature.ts @@ -236,7 +236,7 @@ export abstract class Feature { } /** - * Inspect current files and reject any partial or ambiguous owned shape. + * Inspect current files and reject any partial or ambiguous owned file set. * @param project - project snapshot to inspect. * @returns installation state, selection, and diagnostics. */ diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts index 1251fe45e2..46a020f40a 100644 --- a/packages/self-modification/repository-plugin/src/index.ts +++ b/packages/self-modification/repository-plugin/src/index.ts @@ -92,7 +92,7 @@ async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise process.env, directory, // Schemastery call signatures collapse the parameter to `never` under - // NodeNext; ResolvedMcpServer is shaped for the Config union by design. + // NodeNext; ResolvedMcpServer matches the Config union by design. ).map(input => McpClient.Config(input as never)) await ctx.effect(async function* () { diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index fdb852533d..929bddece9 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -486,7 +486,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'httpServer', - summary: 'The web-shape HTTP carrier service.', + summary: 'The browser HTTP carrier service.', methods: [ { signature: 'register(route: WebRoute): () => void', @@ -602,15 +602,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'planMode', - summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.', + summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.', methods: [ { signature: 'get(agent: Agent): { active: boolean; pending?: boolean }', - jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */', + jsDoc: '/**\n * Read the logged plan state and any selected state awaiting the next\n * accepted in-turn pre-step.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */', }, { signature: 'set(agent: Agent, active: boolean): \'committed\' | \'queued\' | \'cancelled\' | \'noop\'', - jsDoc: '/**\n * Select whether plan mode should be active. Between turns the change\n * commits immediately — no request boundary would arrive until the next\n * prompt, so a queued intent would hang (the open-turn fold is the idle\n * signal: agent status stays `running` through post-turn checkpointing,\n * where a boundary equally never comes). During an open turn the\n * selection is held as pending intent for the next in-turn request\n * boundary. Repeated selection of the current or already-pending state is\n * a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next boundary), `cancelled` (an opposite pending selection was cleared;\n * the logged state already matches), or `noop` (already in that state).\n */', + jsDoc: '/**\n * Select whether plan mode should be active. Between turns the method\n * appends the change immediately because no in-turn pre-step will run until\n * another prompt starts a turn. The open-turn fold is the idle signal:\n * agent status stays `running` through post-turn checkpointing, when no\n * further in-turn pre-step runs. During an open turn the selection remains\n * pending until the next accepted in-turn pre-step. Repeated selection of\n * the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next accepted in-turn pre-step), `cancelled` (an opposite pending selection\n * was cleared; the logged state already matches), or `noop` (already in that\n * state).\n */', }, ], }, @@ -746,7 +746,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'register(definition: ProjectionDefinition): () => void', - jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, boundary schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */', + jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, state schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */', }, { signature: 'onChanged(listener: ProjectionChangeListener): () => void', @@ -820,11 +820,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async readSurface(sessionId: SessionId): Promise', - jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */', + jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and the last sequence number included in the raw-log capture.\n * @throws when source resolution fails or the session surface is invalid.\n */', }, { signature: 'async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise', - jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', + jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or the first parent that could not be resolved.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', }, { signature: 'async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise', @@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'telemetry', - summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', + summary: 'Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', methods: [ { signature: 'abstract emit(record: TelemetryRecord): void', @@ -1308,7 +1308,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise', - jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query plus result-shaping options.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */', + jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query and optional result limit.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */', }, { signature: 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise', @@ -1630,8 +1630,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/code-dispatch-log', mode: 'waterfall', signature: '\'tools/code-dispatch-log\'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise', - jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', - summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', + jsDoc: '/**\n * Allow a listener to replace content in the DURABLE LOG COPY of one\n * `run_code` sub-dispatch outcome before the bridge appends its\n * `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the original settled content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', + summary: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', }, { name: 'tools/execute', diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts index 6b3e20a82c..f093da3e24 100644 --- a/packages/self-modification/tool-cordis/src/sandbox.ts +++ b/packages/self-modification/tool-cordis/src/sandbox.ts @@ -56,8 +56,8 @@ const TIMER_REDIRECT /** * The callable Node APIs the sandbox deliberately disables, each mapped to the - * cordis alternative its trap error names. Only FUNCTION-shaped globals are - * trapped — a data-shaped global like `process` stays `undefined`, because a + * cordis alternative its trap error names. Only function-valued globals are + * trapped; a data-valued global such as `process` stays `undefined`, because a * throwing accessor would detonate the common `typeof process` feature probe * at resolution time. */ diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index b891f5750d..919bf00c88 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -257,7 +257,7 @@ export abstract class SessionQueryService extends Service { /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise { @@ -273,7 +273,7 @@ export abstract class SessionQueryService extends Service { * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise { diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index fd62306b1a..809982f94d 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -211,8 +211,8 @@ export function logPath( * `packChunks` on, delta-chunk runs pack into `text-chunks` / * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event * per line, byte-identical to the pre-packing layout. Reading is layout-blind - * either way ({@link scanLog} always decodes rows), so the switch only shapes - * NEW bytes. + * either way ({@link scanLog} always decodes rows), so the switch changes only + * newly written bytes. * @param events - the batch to serialize, in log order. * @param packChunks - whether to pack delta runs into storage rows. * @returns the batch's JSONL text; the writer adds the final newline. diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 74a0d4c8a9..15808bb5e4 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/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/session/session-persistence/README.md -README.md: c64826db1e9c7339f43a64ad7c13f01a2a39638e -README.zh.md: 651d920404300fa602f77cee63b0f31aec837919 +README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2 +README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index c64826db1e..391548b1b8 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The durable session-persistence Service Definition (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a Service provider in a sibling package, and Consumers that inject the service. +Session persistence is a capability seam. The abstract `SessionPersistence` service (`ctx.sessionPersistence`) is its Service Definition. It requires a persistence backend to store, reload, and list sessions durably without defining the storage implementation. The seam follows the `dsh-bash` roles ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): this package owns the Service Definition, a sibling package owns the Service provider, and Consumers inject the service. The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. @@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed shapes, and unknown `version` reject. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -35,7 +35,7 @@ Each `session/event` copies its event into the session controller. The first pen Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. +Backend reads convert the exact supported older records from the same format version before validating current records. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same converted view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current format. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 651d920404..7213e1ee71 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是用于持久保存会话的 Service Definition(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 能力 seam 模板一致(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供 Service provider,Consumer 注入服务。 +会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-bash` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition,同级包负责 Service provider,Consumer 注入该服务。 持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。 @@ -14,9 +14,9 @@ | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 | +| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非受支持的旧形状需要前缀上下文才能完成规范化;顺序后端(JSONL)解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -35,7 +35,7 @@ 崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 -后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构旧记录中未命名调用方的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 1d0364d1f2..154a5ca2fa 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -65,7 +65,7 @@ export interface ProjectionDefinition { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -188,7 +188,7 @@ export class SessionProjectionRegistry extends Service { * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register(definition: ProjectionDefinition): () => void { diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index b66d641750..50776f7d3f 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -3,9 +3,8 @@ * * Composes the OTel JS SDK as-is — a `LoggerProvider` with a * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each - * record handed over by the capture coordinator onto `logger.emit()`. Per the Service Definition's - * boundary axiom, everything downstream of that call (batching, retry, - * queueing, loss policy) is the SDK's documented behavior, configured + * record handed over by the capture coordinator onto `logger.emit()`. After that call, + * batching, retry, queueing, and loss policy use the SDK's documented behavior, configured * verbatim through the `exporter`/`processor` passthroughs. This package owns * capture mode and an outer shutdown deadline: the SDK's export timeout does * not bound its preceding `forceFlush()` wait. @@ -73,7 +72,7 @@ function assertNever(value: never): never { } /** - * Plugin configuration: one sharing policy, two verbatim SDK option shapes, + * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint * and shutdown deadline at plugin load; `DISABLED` reads neither. */ @@ -101,11 +100,10 @@ export interface Config { /** * Schemastery validator for {@link Config}; cordis runs it before the plugin - * starts. Shape-level only — load-bearing value checks live in the constructor - * so their errors name the fields. Both SDK slots are opaque passthroughs: - * the SDK owns their shapes and validates its own options; - * re-declaring them field-by-field here would violate the boundary axiom - * (and silently drop every field not re-declared). + * starts. It checks only the top-level fields; value checks live in the constructor + * so their errors name the fields. Both SDK option objects pass through unchanged: + * the SDK defines and validates their fields. Re-declaring them here would + * silently drop every field this plugin did not repeat. */ export const Config: z = z.object({ mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE), diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index c6c4b9eff9..3d4650361f 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/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/session/session-telemetry/README.md -README.md: 506f0f5bcb03a54f09805ed0f866a396e3cf0334 -README.zh.md: b5e47c8832452ece281dcdcfac007d6082813583 +README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173 +README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 506f0f5bcb..827554dd53 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The telemetry Service Definition and capture coordinator sit behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). +The telemetry Service Definition declares the `TelemetryBackend` contract, and its capture coordinator passes session records to any reporting SDK backend that implements it. Capture can follow live session events or replay a canonical session-log prefix on demand. This package stops after it calls `emit()`: batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its owning trigger. +`TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger. ## Capture points diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index b5e47c8832..a350ea5935 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -遥测(telemetry)Service Definition 与捕获协调器位于一个后端约定之后,任何上报 SDK 都无需变形即可满足该约定。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 +遥测(telemetry)Service Definition 声明 `TelemetryBackend` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 ## 后端约定 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。 +`TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。 ## 捕获点 diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 977527aace..7ddd85fe8e 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -87,9 +87,8 @@ export interface TelemetryRecord { } /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ export interface TelemetryBackend { @@ -104,8 +103,8 @@ export interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -132,7 +131,7 @@ export interface TelemetryBackend { } /** - * The backend contract in its loadable form: one implementation per context — + * Loadable form of the backend contract: one implementation per context — * the cordis `Service` registration under the `telemetry` key throws on a * duplicate, cordis' standard behavior. A backend composes a * {@link TelemetryCoordinator} in its constructor to install the capture side. diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 4452e78dab..fba3913f75 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/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/settings/settings/README.md -README.md: 5bfbf4623c937c2b66886f71adf27075523f5d28 -README.zh.md: 98808424ba1af2210067bd7f74baa6027decbb06 +README.md: 7917f38017bfb23dc4718ee533c1f9a92b519d41 +README.zh.md: f46cf433b4d2207b17b0f40cc3b9cf70794b512c diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index 5bfbf4623c..7917f38017 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -11,7 +11,7 @@ User-settings Service Definition (`ctx.settings`). One provider holds a raw docu - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. - `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires. - `get(ns)` — resolved value, `undefined` while unregistered. -- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. +- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches may contain only JSON-compatible data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently change such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. - `replace(ns, section)` — sets the user section wholesale: the deliberate reset (`replace({})` re-inherits `base` and schema defaults). - `mutate(ns, ops)` — applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue. This is the removal path for any caller holding an INCOMPLETE view: a configuration UI reads the redacted descriptor, so rebuilding a section from it and replacing wholesale deletes every secret the wire never returned, while an op names the one field it means. - Every write takes an optional `expectedRevision`. Each descriptor carries the namespace's `revision`, a monotonic counter over its RAW section; a write whose expectation no longer matches rejects with `SettingsConflictError` (`code: 'SETTINGS_CONFLICT'`, both revisions attached) instead of overwriting the writer that landed first. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 98808424ba..f46cf433b4 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -11,7 +11,7 @@ - `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 - `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 封装、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个协议接口都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。 - `get(ns)` — 解析值;未注册时为 `undefined`。 -- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 +- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 只能包含与 JSON 兼容的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默改变这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 - `replace(ns, section)` — 整体替换用户分节:这是刻意的重置(`replace({})` 重新继承 `base` 与 schema 默认值)。 - `mutate(ns, ops)` — 在写入排到队首那一刻的分节上,按序施加 `{ op: 'set' | 'unset', path }` 编辑。这是任何持有**不完整**视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor,据此重建分节再整体替换,会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。 - 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision)被拒绝,而不是覆盖先完成写入的写入方。写队列只保证写入的先后次序,它本身分辨不出新的写入方与持有陈旧快照的写入方。 diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index d0f85a2b1b..37d3ec1d50 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -120,14 +120,14 @@ export interface SettingsScope { watch(callback: (next: T, prev: T) => void | Promise): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise @@ -172,11 +172,11 @@ declare module 'cordis' { } /** - * Deep equality over JSON-shaped data (objects, arrays, primitives) — the + * Deep equality over JSON-compatible data (objects, arrays, primitives) — the * Service Definition's single change-detection predicate, exported so the invariant * companion checks exactly the implementation's relation. - * @param a - one JSON-shaped value. - * @param b - the other JSON-shaped value. + * @param a - one JSON-compatible value. + * @param b - the other JSON-compatible value. * @returns whether the two values are structurally equal. */ export function deepEqualJson(a: unknown, b: unknown): boolean { @@ -264,7 +264,7 @@ function applyPathOp(section: Record, op: SettingsPathOp): Reco return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) } } -/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */ +/** Human label for a value that lossless JSON cannot represent (numbers reject inline). */ function describeRejected(value: unknown): string { if (value === undefined) return 'undefined' if (typeof value === 'object' && value !== null) { @@ -276,16 +276,16 @@ function describeRejected(value: unknown): string { } /** - * Detach one write input in a single walk that doubles as the durable-boundary - * shape check: only JSON data (plain objects, arrays, strings, finite numbers, + * Detach and validate one write input in a single walk before persistence: + * only JSON data (plain objects, arrays, strings, finite numbers, * booleans, `null`) may reach a provider document. `structuredClone` alone * would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then * silently distorts on the reload round-trip. `undefined` entries in objects * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while * an `undefined` array entry is rejected rather than coerced. * @param root - plain-object write input (caller-checked). - * @param reject - builds the boundary error from a value label and its `$`-rooted path. - * @returns the detached JSON-shaped clone. + * @param reject - builds the validation error from a value label and its `$`-rooted path. + * @returns the detached JSON-compatible clone. */ function cloneJsonShaped( root: Record, @@ -640,9 +640,9 @@ export abstract class Settings extends Service { } // Snapshot at call time: the queue must never read a caller-owned object // the caller may keep mutating while the write waits its turn. The same - // walk is the JSON-shape boundary check (see cloneJsonShaped). + // walk rejects values that JSON cannot preserve (see cloneJsonShaped). const snapshot = cloneJsonShaped(payload, (label, path) => - new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`)) + new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`)) const previous = this.writeQueues.get(ns) ?? Promise.resolve() // Chain past a failed predecessor: one rejected write must not poison the // namespace queue for every later caller. diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 66c0f1fcf6..fb0efe1ff8 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -479,11 +479,11 @@ describe('second review regressions', () => { expect(applied).toEqual([1, 2]) }) - it('rejects a function value as not JSON-shaped', async () => { + it('rejects a function value as not JSON-compatible', async () => { const { ctx } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) await expect(scope.update({ theme: () => 'dark' })) - .rejects.toThrow(/JSON-shaped.*function at \$\.theme/) + .rejects.toThrow(/JSON-compatible.*function at \$\.theme/) }) it('rejects a write still queued when the service disposes', async () => { @@ -616,7 +616,7 @@ describe('third review regressions', () => { const { ctx, provider } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) await expect(scope.update({ value: { at: new Date(0) } })) - .rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/) + .rejects.toThrow(/JSON-compatible.*Date at \$\.value\.at/) expect(provider.persisted).toEqual([]) }) @@ -914,10 +914,10 @@ describe('mutate (path-addressed writes)', () => { expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' }) }) - it('rejects a value the JSON-shape boundary refuses', async () => { + it('rejects a value that lossless JSON cannot represent', async () => { const ctx = await mounted({ keyed: {} }) await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: ['baseURL'], value: new Date() }])) - .rejects.toThrow(/must be JSON-shaped data/) + .rejects.toThrow(/must contain only JSON-compatible data/) }) }) diff --git a/packages/storage/storage-domain/src/spec.ts b/packages/storage/storage-domain/src/spec.ts index 9e49ef41e3..bf5de09a29 100644 --- a/packages/storage/storage-domain/src/spec.ts +++ b/packages/storage/storage-domain/src/spec.ts @@ -65,7 +65,7 @@ export function domainTable(schema: ZodType): DomainTabl } /** - * Identity helper that pins a spec's literal types and validates its shape. + * Identity helper that pins a spec's literal types and validates its fields. * Misconfiguration fails loud at the owning package's module load, before any * medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version * that is not a non-negative integer, or a global schema that accepts `null` diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index d9070874ca..52a09a0185 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -1,21 +1,21 @@ /** * Backend-facing vocabulary of the storage hub: a backend owns one medium - * (a file-tree root, a database file) and exposes data-shape facets over it. - * This module is the normative contract text for backend implementers; the - * shared conformance suite in `tests/contract.ts` asserts every clause. + * (a file-tree root, a database file) and exposes operation groups over it. + * This module defines the normative contract text for backend implementers; the shared + * conformance suite in `tests/contract.ts` checks every rule. * @module @deepseek-ai/dsh-storage/src/backend */ -/** Allowed shape for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */ +/** Allowed format for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */ export const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/ /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ export interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 01daa65f77..193e3b8dea 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/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/subagent/subagent-acp/README.md -README.md: 33711eb93c38d93472b3b625a86721c1365ac8d9 -README.zh.md: e57010f395e4ebae9b2e909bf63b7ffa1a90afe2 +README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669 +README.zh.md: 80afd65e5f4815042f05c22e4597bbb2677fb4fc diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 33711eb93c..3bccddbca0 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -28,7 +28,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `command` | required | Executable spawned for each run. | | `args` | `[]` | Command arguments. | | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. | -| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | +| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index e57010f395..80afd65e5f 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -28,7 +28,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `command` | 必填 | 每次运行时 spawn 的可执行文件。 | | `args` | `[]` | 命令参数。 | | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | -| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 | +| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | | `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 7cc781ca15..af126b86de 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -42,7 +42,7 @@ export interface Config { /** * How to auto-answer the child's `session/request_permission` prompts: * `reject` (default — decline every prompt) or `allow` (approve via the first - * allow-shaped option). No prompt is surfaced to a human. + * `allow_once` or `allow_always` option). No prompt is surfaced to a human. */ permission: PermissionPolicy /** diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 17476be41c..7c82bfe9fb 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -248,8 +248,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe return Promise.resolve() }, requestPermission(params: RequestPermissionRequest): Promise { - // Auto-answer by the configured policy. `allow` selects the first - // allow-shaped option the child offered; if it offered none (or we + // Auto-answer by the configured policy. `allow` selects the first option + // whose kind is `allow_once` or `allow_always`; if the child offered none (or we // reject), answer `cancelled` so the child does not proceed. if (spec.permission === 'allow') { const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always') diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index b1df485644..de2fd37115 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -219,8 +219,9 @@ export interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index c0cfc56e24..5cdffadf38 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -436,7 +436,7 @@ export function formatToolSchemasSnapshot(initial: readonly unknown[], changes: } /** - * Parse and validate the stable top-level shape of a tool-schema sidecar. + * Parse and validate the stable top-level fields of a tool-schema sidecar. * * @param snapshot The JSON sidecar text. * @returns Its initial and changed-header schema sets. diff --git a/packages/support/invariants/README.i18n.yaml b/packages/support/invariants/README.i18n.yaml index 9d85ffd0a5..ea7fbba8f3 100644 --- a/packages/support/invariants/README.i18n.yaml +++ b/packages/support/invariants/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/support/invariants/README.md -README.md: 71f7fb8b913610aad03694effad7746c0cb16499 -README.zh.md: ce2fc593a294a7f870bb42faa980fab462348e3b +README.md: 9a93187032b6f8e4f5d89e17e742baba41196ff9 +README.zh.md: 7f3fa1e23337e55a73928c3952aa4c925a5fb4e9 diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 71f7fb8b91..9a93187032 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -30,7 +30,7 @@ Session itself owns immutable, surface-valid log storage in every composition: i Publication and registration are exhaustive; runtime assertions are deliberately not synthetic. A companion installs a check only when its package owns an observable event relationship or relevant mutable-data relationship. Confirming a required method, plugin name, injection, effect, or fixed pure-function result is a type, load, or unit-test concern rather than a runtime invariant. -When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their seam, composition-only packages, binaries, persistence adapters whose contracts require crash/round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. +When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their interface package, composition-only packages, binaries, persistence adapters whose contracts require crash and round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. The current executable companions protect these relationships: @@ -66,7 +66,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. +The standard agent composition mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. Every ordinary Vitest topology mounts an explicitly enabled service and the current test package's companion. Focused suites cover valid and invalid observations for executable companions, while one exhaustive topology mounts all companions to prove registration and disposal wiring. diff --git a/packages/support/invariants/README.zh.md b/packages/support/invariants/README.zh.md index ce2fc593a2..7f3fa1e233 100644 --- a/packages/support/invariants/README.zh.md +++ b/packages/support/invariants/README.zh.md @@ -30,7 +30,7 @@ interface Config { 发布和注册覆盖全部包;但不会为了覆盖全部包而人为编造运行时断言。只有当包拥有可观察事件关系或相关可变数据关系时,配套入口才安装检查。确认必需方法、插件名称、注入、effect 或固定纯函数结果属于类型、加载或单元测试关注点,而非运行时不变量。 -如果不存在合理的运行时关系,配套入口使用空 installer,并以包专用的前置 `No runtime invariant:` 注释说明原因。纯工具、行为已通过 seam 观察的薄实现、仅组合包、二进制程序、约定需要崩溃/往返测试的持久化适配器和测试支持包通常属于此类。当 owner 获得可变状态或事件协议时,必须重新审视该说明。 +如果不存在合理的运行时关系,配套入口使用空 installer,并以包专用的前置 `No runtime invariant:` 注释说明原因。纯工具、行为已通过其接口包观察的薄实现、仅组合包、二进制程序、需要通过崩溃测试和往返测试验证其约定的持久化适配器和测试支持包通常属于此类。当 owner 获得可变状态或事件协议时,必须重新审视该说明。 当前可执行配套入口保护以下关系: @@ -66,7 +66,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -标准 agent 主干挂载服务和 4 个核心有状态配套入口。自定义组合为希望检查其约定的其他已加载包显式添加配套入口;过滤器可以在不改变包入口的情况下禁用或选择注册。 +标准 agent 组合挂载服务和 4 个核心有状态配套入口。自定义组合为希望检查其约定的其他已加载包显式添加配套入口;过滤器可以在不改变包入口的情况下禁用或选择注册。 每个普通 Vitest 拓扑都挂载显式启用的服务和当前测试包的配套入口。聚焦套件覆盖可执行配套入口的合法与违规观测,一个穷尽拓扑则挂载全部配套入口,以证明注册和 dispose 接线。 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..9f18c18271 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -281,7 +281,7 @@ const REPLAY_CHUNK_TYPES = new Set([ const FROM_REQUEST_OPEN = '{{fromRequest:' const FROM_REQUEST_CLOSE = '}}' -/** Collect every string leaf of one JSON-shaped value, in traversal order. */ +/** Collect every string leaf of one JSON-compatible value, in traversal order. */ function collectStrings(value: unknown, out: string[]): void { if (typeof value === 'string') { out.push(value) @@ -333,7 +333,7 @@ function substituteString(text: string, corpus: string): string { } } -/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */ +/** Deep-copy one JSON-compatible value with scripted placeholders resolved. */ function substituteValue(value: unknown, corpus: string): unknown { if (typeof value === 'string') { return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 60a7beb012..807bf66cf1 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -114,8 +114,8 @@ export class LocalTaskService extends TaskService { void hooks.done.then( (outcome) => { this.settle(task, outcome) }, (error: unknown) => { - // Contain a producer contract violation so cleanup and waiters cannot hang. - this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`) + // Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang. + this.selfCtx.logger.warn(`tasks: task ${task.id} producer done promise rejected (producer contract violation): ${String(error)}`) this.settle(task, { status: 'failed', detail: String(error) }) }, ) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..35d7e77ff2 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -201,7 +201,7 @@ describe('LocalTaskService reads and settlement', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom')) }) - it('contains a rejecting done as a failed outcome (producer contract violation)', async () => { + it("contains rejection from the producer's done promise as a failed outcome (producer contract violation)", async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const p = producer() diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 0cfe48a6f2..8c2a7aa7dd 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -39,7 +39,7 @@ function validateTodos(value: unknown, fail: InvariantFailure): void { } /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'todo/write') validateTodos(event.data.todos, fail) } diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 575d066e0d..2ae9722dea 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -18,8 +18,8 @@ * current entries. Package verdicts and imported manifests are cached per * package name and never expire — plugin-set changes take effect on restart. * - * Manual `ctx.typert.register()` remains the escape hatch for contributions - * that do not ride a `./typert` artifact (hand-written contract schemas, + * Manual `ctx.typert.register()` remains available for contributions + * that do not use a `./typert` artifact (hand-written wire schemas, * tests, non-loader compositions). * * @module @deepseek-ai/dsh-typert-loader @@ -68,7 +68,7 @@ function typertExportOf(pkgName: string, exportsField: unknown): string | undefi const fallback = (target as Record).default if (typeof fallback === 'string') return fallback } - throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] has an unsupported shape`) + throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] must be a string or an object with a string default`) } /** diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index af5d451351..491538a533 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -356,7 +356,7 @@ describe('typert loader', () => { await ctx.loader.create({ name: '@fixture/export-primitive' }) await ctx.loader.await() - await expect(mountTypertLoader(ctx)).rejects.toThrow('unsupported shape') + await expect(mountTypertLoader(ctx)).rejects.toThrow('must be a string or an object with a string default') }) it('caches a negative verdict for loader entries without a package root', LOADER_TEST_TIMEOUT, async () => { diff --git a/packages/util/retention/src/index.ts b/packages/util/retention/src/index.ts index 1aca8b6aaf..73f44191a8 100644 --- a/packages/util/retention/src/index.ts +++ b/packages/util/retention/src/index.ts @@ -140,7 +140,7 @@ function assertBudget(value: number, name: string): void { * * Grouping, sorting, path mapping, per-unit preview truncation, and any * `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing - * more. The caller pushes already-shaped units and, after {@link finish}, + * more. The caller pushes prepared logical units and, after {@link finish}, * groups/sorts the retained subset itself. */ export class ItemRetainer { @@ -160,7 +160,7 @@ export class ItemRetainer { * and counted as omitted. Callers keep pushing all observed units, so the final * {@link Omitted} count is exact. * - * @param item The already-shaped logical unit (path, flat match, source). + * @param item The prepared logical unit (path, flat match, source). * @returns The per-push {@link PushDecision}. */ push(item: T): PushDecision { @@ -253,7 +253,7 @@ export class TextRetainer { private suffixHeld = 0 private total = 0 - /** @param strategy One of the {@link TextRetentionStrategy} shapes; byte budgets must be non-negative integers. */ + /** @param strategy One {@link TextRetentionStrategy} variant; byte budgets must be non-negative integers. */ constructor(strategy: TextRetentionStrategy) { switch (strategy.kind) { case 'head': diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index f8f32c568a..aad4d8728c 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -58,7 +58,7 @@ export const Config: z = z.object({ fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS), }) -/** The shape after schemastery applies its defaults to every field. */ +/** Complete config after schemastery applies every field default. */ type ResolvedConfig = Required /** Configured count, timeout, and character caps must be positive integers. */ diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index 6e1e6b2bf6..a5636b37ee 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -55,7 +55,7 @@ export const Config: z = z.object({ userAgent: z.string().default(DEFAULT_USER_AGENT), }) -/** The shape after schemastery applies its defaults to every field. */ +/** Complete config after schemastery applies every field default. */ type ResolvedConfig = Required /** A resource limit (byte/char/length/timeout cap) must be a positive finite number. */ diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index ff71d11c60..a41d1f8344 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -224,7 +224,7 @@ function resolveRedirect(location: string, base: URL): URL { /** * Translate a thrown fetch/stream error into a `WebError`, classified by the - * deadline signal rather than the error's shape (which differs by phase: the + * deadline signal rather than the thrown value (which differs by phase: the * request-phase `fetch` rejects with the abort reason, while the read-phase * reader surfaces a bare `AbortError`). `timeoutOf(signal, 'WEB_FETCH_TIMEOUT')` * recovering OUR reason means our timeout fired (`WEB_FETCH_TIMEOUT`); any other diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 59da2e33e0..ded152cc81 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -133,7 +133,7 @@ export class WebService extends Service { * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index 316b6b5c11..3ac4344ace 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -1,7 +1,7 @@ /** * Vocabulary for the web capability seam (`ctx.web`). Search and fetch deliberately share one * seam so provider selection, cancellation, errors, and product configuration have one owner, - * while retaining separate request and result shapes. + * while retaining separate request and result types. * @module @deepseek-ai/dsh-web/types */ diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 219318f8f3..209ac7758c 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/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/workflow/tool-workflow/README.md -README.md: 4b75ce5b6a248949f135bfe1883680a645a4c774 -README.zh.md: 930da9d0975cd334a7a7cabf59958a9e11106861 +README.md: 29896bee0f78a1d1764c3908965325fcecbf7b53 +README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2 diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 4b75ce5b6a..29896bee0f 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns schema and lifecycle shaping over [`ctx.workflows`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope. +The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns the model-facing schema and run lifecycle over [`ctx.workflows`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope. ## What the model sees diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 930da9d097..12e1ecd893 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 塑造 schema 和生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 +面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 定义面向模型的 schema 和运行生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 ## 模型看到的内容 diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 2531a72241..6c1e9b19bb 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -1,6 +1,6 @@ /** * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out - * subagents, and return the script's final value. Pure schema + lifecycle shaping — script + * subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script * parsing, execution, caps, and cancellation live behind `ctx.workflows` * (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model * sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool diff --git a/packages/workflow/workflow-workerthread/src/meta.ts b/packages/workflow/workflow-workerthread/src/meta.ts index 5412345178..5ff48f606b 100644 --- a/packages/workflow/workflow-workerthread/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -1,6 +1,6 @@ /** - * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against the shape - * contract and reject everything else loud, every violation named. Meta arrives as schema-checked + * Meta validation checks caller-provided DATA against the {@link WorkflowMeta} + * contract and rejects every violation by name. Meta arrives as schema-checked * JSON data, never evaluated script text; evaluating it on the host could run getters outside the * worker timeout that exists to isolate model-written code. * @module @deepseek-ai/dsh-workflow-workerthread/meta diff --git a/packages/workflow/workflow-workerthread/src/realm.ts b/packages/workflow/workflow-workerthread/src/realm.ts index cdcc86f8a0..546a4b56e1 100644 --- a/packages/workflow/workflow-workerthread/src/realm.ts +++ b/packages/workflow/workflow-workerthread/src/realm.ts @@ -1,7 +1,7 @@ /** * Materializes values leaving the script vm into plain JSON before they cross the worker * boundary, and renders thrown script values without rejecting the run. The walk rejects - * lossy JSON shapes but trusts model-written workflow scripts: getters and proxy traps may + * values that JSON cannot preserve but trusts model-written workflow scripts: getters and proxy traps may * run, and the vm is not a security boundary. The worker provides host-loop isolation and * forced termination, not hostile-value containment. See * .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale. @@ -22,7 +22,7 @@ export class MaterializeError extends Error { * fall back to `message`, then `String()`. Reading those properties MAY run * script code (a getter, `toString`) — accepted under the module's trust * premise; if that code itself throws, a fixed label is returned instead. - * @param error - the thrown value, of any shape and any realm. + * @param error - any value thrown in the host or worker realm. * @returns human-readable text for the failure report; prefers the stack. */ export function renderThrown(error: unknown): string { @@ -40,7 +40,7 @@ export function renderThrown(error: unknown): string { } /** - * Whether an object's prototype chain is data-shaped: `null`, or a prototype + * Whether an object's prototype chain represents a plain data object: `null`, or a prototype * whose own prototype is `null` (the realm's `Object.prototype` — which we * cannot compare by identity across realms). A `Date`/`Map`/class instance * has a longer chain and is rejected. @@ -87,9 +87,9 @@ function materialize(value: unknown, path: string, seen: Set): unknown { case 'bigint': throw new MaterializeError(path, 'bigints are not JSON data') case 'function': - throw new MaterializeError(path, 'functions cannot cross the workflow value boundary') + throw new MaterializeError(path, 'functions are not plain JSON data') case 'symbol': - throw new MaterializeError(path, 'symbols cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbols are not plain JSON data') case 'undefined': throw new MaterializeError(path, 'undefined is not JSON data') case 'object': @@ -122,7 +122,7 @@ function materializeArray(value: unknown[], path: string, seen: Set): un } } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data') } return out } @@ -132,7 +132,7 @@ function materializeObject(value: object, path: string, seen: Set): Reco throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)') } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data') } const out: Record = {} // Object.keys = own enumerable string keys, matching JSON.stringify's diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 6bc749cbd9..e807d1ef5b 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -1,5 +1,5 @@ /** - * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result shaping; it + * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result serialization; it * never touches Cordis. Script values leaving the realm are materialized as plain JSON before * messaging. Values entering the trusted model-written realm are passed directly; `args` alone is * cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 12386659bc..bdf933a3f7 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -1,6 +1,6 @@ /** * Workflow seam vocabulary: the request/run/result types a workflow engine - * consumes and produces, plus the payload shapes of the `workflow/*` events. + * consumes and produces, plus the fields in the `workflow/*` event payloads. * Types only (plus the id-brand factory), per the package convention. * * @module @deepseek-ai/dsh-workflow/types @@ -57,8 +57,8 @@ export interface WorkflowMeta { /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -66,7 +66,7 @@ export interface WorkflowMeta { export interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 9e0a17e270..0086d8519f 100644 --- a/python/README.i18n.yaml +++ b/python/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 python/README.md -README.md: 27637edb9d4d5e8714fe379a00b6aae3af1a541f -README.zh.md: b0587b25b66750780768786e02b02037dd10ec0a +README.md: 6ab9de681471c4be3bff72ddbf6ce8f118224d4d +README.zh.md: 82fca597791f19caa21a7a433e533a4d47c64ccb diff --git a/python/README.md b/python/README.md index 27637edb9d..6ab9de6814 100644 --- a/python/README.md +++ b/python/README.md @@ -13,7 +13,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com ## Behavior -The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client owns channel selection and default-configuration injection; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete resolution and configuration contracts. +The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client selects the channel and supplies default configuration; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete runtime-selection and configuration contracts. ## Contributor workflows diff --git a/python/README.zh.md b/python/README.zh.md index b0587b25b6..82fca59779 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -13,7 +13,7 @@ ## 行为 -除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端负责选择通道和注入默认配置;运行时本身始终要求显式配置。完整的解析与配置约定分别由 [SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)定义。 +除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端选择通道并提供默认配置;运行时本身始终要求显式配置。[SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)定义完整的运行时选择与配置约定。 ## 贡献者工作流 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 68ea79ea7b..8585d16982 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — Repository scripts -Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer. +Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. diff --git a/scripts/archived-agent-notes.ts b/scripts/archived-agent-notes.ts index bdce541ab0..39786cca27 100644 --- a/scripts/archived-agent-notes.ts +++ b/scripts/archived-agent-notes.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto' import { basename } from 'node:path' import { AGENT_NOTE_CLASSES } from './agent-note-tree.ts' -/** Versioned shape of the frozen-content manifest. */ +/** Versioned fields in the frozen-content manifest. */ export interface ArchiveManifest { version: 1 files: Readonly> diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index aa3bae0b6d..d43c87f08b 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -1,5 +1,5 @@ /** - * Pins shared client-bundle preset contracts: the module-edge purity gate and + * Pins shared client-bundle preset rules: the module-edge purity gate and * the physical watch dependencies hidden behind virtual CSS Modules. */ import { fileURLToPath } from 'node:url' diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts index 3f878be13c..befc6156e1 100644 --- a/scripts/cordis-walk.ts +++ b/scripts/cordis-walk.ts @@ -81,7 +81,7 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') // Only compile-eligible fences belong in the opt-out ratio; every other skipped -// kind has an independent verifier named in BlockKind's contract above. +// kind has an independent verifier named in the BlockKind rules above. const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index a79ffdd47f..12732b9aa3 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -2,7 +2,7 @@ * Generate `docs/config-catalog.md` from package entry points, config types, * JSDoc, and static Schemastery schemas. Every package must classify, referenced * types must resolve without collisions, and every enumerable schema path must - * exist on the declared config type. External and dynamic shapes stay unknown; + * exist on the declared config type. External and dynamic types stay unknown; * declared runtime-only fields need not appear in the schema. `--check` verifies * the committed artifact. */ @@ -220,7 +220,7 @@ interface World { } /** How a schema key path fared against the declared config type: definitely - * present, definitely absent, or crossing a shape the walk cannot enumerate + * present, definitely absent, or crossing a type the walk cannot enumerate * (only `missing` is a violation — `unknown` must never mis-report). */ type PathLookup = 'found' | 'missing' | 'unknown' @@ -307,7 +307,7 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul /** * Walk a schema key path against a declared type. This is a PRESENCE check, - * not a shape check: it answers "does the declared config type have a member + * not a runtime value check: it answers "does the declared config type have a member * here", resolving interfaces (heritage included), type aliases, literals, * intersections, unions, arrays, indexed access, pass-through utility * wrappers, and type references across package-local and workspace imports. @@ -412,7 +412,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression { * Statically walk a schemastery schema expression to its key paths plus the * packages whose schemas an intersect composes. A key path is the top-level * key or a nested path through object/array compositions (`agents[].id`). - * Handles the shapes the repo declares — `z.object({…})` (possibly behind + * Handles the declaration forms the repo uses — `z.object({…})` (possibly behind * chained calls) and `z.intersect([X.Config, …])` — and hard-errors on * anything else, so a schema the walk cannot see fails the gate instead of * silently thinning it. Nested values that are neither `object` nor `array` @@ -521,7 +521,7 @@ function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] { const fromArray = (expr: ts.Expression, where: string): string[] => { if (!ts.isArrayLiteralExpression(expr)) { - violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`) + violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new declaration form.`) return [] } return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf)) @@ -727,7 +727,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] { } // Fold composed schemas' key paths in, then check each path against the type. - // Only a definite miss fails; shapes the walk cannot enumerate stay unknown. + // Only a definite miss fails; types the walk cannot enumerate stay unknown. const byName = new Map(entries.map(e => [e.pkg, e])) for (const entry of entries) { if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f2349d7c13..885e0cf9d2 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -111,16 +111,16 @@ export const SERVICE_PAGE: Record = { */ export const SERVICE_WALK_EXEMPTIONS: Record = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', - configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns the launcher contract', - launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns the launcher contract', + configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', + launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', - headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns the launcher contract', - launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns the launcher contract', + headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns this launcher contract', + launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns this launcher contract', lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the surface', apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface', appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface', connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface', - chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface', + chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API', command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface', conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface', @@ -483,13 +483,13 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { 'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API', TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md', InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md', - LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', + LocaleDict: 'service-local dictionary fields are owned by packages/client/i18n/src/index.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', WebUpgradeRoute: 'upgrade route registration contract is owned by packages/host/webserver/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', - KnobState: 'projection unit state shape is owned by packages/interaction/permission/README.md', + KnobState: 'projection unit state fields are owned by packages/interaction/permission/README.md', PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission/src/types.ts', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md', @@ -752,7 +752,7 @@ export function maybeRecordPair(pageRel: string, before: Map, sc // after review, never silently by regeneration. return false } - // The record must be exactly the well-formed two-entry shape for THIS pair; + // The record must contain exactly the two valid entries for THIS pair; // a malformed or renamed-key sidecar is the pairing gate's problem to // report, never something regeneration silently repairs into validity. const recorded = parsePairMeta(meta) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e9f2f806ae..aa875ca369 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -521,7 +521,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['workflow-workerthread'], consumers: ['tool-workflow', 'tool-ralph'], - note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', + note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', }, ] @@ -815,7 +815,7 @@ export class EventRelationCollector { * Return every indexed call resolving to one local helper declaration. * Fast path: when every same-file reference to the non-exported helper is * provably a direct callee, module scoping confines all of its calls to that - * file, so only that file is indexed. Any other reference shape may alias + * file, so only that file is indexed. Any other reference form may alias * the function value outward, so the original full package-source index * decides instead. */ @@ -1155,7 +1155,7 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) } // Every declared event needs a dispatcher: zero means dead vocabulary or an - // unrecognized semantic dispatch shape. Listener-free extension points remain + // unrecognized semantic dispatch form. Listener-free extension points remain // valid. Client-declared events are exempt: the relation scan seeds the HOST // aggregate program only (host+client cannot share one program — the cordis // Context merges collide), so client dispatch sites are structurally @@ -1168,8 +1168,8 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin if (undispatched.length > 0) { throw new Error( `event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} ` - + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses ` - + '(teach scripts/gen-doc-graphs.ts the shape)', + + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch form the semantic scan misses ` + + '(teach scripts/gen-doc-graphs.ts that form)', ) } const declared = new Set(events.map(event => event.name)) @@ -1262,9 +1262,9 @@ function renderLifecycle(): string { '', '`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.', '', - 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch.', + 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.', '', - 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', + 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request construction, steering, continuation, and errors.', '', ...maintenanceFooter(maintenance), ].join('\n') @@ -1274,7 +1274,7 @@ function renderToolPipeline(): string { const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs' return [ ...generatedHeader('Tool Execution Pipeline'), - 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them.', + 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward.', '', '```mermaid', 'flowchart TD', @@ -1380,7 +1380,7 @@ function renderIndex(docs: GraphDoc[]): string { const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode' return [ ...generatedHeader('Documentation Graph Index'), - 'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).', + 'These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).', '', 'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).', '', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index c9b1f41dbf..aa3198057b 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -139,7 +139,7 @@ describe('parseVendoredRows', () => { expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true) }) - it('yields nothing when the table shape changes, so the generator fails loud', () => { + it('yields nothing when the table columns change, so the generator fails loud', () => { expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) }) @@ -215,7 +215,7 @@ describe('parsePyprojectRequirements', () => { ].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest']) }) - it('accepts dependency-group includes and rejects unsupported requirement shapes', () => { + it('accepts dependency-group includes and rejects unsupported requirement forms', () => { expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n')) .toEqual(['pytest']) expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 9802adacf2..c2ab21688a 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -475,7 +475,7 @@ function collectPythonRequirementArray( } } -/** Read an optional TOML table and reject a present value of another shape. */ +/** Read an optional TOML table and reject a present non-table value. */ function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined { if (value === undefined || isTomlTable(value)) return value throw new Error(`gen-third-party-notices: ${location} must be a table.`) @@ -487,7 +487,7 @@ function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: * `[build-system]`, `dependencies` under `[project]`, and every key under * `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser * owns comments, quoted keys, escapes, and array boundaries; unsupported - * requirement shapes fail instead of disappearing from the notices. + * requirement forms fail instead of disappearing from the notices. * @param text - the complete `pyproject.toml` contents. * @returns the local project name and declared requirement names. */ diff --git a/scripts/gen-translation-brief.ts b/scripts/gen-translation-brief.ts index a979d2652a..0a0d03cc61 100644 --- a/scripts/gen-translation-brief.ts +++ b/scripts/gen-translation-brief.ts @@ -7,7 +7,7 @@ * the narrowest safe granularity — code-fence-only splice, changed * Markdown units, heading sections, whole document — and `--apply` writes * the computed counterpart for pairs whose change is code-fence-only. - * The briefing contract lives in `scripts/translation-brief.ts`; the + * The briefing rules live in `scripts/translation-brief.ts`; the * consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`. */ diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts index b1f57eba7b..0db617ba59 100644 --- a/scripts/lint-rule-fingerprint.spec.ts +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -15,7 +15,7 @@ interface Profile { // A one-time audit against eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2 // mapped @typescript-eslint/* to typescript/* and four extension rules to their // Oxlint core equivalents. These fingerprints pin the resulting repository -// contract; they do not re-evaluate that deleted baseline or track its preset. +// snapshot; they do not re-evaluate that deleted baseline or track its preset. const profiles = { source: { count: 88, @@ -84,7 +84,7 @@ describe('Oxlint repository rule fingerprint', () => { } const overrides: readonly unknown[] = parsed.overrides - it('pins the complete override shape', () => { + it('pins every override field', () => { expect(overrides).toHaveLength(8) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 54318ca94f..eeae171a0b 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -19,7 +19,7 @@ interface PackageManifest { devDependencies?: Record } -/** One package and the files participating in its invariant publication contract. */ +/** One package and the files participating in its invariant publication rules. */ export interface PackageInvariantOwner { readonly dir: string readonly manifestPath: string @@ -53,7 +53,7 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { }) } -/** Return all violations of the package-invariant companion contract. */ +/** Return all violations of the package-invariant companion rules. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] for (const owner of packageInvariantOwners(root)) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index cbcf8504d5..e8055db866 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -341,7 +341,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] { return gates } -/** Active Node major used to scope version-specific compatibility contracts. */ +/** Active Node major used to select version-specific compatibility checks. */ function runningNodeMajor(): number { const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10) if (!Number.isSafeInteger(major)) { @@ -473,7 +473,7 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // The heavy suites run uninstrumented beside the thresholded gate: their // compiler- and subprocess-bound fixtures pay a multiple of their runtime // under v8 instrumentation while contributing nothing the thresholds need -// (membership contract in scripts/coverage-exempt.ts). +// (membership rules in scripts/coverage-exempt.ts). // // DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel // gates split it instead of each claiming it whole (the failover pool's diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 587d59a8bd..bea681d55a 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,31 +4,31 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that must be fixed before a new release. A release must not include an unresolved FIXME unless reviewers explicitly approve merging the change without fixing it.`\n- Bad: `FIXME——新版本之前必须修复的问题。除非评审者明确批准带着问题合入,否则版本里不能有未解决的 FIXME。`\n- Good: `FIXME:新版本发布前必须修复的问题。除非评审者明确批准在不修复的情况下合并该更改,否则发布版本不得包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)约定](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何无法确定的情形都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 2c00bc7439..db3cae7073 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -137,7 +137,7 @@ describe('global test invariant host', () => { .toEqual(Object.keys(testInvariantCompanions).sort()) }) - it('loads and executes every source companion through the real Loader shape', async () => { + it('loads and executes every source companion through the real Loader setup', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map() const loader = Object.create(Loader.prototype) as Loader diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index d6f05eee90..9235f34e46 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -24,7 +24,7 @@ declare global { } } -/** Loader-safe shape shared by every package invariant companion. */ +/** Loader-safe exports shared by every package invariant companion. */ export interface TestInvariantCompanion { readonly name: string readonly inject: readonly string[] diff --git a/scripts/translation-pairing-git.ts b/scripts/translation-pairing-git.ts index cda27426c6..5f1ddd4fc0 100644 --- a/scripts/translation-pairing-git.ts +++ b/scripts/translation-pairing-git.ts @@ -54,7 +54,7 @@ export interface GitIndexBlob { * @param root - Repository root. * @param path - Repository-relative path. * @returns The stage-zero blob, or `undefined` when the path is absent. - * @throws Error when the path is unmerged or has an invalid index shape. + * @throws Error when the path is unmerged or its index entries are not a valid merge state. */ export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined { const output = runGit( diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index e80605c14b..6c6e3b8226 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -80,7 +80,7 @@ const PAIR_META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/ /** * Parse a `foo.i18n.yaml` consistency record into basename → recorded blob * hash, or undefined when any non-comment line deviates from the exact - * `.md: <40-hex>` shape or repeats a key. Consumers must + * `.md: <40-hex>` format or repeats a key. Consumers must * additionally require exactly the two expected basenames — a renamed key is * a malformed record, never a silently-missing entry. * @param content - Sidecar file text. @@ -118,7 +118,7 @@ export function renderPairMeta(source: string, sourceHash: string, zh: string, z ].join('\n') } -/** Validated shape of `scripts/translation-pairing.manifest.json`. */ +/** Validated fields of `scripts/translation-pairing.manifest.json`. */ export interface TranslationPairingManifest { /** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */ excluded: string[] diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index e3b14f169e..65160896c7 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -21,7 +21,7 @@ const retainedExamples = [ ['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'], ['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'], ['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'], - ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to hear without the source anchoring you', '不对照原文时,更容易察觉别扭的表达'], + ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to notice when you read the translation without comparing it with the source', '不对照原文阅读译文时,更容易察觉别扭的表达'], ['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'], ['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'], ['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'], @@ -44,7 +44,7 @@ describe('translation prompt rendering', () => { expect(zh).toContain('from Chinese to English') }) - it('retains every v4 embedded example', () => { + it('contains every embedded example', () => { for (const example of retainedExamples) { for (const fragment of example) expect(document).toContain(fragment) } diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts index aaf114efe8..91bd8e3210 100644 --- a/scripts/translation-prompt.ts +++ b/scripts/translation-prompt.ts @@ -169,7 +169,7 @@ function unescapeResponseBody(value: string): string { }).join('\n') } -/** Serialize a response in the exact escaped three-section shape the prompt requests. */ +/** Serialize a response in the exact escaped three-section format the prompt requests. */ export function renderTranslationResponse(response: TranslationResponse): string { return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n`).join('\n\n') } @@ -178,7 +178,7 @@ export function renderTranslationResponse(response: TranslationResponse): string * Parse the three-section response. Sections must each appear exactly once * and in order; escaped delimiter lines in Markdown bodies are restored. * A fenced ```xml wrapper around the whole response is tolerated, matching - * the shape some models echo back from the prompt's own example. + * the wrapper some models copy from the prompt's own example. */ export function parseTranslationResponse(text: string): TranslationResponse { let body = text.trim() diff --git a/scripts/verify-agent-note-classification.ts b/scripts/verify-agent-note-classification.ts index 776e155f4f..588a32c3fc 100644 --- a/scripts/verify-agent-note-classification.ts +++ b/scripts/verify-agent-note-classification.ts @@ -1,6 +1,6 @@ /** * Enforce Agent Note lifecycle/class paths and dated filenames. Structural rules - * are shared with `agent-note-tree.ts`; the closed classification contract lives + * are shared with `agent-note-tree.ts`; the closed classification rules live * in `.agents/notes/README.md`. */ diff --git a/scripts/verify-agent-note-format.ts b/scripts/verify-agent-note-format.ts index 39018588e6..0ca93b298e 100644 --- a/scripts/verify-agent-note-format.ts +++ b/scripts/verify-agent-note-format.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { agentNoteRoot, walkAgentNoteTree } from './agent-note-tree.ts' -/** The date the format contract landed; the grandfather comment is valid only before it. */ +/** The date these format rules took effect; the grandfather comment is valid only before it. */ const FORMAT_ADOPTED = '2026-07-05' /** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */ diff --git a/scripts/verify-archived-agent-notes.ts b/scripts/verify-archived-agent-notes.ts index ca27dd8852..d2f12f826e 100644 --- a/scripts/verify-archived-agent-notes.ts +++ b/scripts/verify-archived-agent-notes.ts @@ -99,7 +99,7 @@ if (!writeMode) { } if (errors.length > 0) { - console.error('verify-archived-agent-notes: archive contract violated:') + console.error('verify-archived-agent-notes: archive rules violated:') for (const error of errors) console.error(` ${error}`) process.exit(1) } diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index b0f4b89cdf..0684124215 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -19,7 +19,7 @@ const SHIPPED_CONFIG_GLOBS = [ 'python/*/src/**/cordis.yml', ] -/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */ +/** Ordinary single-line configuration forms this source check rejects; not full YAML analysis. */ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ /** Return every forbidden inline environment form in shipped configuration. */ diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 988c3a2e38..bcc2c2cfbd 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -75,9 +75,9 @@ function unwrapExpression(e: ts.Expression): ts.Expression { /** * Classify inline callable annotations. Mixed callable literals fail closed; - * other annotations are ordinary value shapes. + * other annotations are ordinary value types. * @param type - the declarator's type annotation. - * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape. + * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable type. */ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null { if (ts.isFunctionTypeNode(type)) return type @@ -446,7 +446,7 @@ function checkScope( if (ts.isExportAssignment(stmt)) { if (stmt.isExportEquals) { // `export =` has no ESM consumer surface in this repo and the walk - // cannot classify its operand's shape; refuse rather than fail open. + // cannot classify its operand's type; refuse rather than fail open. w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`) continue } diff --git a/scripts/verify-package-invariants.ts b/scripts/verify-package-invariants.ts index 32e34539fa..dc686b1018 100644 --- a/scripts/verify-package-invariants.ts +++ b/scripts/verify-package-invariants.ts @@ -1,4 +1,4 @@ -/** Verify package-owned invariant source and publication contracts. */ +/** Verify package-owned invariant source and publication rules. */ import { resolve } from 'node:path' import { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1a1de87d09..0a202e6142 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -217,12 +217,12 @@ function validateNestedVerbatim(raw: readonly string[], fragments: Set): return { blocks } } -/** GitHub-style fragment for the simple ASCII nested titles allowed by this contract. */ +/** GitHub-style fragment for the simple ASCII nested titles allowed by these rules. */ function headingFragment(title: string): string { return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-') } -/** A direct stable system-prompt contribution, as named by the README contract. */ +/** A direct stable system-prompt contribution, as named by the README rules. */ function isDirectSystemPromptSurface(title: string): boolean { return /\bsystem prompt\b/i.test(title) } diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 95a2fe9747..e91661e5e4 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -291,6 +291,6 @@ if (errors.length === 0) { process.exit(0) } -console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):') +console.error('verify-translation-pairing: bilingual pairing rules violated (see docs/i18n/README.md):') for (const message of errors) console.error(` ${message}`) process.exit(1) diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 30c5cc7bdc..119725274c 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -33,7 +33,7 @@ The worktree name is always `staging-` under ``, never derive 3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. 4. In the main clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the main clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. 5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. -6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. +6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds current, independently useful behavior or rules absent upstream. Abort without changing the installed launcher when resolution is uncertain. 7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. 8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared main-clone exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. 9. Recheck the old worktree, existing lock, launcher, `current`, main clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the main clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. From 51b2fc35f336fec5ccbfc14342b5fe6d07d45ab9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 16:15:30 +0800 Subject: [PATCH 67/73] docs: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index bea681d55a..7f2c71353e 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that must be fixed before a new release. A release must not include an unresolved FIXME unless reviewers explicitly approve merging the change without fixing it.`\n- Bad: `FIXME——新版本之前必须修复的问题。除非评审者明确批准带着问题合入,否则版本里不能有未解决的 FIXME。`\n- Good: `FIXME:新版本发布前必须修复的问题。除非评审者明确批准在不修复的情况下合并该更改,否则发布版本不得包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", From cf5fbd02b8c6480cd00d2d9631d648eeefe758a7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 16:34:29 +0800 Subject: [PATCH 68/73] fix(tasks-local): return the layer disposer directly and cover scoped teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ScopedLayers.effect` already returns an exact `() => void`, so the inherited `() => void dispose()` wrapper voided a void — two lint rules, four errors. The scoped layer's own teardown had no test, which is the registry-contribution disposal contract the testing policy requires and the only path that calls `TaskLayer.isEmpty()`: `ScopedLayers` prunes a scope's layer when its last contribution disposes. The new case mounts one plugin contributing both a surface and a listener into one scope, then unloads it and observes that the agents which joined that scope are refused again. Refs #2141 --- packages/tasks/tasks-local/src/index.ts | 6 ++--- .../tasks/tasks-local/tests/tasks.spec.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 146d3e4ca7..cb7de27ecd 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -238,23 +238,21 @@ export class LocalTaskService extends TaskService { } onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.listeners.append(listener), { label: 'tasks.onTaskDone()' }, ) - return () => void dispose() } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.surfaces.append(token), { label: 'tasks.attachSurface()' }, ) - return () => void dispose() } /** diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index bdaa31d975..6a7eb44d09 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -805,6 +805,30 @@ describe('LocalTaskService disposal', () => { expect(ownerEffects()).toHaveLength(0) }) + it('drops a scoped layer when its registrations dispose', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + const standing = createScope(ctx, {}) + // One mount contributes both kinds into the same layer, as `tool-tasks` + // does; unloading it must leave nothing serving the agents that joined it. + const mount = await standing.ctx.plugin({ + inject: ['tasks'], + apply(pluginCtx: Context) { + pluginCtx.tasks.attachSurface('tool-tasks') + pluginCtx.tasks.onTaskDone(() => {}) + }, + }) + const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx)) + ctx.agents.register(owner) + expect(() => ctx.tasks.start(producer({ owner }).spec)).not.toThrow() + + await mount.dispose() + + expect(() => ctx.tasks.start(producer({ owner }).spec)) + .toThrow('no control surface serves this agent') + }) + it('detaching the last surface re-arms the register fence', async () => { const ctx = new Context() await ctx.plugin(LocalTaskService) From 76feeece55d72d4a903ff9ee8b42c06a08a42a29 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 16:37:01 +0800 Subject: [PATCH 69/73] test(web): pin the assembled snapshot lane's locale through the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembled Web snapshot lane selected its locale with a `dsh.locale` localStorage key. That key stopped selecting anything once the locale preference moved to the Host settings document, so the image-display scenario's Chinese expectations met the English default and failed. Pin the navigator languages the boot env already documents, and state the image-display expectations in the lane's English copy — the fixture session title stays Chinese because it is fixture data, not product copy. --- apps/web/tests/assembled-boot.ts | 12 +++++++++++- apps/web/tests/image-display.snapshot.ts | 18 ++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 631196c652..ac0ec80fcb 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -70,7 +70,12 @@ let unmount: (() => void) | undefined export function installAssembledBootEnv(): void { beforeEach(() => { localStorage.clear() - localStorage.setItem('dsh.locale', 'en') + // The locale service derives its provisional locale from the browser and + // takes an explicit choice only from Host settings, which this lane's + // fixture transport does not serve; pinning the navigator is what selects + // English here. + Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true }) + Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }) document.title = 'DeepSeek Harness' vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => @@ -88,6 +93,11 @@ export function installAssembledBootEnv(): void { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') + // Deleting the own properties uncovers jsdom's own accessors again + // (Navigator declares both readonly, hence the erased receiver). + const ownNavigator = navigator as unknown as Record + delete ownNavigator.languages + delete ownNavigator.language vi.unstubAllGlobals() }) } diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 6546a79f1b..df286ec1c3 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -14,7 +14,7 @@ installAssembledBootEnv() /** 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 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) @@ -33,7 +33,6 @@ async function openFixtureSession(): Promise { } it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => { - localStorage.setItem('dsh.locale', 'zh') mountAssembledApp() await openFixtureSession() @@ -73,24 +72,23 @@ it('renders the history image pair through the authorized attachment route and o fireEvent.doubleClick(frame) const lightbox = await screen.findByRole('dialog') expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') - fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ })) + fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ })) await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) }) it('accepts pasted images into the composer rail in order and removes them', async () => { - localStorage.setItem('dsh.locale', 'zh') mountAssembledApp() - const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) - const start = tree.querySelector('button[aria-label="在“fixture”中新建会话"]') + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') if (start === null) throw new Error('fixture Workspace new-session action missing') fireEvent.click(start) // 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('描述你想要构建的内容', {}, { timeout: 10_000 }) + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -102,7 +100,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // The rail is an accessible group holding the draft thumbnail (queried via // DOM: jsdom's a11y-visibility computation hides the composer subtree). const rail = await waitFor(() => { - const el = document.querySelector('[role="group"][aria-label="待发送图片"]') + const el = document.querySelector('[role="group"][aria-label="Pending images"]') if (el === null) throw new Error('attachment rail missing') return el }, { timeout: 5_000 }) @@ -129,10 +127,10 @@ it('accepts pasted images into the composer rail in order and removes them', asy .toEqual(['pasted.png', 'second.png']) }) - const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')] + const remove = [...rail.querySelectorAll('button[aria-label^="Remove image"]')] 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() + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }) }) From c5dd99124cb0caee32ddd3f1856860ecb4acd149 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:26:47 +0800 Subject: [PATCH 70/73] docs: make routine translation lightweight --- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +-- ...6-07-02-bilingual-docs-and-pairing-gate.md | 4 +-- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 4 +-- ...efed-minimal-translation-updates.i18n.yaml | 4 +-- ...-26-briefed-minimal-translation-updates.md | 6 ++-- ...-briefed-minimal-translation-updates.zh.md | 6 ++-- ...outine-documentation-translation.i18n.yaml | 6 ++++ ...eight-routine-documentation-translation.md | 30 +++++++++++++++++++ ...ht-routine-documentation-translation.zh.md | 30 +++++++++++++++++++ .agents/skills/dsh-code-review/SKILL.md | 2 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 2 +- .agents/skills/dsh-prose-standard/SKILL.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 8 ++++- .../dsh-translate-docs/agents/openai.yaml | 7 +++++ AGENTS.md | 2 +- docs/AGENTS.md | 2 +- docs/i18n/README.i18n.yaml | 4 +-- docs/i18n/README.md | 8 ++--- docs/i18n/README.zh.md | 8 ++--- docs/i18n/translation-rules.i18n.yaml | 4 +-- docs/i18n/translation-rules.md | 7 ++--- docs/i18n/translation-rules.zh.md | 7 ++--- 23 files changed, 118 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md create mode 100644 .agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md create mode 100644 .agents/skills/dsh-translate-docs/agents/openai.yaml diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index a869a98b51..a993fdec64 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.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/process/2026-07-02-bilingual-docs-and-pairing-gate.md -2026-07-02-bilingual-docs-and-pairing-gate.md: 9e6611aa8391e1478603bedb7d2b96fdd9a8bad2 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 654d265f3e8b396e23a49fd53e522826a7adb2ff +2026-07-02-bilingual-docs-and-pairing-gate.md: d516c422d09a51cc47440d3ca73d914e96db2320 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 8d478d293b4d6a07e68da5036301816bdeb1bdfd diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 9e6611aa83..d516c422d0 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -15,7 +15,7 @@ This repo's documentation corpus is read by people and agents inside and outside - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch. - **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. - **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent. +- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth. ## Verification @@ -32,7 +32,7 @@ The verification contract covers each boundary independently. `verify-translatio ## Industry precedent -Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service. +Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 654d265f3e..8d478d293b 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -15,7 +15,7 @@ Status: implemented - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。 - **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 - **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。 +- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。 ## 验证 @@ -32,7 +32,7 @@ Status: implemented ## 业界先例 -带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。 +带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index 7f4bc40463..e98385c805 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.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/process/2026-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: afd990b7b63adfd0e66a4726975b678d044e7cad -2026-07-26-briefed-minimal-translation-updates.zh.md: a6559667f0f1140d8a26cd5ebc4bb64b7b95fe45 +2026-07-26-briefed-minimal-translation-updates.md: 1c032fa07167ec2407d0f46707f942ba0c830494 +2026-07-26-briefed-minimal-translation-updates.zh.md: 3e4b235ea6d65e5b8fec60d24208924537577951 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index afd990b7b6..1c032fa071 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -10,10 +10,10 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) ## Decision -Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged. +The extended manual workflow runs pair updates on a generated briefing instead of the guidance corpus; new pairs in that workflow still use the unchanged whole-document path. Routine agent work uses the direct path defined by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). - **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the incremental prompt-pipeline work; its provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. -- **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. +- **When explicitly invoked, the update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. - **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection. ## Benchmark @@ -38,7 +38,7 @@ A second head-to-head replay on the same ten examples compared this note's shipp ## Consequences -- A small prose edit's counterpart update now costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds; the cheap path and the correct path point the same way. +- In the explicitly invoked extended workflow, a small prose edit's counterpart update costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds. - The briefing generator is a second consumer of the consistency records: recorded blob hashes now also drive diff recovery and section mapping, strengthening the incentive to keep records honest. - Each distinct confirmed snapshot retains a content-addressed local ref and object. An abandoned re-record may therefore leave an extra durable pin, but it changes no branch or commit history; this local retention is the tradeoff that prevents garbage collection from invalidating an accepted pairing record. - `--write` without arguments no longer works; muscle-memory callers must name pairs or pass `--all`. That is the point — the bulk bless is now a visible, deliberate act. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index a6559667f0..3e4b235ea6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -10,10 +10,10 @@ Status: implemented ## 决策 -配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 +扩展的手动工作流使用生成的简报(briefing)而非指导语料来更新配对;该工作流中的新配对仍采用保持不变的整篇文档路径。常规 agent 工作采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)定义的直接路径。 - **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了增量提示词流水线工作的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 -- **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 +- **显式调用时,[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 - **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。 ## 基准测试 @@ -38,7 +38,7 @@ Status: implemented ## 后果 -- 一次小的行文修改,其对侧更新如今只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变;低成本的路径与正确的路径指向同一个方向。 +- 在显式调用的扩展工作流中,一次小的行文修改,其对侧文件更新只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变。 - 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。 - 每个不同的已确认快照都会保留一个内容寻址的本地 ref 和对象。因此,中途放弃的重新记录可能留下额外的持久固定项,但它不会改变任何分支或提交历史;这种本地保留正是防止垃圾回收让已接受配对记录失效所付出的代价。 - 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml new file mode 100644 index 0000000000..d86489d45a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.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/process/2026-08-08-lightweight-routine-documentation-translation.md +2026-08-08-lightweight-routine-documentation-translation.md: 713c2f14541aff49411b6f7d8b6bf5b4e02fa667 +2026-08-08-lightweight-routine-documentation-translation.zh.md: 7cb13e9fcaa8b8a4d38ab6c0050eec99c1025b46 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md new file mode 100644 index 0000000000..713c2f1454 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md @@ -0,0 +1,30 @@ +# Agent Note: Lightweight routine documentation translation + +Status: implemented + +English | [中文](2026-08-08-lightweight-routine-documentation-translation.zh.md) + +## Problem + +Routine bilingual edits automatically selected the full [translation skill](../../../skills/dsh-translate-docs/SKILL.md). Even after the [briefed-update optimization](2026-07-26-briefed-minimal-translation-updates.md), a small documentation change could still load a specialized workflow, generate a briefing, delegate prose to a subagent, and perform a separate verification pass. That orchestration consumed more time, context, and model tokens than translating the changed text itself, and automatic skill discovery exposed the workflow on ordinary documentation turns. + +## Decision + +- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent. +- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. Claude Code sees `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex sees `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. +- **Automatic workflows do not chain into the manual skill.** Root and documentation instructions own the lightweight default. Documentation, website-sync, prose, and code-review skills link to those instructions or the i18n contracts instead of loading `dsh-translate-docs` from an inferred bilingual change. +- **The pairing and review contracts stay intact.** Both language files still update together, untouched counterpart wording remains stable, terminology stays binding, the consistency record is rewritten only after the active agent confirms the pair, and `doc-sync` retains the corpus-wide mechanical checks. Human review still owns semantic translation quality. + +## Alternatives considered + +- **Delete the extended skill and briefing tools** — rejected: explicit manual use remains valuable for whole-document translations, difficult reconciliation, and callers that deliberately choose the guarded workflow. +- **Replace the extended skill with an automatically invoked lightweight skill** — rejected: another automatic skill would still add discovery context and an invocation boundary around a task the active agent can complete directly from the terminology table and standing instructions. +- **Keep automatic invocation only for new pairs or large changes** — rejected: size-based inference is another hidden policy and can unexpectedly activate the expensive workflow. The user, not the agent, chooses when the extended path is worth its cost. +- **Drop the terminology load as well** — rejected: the glossary is the small, binding input that prevents repository-wide term drift; removing it would trade token savings for inconsistent product language. + +## Consequences + +- Ordinary development pays for the changed source text, its local counterpart context, and the terminology table rather than the extended workflow's briefing and subagent context. +- The active agent owns the final routine translation in the same turn. The lightweight path deliberately gives up the extended workflow's generated alignment, delegated isolation, and separate prose-verification pass. +- Explicit users can still invoke the full workflow through `/dsh-translate-docs` in Claude Code or `$dsh-translate-docs` in Codex. +- The Claude Code frontmatter and Codex policy file are separate product contracts and must remain aligned when the skill's invocation policy changes. diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md new file mode 100644 index 0000000000..7cb13e9fca --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 轻量化日常文档翻译 + +Status: implemented + +[English](2026-08-08-lightweight-routine-documentation-translation.md) | 中文 + +## 问题 + +日常双语编辑会自动选用完整的[翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)。即使经过[基于简报的更新优化](2026-07-26-briefed-minimal-translation-updates.md),一次小的文档改动仍可能加载专用工作流、生成简报、把行文翻译委派给 subagent,并另行执行一轮核验。这种编排耗费的时间、上下文和模型 token 比直接翻译改动文本本身还多,而且 skill 的自动发现机制还会在普通文档处理轮次中暴露该工作流。 + +## 决策 + +- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容,保留改动之外已经评审的对侧文件行文,并重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。 +- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。在 `SKILL.md` 中,Claude Code 读取 `disable-model-invocation: true` 和 `user-invocable: true`;在 `agents/openai.yaml` 中,Codex 读取 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。 +- **自动工作流不会串联调用这项仅限手动调用的 skill。** 轻量默认行为由根级指令和文档指令定义。文档、网站同步、行文和代码评审 skill 会链接这些指令或 i18n 契约,而不会因为推断到双语改动就加载 `dsh-translate-docs`。 +- **配对契约与评审契约保持不变。** 两种语言文件仍会一并更新;未触及的对侧文件措辞保持稳定;术语约束仍然有效;只有当前 agent 确认配对后,才会重写一致性记录;`doc-sync`(文档同步门禁)继续执行全语料机械检查。语义层面的翻译质量仍由人工评审负责。 + +## 曾考虑的替代方案 + +- **删除扩展 skill 和简报工具**:不予采纳。在整篇文档翻译或棘手的两侧内容协调中,以及对有意选择受控工作流的调用方而言,显式手动调用仍有价值。 +- **用自动调用的轻量 skill 取代扩展 skill**:不予采纳。另一项自动 skill 仍会给这项任务增加发现上下文和调用边界,而当前 agent 仅依据术语表与常驻指令即可直接完成该任务。 +- **仅对新配对或大规模改动保留自动调用**:不予采纳。基于规模的推断同样是一项隐藏政策,可能出乎意料地启用高开销工作流。何时值得为扩展路径付出成本,应由用户而非 agent 决定。 +- **同时取消加载术语表**:不予采纳。术语表是体量小但有约束力的输入,可以防止整个仓库发生术语漂移;移除它等于用产品语言不一致换取 token 节省。 + +## 后果 + +- 普通开发的成本来自发生改动的源文本、其局部对侧文件上下文和术语表,不再来自扩展工作流的简报与 subagent 上下文。 +- 当前 agent 在同一轮次内对日常翻译的最终结果负责。轻量路径有意放弃扩展工作流提供的自动生成对齐信息、委派所提供的隔离,以及单独的行文核验轮次。 +- 用户仍可在 Claude Code 中通过 `/dsh-translate-docs`,或在 Codex 中通过 `$dsh-translate-docs` 显式调用完整工作流。 +- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;skill 调用策略变更时,两者必须保持一致。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 572c36a8a1..e79f11cdc7 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -15,7 +15,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates Agent Note](../../notes/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates. - [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto. -- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md), [terminology.md](../../../docs/i18n/terminology.md), and [dsh-translate-docs](../dsh-translate-docs/SKILL.md). +- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation. ## Blocking requirements diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 5d118f2257..5d39470d72 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -12,7 +12,7 @@ Repository translations follow the sibling pairing contract: English `foo.md`, C ## Read the owning contracts - Read [docs/AGENTS.md](../../../docs/AGENTS.md) and use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) when deciding where content belongs or changing product documentation prose. -- Use [dsh-translate-docs](../dsh-translate-docs/SKILL.md) whenever an edited source has a bilingual counterpart. +- For an edited bilingual source, follow the lightweight routine path in [docs/AGENTS.md](../../../docs/AGENTS.md#writing-rules) and the [pairing contract](../../../docs/i18n/README.md); never invoke the extended translation skill automatically. - Read the current `DocsPage` type and entries in [website/docs.ts](../../../website/docs.ts) before changing the manifest; do not rely on a remembered field set. - Read [website/.vitepress/config.ts](../../../website/.vitepress/config.ts) before adding a new section, sidebar collection, locale, or top-level navigation item. diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index a42c27cfda..1ea836b8ee 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -53,4 +53,4 @@ Apply the ordered relocate-condense-raise policy in [docs/AGENTS.md](../../../do ## Validation and PR hygiene -Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write `. The PR body should give word deltas, explain any deliberately long exception, and list checks. +Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules) and run `pnpm run verify-translation-pairing --write `. The PR body should give word deltas, explain any deliberately long exception, and list checks. diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 6ad226baf4..f100f78613 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -23,7 +23,7 @@ Always exclude `vendor/` from discovery, review, and edits, even when the reques Also exclude `.agents/notes/archived/` from prose review and edits. Archived Agent Notes are frozen snapshots; inspect an exact target only to understand a historical inbound citation, never to modernize its prose or outbound links. -Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Update the counterpart minimally and re-record the pair. +Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules), update the counterpart minimally, and re-record the pair. ## Preserve the complete proposition diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 586eb554c8..5057d8b760 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -1,10 +1,16 @@ --- name: dsh-translate-docs -description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — tells the orchestrating agent when to delegate translation to a subagent, and orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result +description: Manually run the extended DeepSeek Harness bilingual-document workflow, including generated briefings, delegated prose translation, whole-document translation, and scoped pairing verification. +disable-model-invocation: true +user-invocable: true --- # Translating DeepSeek-Harness docs +## Invocation boundary + +Run this extended workflow only when the user explicitly invokes `dsh-translate-docs` by name. Never select or load it for ordinary documentation work, from another skill, or from an inferred translation need; routine translation follows the one-shot, one-pass rule in [docs/AGENTS.md](../../../docs/AGENTS.md). + ## What this skill is **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. diff --git a/.agents/skills/dsh-translate-docs/agents/openai.yaml b/.agents/skills/dsh-translate-docs/agents/openai.yaml new file mode 100644 index 0000000000..8f02948105 --- /dev/null +++ b/.agents/skills/dsh-translate-docs/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "DSH Extended Doc Translation" + short_description: "Run the full bilingual documentation workflow manually" + default_prompt: "Use $dsh-translate-docs to run the extended bilingual-document workflow for the specified pair." + +policy: + allow_implicit_invocation: false diff --git a/AGENTS.md b/AGENTS.md index a8a202147c..9daf45a832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ Everything compiles under `strict: true` with `noImplicitAny`; every remaining ` Comments and docs state complete contracts and context, not reasoning transcripts. Use direct, concrete terms. Do not use metaphors. Before writing `contract`, `boundary`, or `shape`, ask whether a more exact term names the subject: write `response fields`, `JSON validation`, or `ESM exports` instead of `response shape`, `validation boundary`, or `module shape`. Keep `contract` for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. Keep a literal process, wire, security, transaction, or lifecycle boundary. Do not narrate control flow or tests, preserve review history, or restate code. Keep behavior, failure, timing, ownership, and safe-use facts; link the rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each changed acceptance path rejects an invalid case. Use narrow, justified exceptions instead of disabling a rule globally. -Docs accompany every code change: update affected README and JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). +Docs accompany every code change: update affected README and JSDoc contracts together. Routine bilingual work follows [docs/AGENTS.md](docs/AGENTS.md); only explicit user invocation may run `dsh-translate-docs`. Current-state prose, one physical line per paragraph, one home per fact, and word budgets live there. ## Editing these instructions diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 64ea632cb6..bc2081da1d 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -40,7 +40,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). -- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). +- **Bilingual pairs update together**: load [terminology](i18n/terminology.md), translate changed content one-shot and one-pass in the active agent, preserve untouched counterpart prose, and re-record. Only explicit user invocation may run `dsh-translate-docs` ([contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. - Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface". diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 45e4077203..087e9e9dfe 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/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 docs/i18n/README.md -README.md: af6a35294bc23adcd6214747f78a28a69ae443d1 -README.zh.md: 74cb98932460d014dab26d3b48cd81142e0f7bf8 +README.md: 9875eb0c9924daa0b519923e9aac8a67de8cda61 +README.zh.md: eed73226dffd9bc1f6af7b21af5b0b77363878e2 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index af6a35294b..9875eb0c99 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). +This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation. ## The pairing contract @@ -15,7 +15,7 @@ This repo's documentation is read by people and agents both inside and outside t foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). + Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. - **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. @@ -35,7 +35,7 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat `pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level. -The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. +The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. @@ -57,4 +57,4 @@ Generated English references and graphs participate in pairing when a reviewed C ## Division of labor -Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`. +Routine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 74cb989324..eed73226df 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。 ## 配对约定 @@ -15,7 +15,7 @@ foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 - **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。 @@ -35,7 +35,7 @@ `pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。 -这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 +这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 @@ -57,4 +57,4 @@ ## 分工 -这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 +日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。 diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml index e8e4d8f801..34b03c956f 100644 --- a/docs/i18n/translation-rules.i18n.yaml +++ b/docs/i18n/translation-rules.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/i18n/translation-rules.md -translation-rules.md: fb6aa9ac05bebe68ff9213af99f64457bdb1ad6f -translation-rules.zh.md: 04dd0a704e19502c676ea0966437870c5af0624f +translation-rules.md: ce20ed9a9673b0782ef07c9a4a21ff1c98ace960 +translation-rules.zh.md: daea57ab1d3a1abbad442982c8bb1c189478b8a8 diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md index fb6aa9ac05..ce20ed9a96 100644 --- a/docs/i18n/translation-rules.md +++ b/docs/i18n/translation-rules.md @@ -2,7 +2,7 @@ English | [中文](translation-rules.zh.md) -How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. +How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. ## Faithfulness @@ -13,7 +13,7 @@ How to translate between the two sides of a documentation pair in this repo. Bot ## Voice - The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose. -- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause. +- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause. - Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人). - Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it. - Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs. @@ -54,8 +54,7 @@ These rules govern the Chinese side; the English side follows the repo's normal ## Quality bar - A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra. -- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you. -- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone. +- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone. ## References diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index 04dd0a704e..daea57ab1d 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -2,7 +2,7 @@ [English](translation-rules.md) | 中文 -本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效;应用这些规则的仓库内置 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。 +本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。 ## 忠实性 @@ -13,7 +13,7 @@ ## 行文 - 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。 -- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。 +- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。 - 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。 - 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。 - 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。 @@ -54,8 +54,7 @@ ## 质量标准 - 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。 -- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。 -- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需人工核对。 +- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。 ## 参考资料 From b4581c8b97465bb0155f69dd19d8eecb48f6982b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:44:45 +0800 Subject: [PATCH 71/73] test(snapshot): refresh translation prompt fixture --- .../request-response.expected.json | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 7f2c71353e..ab862dba34 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,27 +24,43 @@ }, { "role": "user", +<<<<<<< HEAD "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" - }, - { - "role": "user", - "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" +======= + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files whose generators emit English only; a hand-written translation would go stale on regeneration.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效;应用这些规则的仓库内置 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需人工核对。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件,其生成器只输出英文;手写译文会在重新生成时变得陈旧。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" +>>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) }, { "role": "user", + "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" + }, + { + "role": "assistant", + "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + }, + { + "role": "user", +<<<<<<< HEAD "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" +======= + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" + }, + { + "role": "assistant", + "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" +>>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) }, { "role": "user", From ef7195a00a34747017b7eb1587fdbd845f45da6b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:12:36 +0800 Subject: [PATCH 72/73] fix(skill): enforce manual invocation policy --- ...outine-documentation-translation.i18n.yaml | 4 +- ...eight-routine-documentation-translation.md | 6 +- ...ht-routine-documentation-translation.zh.md | 6 +- .agents/skills/dsh-translate-docs/SKILL.md | 1 + docs/AGENTS.md | 2 +- package.json | 1 + scripts/run-gates.ts | 1 + .../request-response.expected.json | 24 +--- .../verify-skill-invocation-metadata.spec.ts | 53 ++++++++ scripts/verify-skill-invocation-metadata.ts | 122 ++++++++++++++++++ 10 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 scripts/verify-skill-invocation-metadata.spec.ts create mode 100644 scripts/verify-skill-invocation-metadata.ts diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml index d86489d45a..9805a10d2c 100644 --- a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.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/process/2026-08-08-lightweight-routine-documentation-translation.md -2026-08-08-lightweight-routine-documentation-translation.md: 713c2f14541aff49411b6f7d8b6bf5b4e02fa667 -2026-08-08-lightweight-routine-documentation-translation.zh.md: 7cb13e9fcaa8b8a4d38ab6c0050eec99c1025b46 +2026-08-08-lightweight-routine-documentation-translation.md: ff4d6005588b562018bf1ee40d6dabb5569766f0 +2026-08-08-lightweight-routine-documentation-translation.zh.md: fe809d328e739c2c869d5597cf6786198864bd56 diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md index 713c2f1454..ff4d600558 100644 --- a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.md @@ -10,8 +10,8 @@ Routine bilingual edits automatically selected the full [translation skill](../. ## Decision -- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent. -- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. Claude Code sees `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex sees `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. +- **Routine translation is one shot and one pass.** The active agent loads [terminology.md](../../../../docs/i18n/terminology.md), translates only the changed content directly, moves a terminology annotation when the true first occurrence crosses the edit boundary, otherwise preserves reviewed counterpart prose outside the change, and re-records the pair. It does not invoke a translation skill, generate a briefing, start a separate translation-review pass, or delegate translation to a subagent. +- **The extended workflow is manual-only.** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) retains its briefing, delegated prose, whole-document, and scoped-verification paths. The [Claude Code skill contract](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill) reads `disable-model-invocation: true` with `user-invocable: true` in `SKILL.md`; Codex reads `policy.allow_implicit_invocation: false` in `agents/openai.yaml`. The repository's `.claude/skills` symlink projects the same skill directory to Claude Code, so both products share one committed workflow while enforcing their own invocation metadata. The `doc-sync` skill-invocation-metadata gate keeps those independent policies aligned. - **Automatic workflows do not chain into the manual skill.** Root and documentation instructions own the lightweight default. Documentation, website-sync, prose, and code-review skills link to those instructions or the i18n contracts instead of loading `dsh-translate-docs` from an inferred bilingual change. - **The pairing and review contracts stay intact.** Both language files still update together, untouched counterpart wording remains stable, terminology stays binding, the consistency record is rewritten only after the active agent confirms the pair, and `doc-sync` retains the corpus-wide mechanical checks. Human review still owns semantic translation quality. @@ -27,4 +27,4 @@ Routine bilingual edits automatically selected the full [translation skill](../. - Ordinary development pays for the changed source text, its local counterpart context, and the terminology table rather than the extended workflow's briefing and subagent context. - The active agent owns the final routine translation in the same turn. The lightweight path deliberately gives up the extended workflow's generated alignment, delegated isolation, and separate prose-verification pass. - Explicit users can still invoke the full workflow through `/dsh-translate-docs` in Claude Code or `$dsh-translate-docs` in Codex. -- The Claude Code frontmatter and Codex policy file are separate product contracts and must remain aligned when the skill's invocation policy changes. +- The Claude Code frontmatter and Codex policy file are separate product contracts; `doc-sync` rejects a skill that becomes manual-only on only one product or becomes unavailable to the Claude Code user as well as the model. diff --git a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md index 7cb13e9fca..fe809d328e 100644 --- a/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-lightweight-routine-documentation-translation.zh.md @@ -10,8 +10,8 @@ Status: implemented ## 决策 -- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容,保留改动之外已经评审的对侧文件行文,并重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。 -- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。在 `SKILL.md` 中,Claude Code 读取 `disable-model-invocation: true` 和 `user-invocable: true`;在 `agents/openai.yaml` 中,Codex 读取 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。 +- **日常翻译一次完成,只处理一遍。** 当前 agent(智能体)加载 [terminology.md](../../../../docs/i18n/terminology.md),直接翻译发生改动的内容;如果术语的实际首现位置跨过了编辑边界,则移动相应括注,否则保留改动之外已经评审的对侧文件行文;最后重新记录配对。它不会调用翻译 skill、生成简报、启动单独的翻译评审轮次,也不会把翻译委派给 subagent。 +- **扩展工作流仅限手动调用。** [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 保留简报、行文翻译委派、整篇文档和按范围核验路径。[Claude Code skill 契约](https://code.claude.com/docs/en/skills#control-who-invokes-a-skill)读取 `SKILL.md` 中的 `disable-model-invocation: true` 和 `user-invocable: true`;Codex 读取 `agents/openai.yaml` 中的 `policy.allow_implicit_invocation: false`。仓库的 `.claude/skills` 符号链接把同一个 skill 目录映射给 Claude Code,因此两个产品共享同一份提交到仓库的工作流,同时分别执行各自的调用元数据契约。`doc-sync` 中的 skill 调用元数据门禁会让这两份独立策略保持一致。 - **自动工作流不会串联调用这项仅限手动调用的 skill。** 轻量默认行为由根级指令和文档指令定义。文档、网站同步、行文和代码评审 skill 会链接这些指令或 i18n 契约,而不会因为推断到双语改动就加载 `dsh-translate-docs`。 - **配对契约与评审契约保持不变。** 两种语言文件仍会一并更新;未触及的对侧文件措辞保持稳定;术语约束仍然有效;只有当前 agent 确认配对后,才会重写一致性记录;`doc-sync`(文档同步门禁)继续执行全语料机械检查。语义层面的翻译质量仍由人工评审负责。 @@ -27,4 +27,4 @@ Status: implemented - 普通开发的成本来自发生改动的源文本、其局部对侧文件上下文和术语表,不再来自扩展工作流的简报与 subagent 上下文。 - 当前 agent 在同一轮次内对日常翻译的最终结果负责。轻量路径有意放弃扩展工作流提供的自动生成对齐信息、委派所提供的隔离,以及单独的行文核验轮次。 - 用户仍可在 Claude Code 中通过 `/dsh-translate-docs`,或在 Codex 中通过 `$dsh-translate-docs` 显式调用完整工作流。 -- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;skill 调用策略变更时,两者必须保持一致。 +- Claude Code frontmatter 与 Codex 策略文件是彼此独立的产品契约;如果某项 skill 仅在一个产品中变为手动调用,或者在 Claude Code 中对模型和用户都不可用,`doc-sync` 会拒绝该状态。 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 5057d8b760..332c55920b 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -49,6 +49,7 @@ When translations need to be written from scratch, the orchestrating agent does - **Pass 1 — write, don't transpose.** Read a semantic unit, then restate it as a native technical author in the nearest [style sample's](../../../docs/i18n/style-samples.md) register. Preserve the required frame without forcing sentence-by-sentence correspondence. - **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it. +- **Read the completed counterpart alone.** After the source comparison, read the translated file without the source beside it and rewrite phrasing whose awkwardness only becomes visible in isolation. - Write only the final text to the file, never drafts or notes. - Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified. For a Chinese target, use the Chinese and first-occurrence columns; an unlisted term needs a citable Chinese OSS/vendor precedent or stays English under 「待定术语」. For an English target, use the English column and an established English technical term; preserve an ambiguous source term with a short gloss and list it as pending. Never invent a rendering inline. - Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index bc2081da1d..96c9b36352 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -40,7 +40,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). -- **Bilingual pairs update together**: load [terminology](i18n/terminology.md), translate changed content one-shot and one-pass in the active agent, preserve untouched counterpart prose, and re-record. Only explicit user invocation may run `dsh-translate-docs` ([contract](i18n/README.md)). +- **Pairs update together**: [Terminology-guided](i18n/terminology.md), single-pass active-agent work repositions first-use annotations, preserves untouched prose, and re-records; `dsh-translate-docs` remains user-invoked ([contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. - Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface". diff --git a/package.json b/package.json index d417354739..2830640c26 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts", "verify-archived-agent-notes": "tsx scripts/verify-archived-agent-notes.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "resolve-translation-pairing-conflicts": "tsx scripts/merge-translation-pairing.ts --resolve", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e8055db866..3b88da217f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -599,6 +599,7 @@ function docSyncLeafGates(options: { pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }), pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }), pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), + pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata' }), pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index ab862dba34..63050b2079 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,19 +24,11 @@ }, { "role": "user", -<<<<<<< HEAD - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write `), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write `),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" -======= - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files whose generators emit English only; a hand-written translation would go stale on regeneration.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" - }, - { - "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对契约、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对契约\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件,其生成器只输出英文;手写译文会在重新生成时变得陈旧。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" ->>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", @@ -48,19 +40,11 @@ }, { "role": "user", -<<<<<<< HEAD - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" + "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — `.zh.md` files would carry an HTML comment recording the English source's blob hash, and translation would flow EN → ZH only. Rejected: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated English documents remain derived from source and freshness-gated by their owning generators. A generated page with a reviewed Chinese counterpart participates in the three-file pairing workflow, with one structural exception: the generated English source has no language switcher because adding one would make the generator stale, while the Chinese counterpart links back to it. Generated pages without a reviewed counterpart remain explicit exclusions and use an English website projection.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" }, { "role": "assistant", - "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个仓库内置的 agent skill 替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" -======= - "content": "# Agent Note: Bilingual documentation via paired sibling files and a pairing gate\n\nStatus: implemented\n\nEnglish | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md)\n\n## Problem\n\nThis repo's documentation corpus is read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one.\n\n## Decision\n\n- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).\n- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.\n- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.\n- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.\n- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.\n- **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth.\n\n## Verification\n\nThe verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible.\n\n## Alternatives considered\n\n- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this Agent Note: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese Agent Note, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged.\n- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged.\n- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates.\n- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible.\n- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express \"consistent as of the state this PR introduces\", and verifying it requires git history instead of file content.\n- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.\n\n## Industry precedent\n\nPaired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus an agent-run workflow in place of a bot service.\n\n## Consequences\n\n- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant.\n- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, \"who confirmed these consistent, and when\" is answerable from git blame on the yaml.\n- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.\n- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.\n- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.\n- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.\n" - }, - { - "role": "assistant", - "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证契约分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:本 Agent Note 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" ->>>>>>> 45777c7624 (test(snapshot): refresh translation prompt fixture) + "content": "# Agent Note: 通过配对兄弟文件与配对门禁实现双语文档\n\nStatus: implemented\n\n[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文\n\n## 问题\n\n本仓库的文档语料会被公司内外的人和 agent(智能体)以中英两种语言阅读。在没有机制的情况下纯靠手工维护第二语言,正是译文腐烂的根源:一侧持续演进,另一侧默默失实,而没有门禁会注意到。对于这类不变式,本仓库一贯的做法是将其编码为机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](../../archived/process/2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。\n\n## 决策\n\n- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。\n- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。\n- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、中文侧和所有普通撰写的英文源都带切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。\n- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。\n- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。\n- **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。\n\n## 验证\n\n验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和响应消费结果。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。\n\n## 曾考虑的替代方案\n\n- **英文为正典源、指纹放在译文内**:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。否决:团队需要中文先行的撰写方式(先写、先审中文 Agent Note,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的伴随记录取代了文件内的单向指纹;blob hash 的机制本身保持不变。\n- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**:否决。本仓库没有将 locale 映射到路由的文档站框架;如果移动所有英文文件,所有既有交叉引用都要随之修改;且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑,而非原样工作。\n- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**:否决。适合有独立发布节奏的文档产品,对 monorepo 自身的文档而言过重;还会把译文置于本仓库门禁触及不到的地方。\n- **中英混排单文件(一个文件、两种语言)**:否决。每个 diff 都翻倍,破坏一段一行约定的 diff 易读性,且局部不一致不可见。\n- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**:否决,改用 blob hash。同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。\n- **比较配对两侧的 git 时间戳(无记录)**:否决。纯格式化的改动会误报,一次无关改动之后提交的对侧文件会漏报;只有内容同一性这个信号才与门禁的承诺名实相符。\n\n## 业界先例\n\n带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`),但这些仓库都没有在 CI 中**强制**配对或一致性检查;约定纯靠评审维系。一致性自动化存在于中国以外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit,为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计将两者结合:中文生态的文件布局,加上 hash 配对门禁,再加一个由 agent 运行的工作流替代 bot 服务。\n\n## 后果\n\n- 修改已配对文档的任一侧,同一个 PR 就有义务更新对侧并重新记录配对。门禁将 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。\n- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对文档一致」可以从 yaml 的 git blame 直接回答。\n- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。\n- 生成的英文文档仍由源码派生,并由各自的生成器实施新鲜度门禁。有经评审中文对侧的生成页面遵循三文件配对工作流,但有一项结构例外:生成的英文源文件不含语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。没有经评审对侧的生成页面保留为显式排除项,并在网站上投影英文。\n- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。\n- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。\n" }, { "role": "user", diff --git a/scripts/verify-skill-invocation-metadata.spec.ts b/scripts/verify-skill-invocation-metadata.spec.ts new file mode 100644 index 0000000000..88dbfed84e --- /dev/null +++ b/scripts/verify-skill-invocation-metadata.spec.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { collectSkillInvocationMetadataViolations } from './verify-skill-invocation-metadata.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixtureRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-skill-invocation-metadata-')) + roots.push(root) + return root +} + +function writeSkill(root: string, name: string, frontmatter: string, policy = ''): void { + const directory = join(root, '.agents/skills', name) + mkdirSync(join(directory, 'agents'), { recursive: true }) + writeFileSync(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Test skill\n${frontmatter}---\n\nTest.\n`) + writeFileSync( + join(directory, 'agents/openai.yaml'), + `interface:\n display_name: "Test"\n${policy}`, + ) +} + +describe('cross-product skill invocation metadata gate', () => { + it('accepts aligned default and manual-only policies', () => { + const root = fixtureRoot() + writeSkill(root, 'default-skill', '') + writeSkill( + root, + 'manual-skill', + 'disable-model-invocation: true\nuser-invocable: true\n', + 'policy:\n allow_implicit_invocation: false\n', + ) + + expect(collectSkillInvocationMetadataViolations(root)).toEqual([]) + }) + + it('rejects either direction of a manual-only policy mismatch', () => { + const root = fixtureRoot() + writeSkill(root, 'claude-only', 'disable-model-invocation: true\n') + writeSkill(root, 'codex-only', '', 'policy:\n allow_implicit_invocation: false\n') + + expect(collectSkillInvocationMetadataViolations(root)).toEqual([ + '.agents/skills/claude-only: Claude Code manual-only=true but Codex manual-only=false', + '.agents/skills/codex-only: Claude Code manual-only=false but Codex manual-only=true', + ]) + }) +}) diff --git a/scripts/verify-skill-invocation-metadata.ts b/scripts/verify-skill-invocation-metadata.ts new file mode 100644 index 0000000000..f7e7712c6e --- /dev/null +++ b/scripts/verify-skill-invocation-metadata.ts @@ -0,0 +1,122 @@ +/** + * Keep Claude Code and Codex invocation metadata aligned for repository skills. + * @module scripts/verify-skill-invocation-metadata + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { load } from 'js-yaml' + +const ROOT = resolve(import.meta.dirname, '..') + +/** Return an object-shaped YAML value, or undefined for every other shape. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +/** Parse a skill's YAML frontmatter as an object. */ +function parseSkillFrontmatter(source: string): Record { + const lines = source.split('\n') + if (lines[0] !== '---') throw new Error('SKILL.md must start with YAML frontmatter') + const end = lines.indexOf('---', 1) + if (end < 0) throw new Error('SKILL.md frontmatter is not closed') + const metadata = asRecord(load(lines.slice(1, end).join('\n'))) + if (metadata === undefined) throw new Error('SKILL.md frontmatter must be a YAML object') + return metadata +} + +/** Find repository skill directories that carry Codex product metadata. */ +function skillDirectories(root: string): string[] { + const skillsRoot = resolve(root, '.agents/skills') + if (!existsSync(skillsRoot)) return [] + return readdirSync(skillsRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && existsSync(resolve(skillsRoot, entry.name, 'agents/openai.yaml'))) + .map(entry => entry.name) + .sort() +} + +/** + * Report cross-product invocation-policy mismatches for repository skills. + * @param root - Repository root containing `.agents/skills`. + * @returns diagnostics for malformed metadata or policies that expose a skill differently. + */ +export function collectSkillInvocationMetadataViolations(root: string): string[] { + const violations: string[] = [] + + for (const skill of skillDirectories(root)) { + const relativeRoot = `.agents/skills/${skill}` + const skillFile = resolve(root, relativeRoot, 'SKILL.md') + const openaiFile = resolve(root, relativeRoot, 'agents/openai.yaml') + if (!existsSync(skillFile)) { + violations.push(`${relativeRoot}: agents/openai.yaml has no sibling SKILL.md`) + continue + } + + let frontmatter: Record + let openai: Record + try { + frontmatter = parseSkillFrontmatter(readFileSync(skillFile, 'utf8')) + } + catch (error) { + violations.push(`${relativeRoot}/SKILL.md: ${error instanceof Error ? error.message : String(error)}`) + continue + } + try { + const parsed = asRecord(load(readFileSync(openaiFile, 'utf8'))) + if (parsed === undefined) throw new Error('agents/openai.yaml must be a YAML object') + openai = parsed + } + catch (error) { + violations.push(`${relativeRoot}/agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}`) + continue + } + + const disableModelInvocation = frontmatter['disable-model-invocation'] + if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') { + violations.push(`${relativeRoot}/SKILL.md: disable-model-invocation must be a boolean`) + continue + } + const userInvocable = frontmatter['user-invocable'] + if (userInvocable !== undefined && typeof userInvocable !== 'boolean') { + violations.push(`${relativeRoot}/SKILL.md: user-invocable must be a boolean`) + continue + } + + const policy = asRecord(openai.policy) + const allowImplicitInvocation = policy?.allow_implicit_invocation + if (allowImplicitInvocation !== undefined && typeof allowImplicitInvocation !== 'boolean') { + violations.push(`${relativeRoot}/agents/openai.yaml: policy.allow_implicit_invocation must be a boolean`) + continue + } + + const claudeManualOnly = disableModelInvocation === true + const codexManualOnly = allowImplicitInvocation === false + if (claudeManualOnly !== codexManualOnly) { + violations.push( + `${relativeRoot}: Claude Code manual-only=${String(claudeManualOnly)}` + + ` but Codex manual-only=${String(codexManualOnly)}`, + ) + } + if (claudeManualOnly && userInvocable === false) { + violations.push(`${relativeRoot}/SKILL.md: a manual-only skill must remain user-invocable`) + } + } + + return violations +} + +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + const skills = skillDirectories(ROOT) + const violations = collectSkillInvocationMetadataViolations(ROOT) + if (violations.length > 0) { + process.stderr.write('verify-skill-invocation-metadata: violations found:\n') + for (const violation of violations) process.stderr.write(` ${violation}\n`) + process.exit(1) + } + + process.stdout.write( + `verify-skill-invocation-metadata: ${String(skills.length)} cross-product skill policy pair(s) aligned.\n`, + ) +} From bf39cef48fd225c336e6026026ab6b8f0a227d55 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:52:07 -0700 Subject: [PATCH 73/73] fix(ci): tolerate platform-specific coverage and curl retries --- packages/preset/agent-presets/src/authoring.ts | 2 ++ scripts/prepare-ci-bubblewrap.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 5ac4e55874..8a40879a24 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -105,6 +105,8 @@ async function tightenModes(dir: string): Promise { if (entry.isDirectory()) { await tightenModes(target) } else { + /* v8 ignore next -- Windows mode bits cannot represent POSIX owner-execute state; + * the Windows native gate preserves the DACL while the POSIX suite covers this branch. */ await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700) } } diff --git a/scripts/prepare-ci-bubblewrap.sh b/scripts/prepare-ci-bubblewrap.sh index 00a513db8f..e5f0902750 100755 --- a/scripts/prepare-ci-bubblewrap.sh +++ b/scripts/prepare-ci-bubblewrap.sh @@ -19,7 +19,7 @@ fi archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb" root="${RUNNER_TEMP}/dsh-bubblewrap" -curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL" +curl --fail --silent --show-error --location --retry 3 --retry-all-errors --output "$archive" "$BUBBLEWRAP_URL" printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status mkdir -p "$root" dpkg-deb --extract "$archive" "$root"