diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index e3bd9b93f5..9c1ddab7b7 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -15,8 +15,8 @@ en: da694641e1d009de zh: 9d7492d39039af66 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-registry-owns-the-mode: - en: 091b7bc49b027353 - zh: 42dc77a810202694 + en: 4cd76a34f4712e1a + zh: 303ef72a18644d7c /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-run-code-tool-and-the-dispatch-bridge: en: 6a0e2698b082978e zh: fbb03503520b30e9 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index 97fc5b04db..e99eccd65b 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -40,7 +40,7 @@ The [Node execution decision](../architecture/2026-09-11-sandboxed-node-ptc-runt **SDK prompt section.** In `'ptc'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.ptcRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. -**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition. +**Assembly ownership.** `tools:sdk` enters the trusted `system-prompt/assemble` presentation waterfall as a normal section, while `run_code` stays a provider-owned declaration that the waterfall cannot remove or replace. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace the section. Whoever changes it owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition. **Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 381448d354..71efafa7f2 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -40,7 +40,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **SDK 提示词段。** 在 `'ptc'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](../../archived/feature/2026-07-31-ptc-language-dispatch.md) 加入了 Python 与按 `ctx.ptcRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 -**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 +**组装所有权。** `tools:sdk` 作为正常段进入受信任的 `system-prompt/assemble` 展示 waterfall,而 `run_code` 仍是提供方持有的声明,waterfall 不能移除或替换它。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换该段。修改它的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 **代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 639381d125..ca48f70356 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml @@ -12,8 +12,8 @@ en: 532bb7268d9b93ce zh: ad47162233222ab0 /agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering: - en: 4e85280cc5eb8697 - zh: 8e6b09b1527784fa + en: 47010d626ab15117 + zh: a5b77385d7a3e781 /agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract: en: 16c4ee08b65ead23 zh: df80d6e49982615b @@ -21,11 +21,11 @@ en: e112957ed8a48039 zh: 37d63f9ea4968fb2 /agent-note-parallel-tool-call-execution-by-per-call-safety/verification: - en: eb5a6aa84980f0ab - zh: eed2680a160e8ed2 + en: 224b6838130c113f + zh: f3d7eb68ab5932fb /agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered: en: 17becc038bb7df7c zh: 673d9a9bdfa39edf /agent-note-parallel-tool-call-execution-by-per-call-safety/consequences: - en: a5508057bc03f4f6 - zh: 96b46c59402474c3 + en: 20a3e29931e01aa0 + zh: acbfc61dd710d960 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 40adc9f9aa..4eb497b1ff 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -26,7 +26,7 @@ A tagged mode, rather than a public boolean scheduler API, keeps resource-aware ## Scheduling and ordering -The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier. +The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. Calls resolve through the step's admitted tool set, so replacing or removing a call's registration makes that unstarted call fail as `UNKNOWN_TOOL`; the scheduler classifies it as exclusive, and the current pool drains before it settles as the next barrier. For example: @@ -66,7 +66,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati ## Verification -Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. +Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, rejection of unstarted calls after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. PTC mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. @@ -102,6 +102,6 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. -Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool. +Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. A call runs only the registration its step declared ([conditional tools](../../../../packages/core/tools/README.md)), so a hot reload, MCP reconnect, or skill reload that re-registers a tool mid-step fails that step's unstarted calls of it; the next step declares and runs the new registration. Already-started calls retain the scheduling decision under which they entered the pool. A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index 536541370b..dbfa7cd684 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -26,7 +26,7 @@ Status: implemented ## 调度与顺序 -循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。 +循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。调用通过本步骤的准入工具集合解析,因此替换或移除某个调用的注册后,该尚未开始的调用会以 `UNKNOWN_TOOL` 失败;调度器把它分类为独占,当前池会先完全排空,然后它才作为下一个屏障结束。 例如: @@ -66,7 +66,7 @@ Status: implemented ## 验证 -单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后的运行时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。 +单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后拒绝尚未开始的调用、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。 快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。PTC mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 @@ -102,6 +102,6 @@ Status: implemented 并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。 -工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。 +工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。调用只执行其步骤所声明的注册([条件工具](../../../../packages/core/tools/README.zh.md)),因此在步骤中途重新注册工具的热重载、MCP 重连或 skill 重载会让该步骤里这个工具尚未开始的调用失败;下一个步骤声明并执行新的注册。已启动的调用保留它们进入池时所依据的调度决策。 终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 496d2e8136..98789701f0 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -9,8 +9,8 @@ en: e7c3b5d7702714fe zh: 268f162b553b37d1 /agent-note-generic-file-upload/decision: - en: 31c7ad96c6ab82c8 - zh: 806922fc8fcd1242 + en: 1769950d73d30dca + zh: bf296b920ea9b997 /agent-note-generic-file-upload/alternatives-considered: en: 23f386b05e4e79ef zh: 0756c4fa30e8d0eb @@ -21,5 +21,5 @@ en: 3a50ac370d51cda9 zh: d490f58922f8795b /agent-note-generic-file-upload/related: - en: 7579c49eefdb8488 - zh: 68ad384fd9d2f8f9 + en: 14e8659b89dc08a8 + zh: dec5baa11f168e91 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index f60649a083..133312a83c 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -14,7 +14,7 @@ The command plane had already established a whole-envelope rule for images: a co Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. -Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. +Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. ## Alternatives considered @@ -54,3 +54,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat The [Trajectory reference](../../../../packages/client/ui-trajectory/README.md) documents ledger counts and inspector attachment layouts. The [input echo admission reference](../../../../packages/api/session-controller/README.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments. + +The [composer image upload on add](2026-10-21-composer-image-upload-on-add.md) note extends this background-upload queue to the images of an ordinary Session's draft. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 3d1667d181..dfc48509c5 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -14,7 +14,7 @@ Status: implemented 文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 -默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 +默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每项上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 ## 考虑过的替代方案 @@ -54,3 +54,5 @@ Status: implemented [Trajectory 参考](../../../../packages/client/ui-trajectory/README.zh.md)说明列表计数与详情面板附件布局。 [输入回显入档参考](../../../../packages/api/session-controller/README.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。 + +[输入框在加入时上传图片](2026-10-21-composer-image-upload-on-add.zh.md) 记录把这条后台上传队列扩展到普通 Session 草稿里的图片。 diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml new file mode 100644 index 0000000000..3d86d265aa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-21-composer-image-upload-on-add.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md +/agent-note-composer-image-upload-on-add: + en: 996b39b53086b9ac + zh: c81fade50ac895ec +/agent-note-composer-image-upload-on-add/problem: + en: b184ade2bd353f50 + zh: 00b97c91df54b310 +/agent-note-composer-image-upload-on-add/decision: + en: eada67e3c3d50f47 + zh: 5fb4f3a5e774e47e +/agent-note-composer-image-upload-on-add/alternatives-considered: + en: afa82b9bce9015f6 + zh: d088b92c2cb6c622 +/agent-note-composer-image-upload-on-add/consequences: + en: 08d1bcb970beb40e + zh: f42db4052618c289 +/agent-note-composer-image-upload-on-add/testing: + en: f2d2c59272ffcf70 + zh: 46c933fd2aeb87ea +/agent-note-composer-image-upload-on-add/related: + en: 6eac98f60c3b99f9 + zh: c1e318e74f44fbb4 diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md new file mode 100644 index 0000000000..ae8c509ac7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.md @@ -0,0 +1,36 @@ +# Agent Note: Composer image upload on add + +Status: implemented + +English | [中文](2026-10-21-composer-image-upload-on-add.zh.md) + +## Problem + +The Web composer kept image drafts in the browser and base64-encoded each image into `session/prompt` at send time, so the Host received, validated, normalized, and stored image bytes inside prompt admission. Generic files already uploaded when picked ([generic file upload](2026-08-26-generic-file-upload.md)). An image message therefore carried up to the per-message image byte limit in one RPC body, and its send time grew with image size; a composer that waits for the Host to accept a message before clearing it would hold the draft for that whole transfer. + +## Decision + +An ordinary Session's composer uploads each image draft as soon as it is added, through the same FIFO background-upload queue, Worker carriers, progress, retry, and cancellation that generic files use. The raw upload route takes a `mediaType` query parameter for an image upload; the Host reads at most the smaller of the deployment's per-image and per-message image byte limits, then validates, normalizes, and stores the image with `ctx.attachments.saveImages` exactly as prompt admission would, and stages the normalized image under a receipt for that Session. The prompt cites the receipt in an ordinary file part, so `session/prompt` keeps its wire types; prompt admission resolves the receipt to the stored image, applies the model's image-input check to the prompt, and counts stored images toward `maxImagesPerMessage` and `maxMessageImageBytes` together with encoded ones, each stored image by the bytes of its upload. Send waits until every image draft has a receipt, and a failed image upload turns its thumbnail into a retry control. Commands accept only file receipts, so command submissions keep encoding images inline. A continuable subagent's composer keeps inline images because the Host stages uploads only for ordinary Sessions; SDK and other wire callers may still send encoded images. + +## Alternatives considered + +- **A dedicated image-receipt prompt part.** Rejected: the receipt already names what was staged, so a second part kind would widen the `session/prompt` wire union, its generated catalogs, and every consumer that switches on parts without changing what the Host admits. +- **A separate image upload route or Remote method.** Rejected: one route with an explicit `mediaType` parameter reuses Session addressing, carrier selection, progress, and the failure envelope; the exact-bytes Remote fallback has no image caller. +- **Upload images but normalize them at send.** Rejected: normalization is the expensive part of image admission, so deferring it would keep the send-time cost this change removes. + +## Consequences + +- Sending an image message transfers only references, and the normalization cost moves to the moment the image is added. +- Per-image refusals such as an undecodable or oversized image surface when the image is added: the thumbnail names the refusal instead of offering a retry, and Enter repeats it. The model's image-input check and the per-message image count and byte limits still apply at send. +- Commands accept only file receipts, so a command submission that carries an image waits for the image upload like a message and then encodes the image inline: the image is stored a second time, and its unused receipt lasts until its Session ends. +- Removing an image while it uploads cancels the upload, and a cancelled upload receives no receipt; an image whose storage had already begun, or whose upload had finished, stays under `DSH_HOME`, which never deletes attachments. On a remote Host the bytes reach the server as soon as the user adds the image. +- Staged image receipts share the generic files' in-memory lifetime: a Host restart between adding and sending surfaces `FILE_NOT_STAGED`, and the user adds the image again. +- Image drafts, like file drafts, are not restored after a page reload or after their Session's client scope is released. + +## Testing + +Host coverage pins image staging through the raw route, the per-image byte bound that stops reading, unsupported types, subagent refusal, command refusal of image receipts, receipt resolution into the admitted image, the image-input check for an uploaded image, the per-message byte limit across receipts, and withheld receipts for uploads cancelled after their last chunk or during storage. Attachment admission coverage pins stored-image pass-through and the combined image count and byte limits. Client coverage pins image upload start, retry, cancellation, the send gate, receipt citation without re-encoding, inline images for drafts without an upload, the rail's progress, retry, and refusal states, and the Enter explanation of a held send. + +## Related + +The [generic file upload](2026-08-26-generic-file-upload.md) note owns the background-upload queue, carriers, staging lifetime, and file storage that image uploads reuse. diff --git a/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md new file mode 100644 index 0000000000..bfa887c85b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-composer-image-upload-on-add.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 输入框在加入时上传图片 + +Status: implemented + +[English](2026-10-21-composer-image-upload-on-add.md) | 中文 + +## 问题 + +Web 输入框把图片草稿留在浏览器里,发送时才把每张图片 base64 编码进 `session/prompt`,Host 在 prompt 准入时才接收、校验、规整并存储图片字节。通用文件早已在选中时上传([通用文件上传](2026-08-26-generic-file-upload.zh.md))。因此一条图片消息要在一个 RPC 请求体里携带最多达到单条消息图片字节上限的数据,发送耗时随图片大小增长;如果输入框要等 Host 接纳消息后才清空,草稿就要在整个传输期间一直保留。 + +## 决定 + +普通 Session 的输入框在图片草稿加入时就上传它,复用通用文件的先进先出后台上传队列、Worker 载体、进度、重试与取消。原始上传路由用 `mediaType` 查询参数表示图片上传;Host 最多读取部署配置的单张图片与单条消息图片字节上限中较小的一个,然后按 prompt 准入的同样方式用 `ctx.attachments.saveImages` 校验、规整并存储图片,再以该 Session 的凭证暂存规整后的图片。prompt 在普通文件部分引用该凭证,因此 `session/prompt` 的线上类型不变;prompt 准入把凭证解析为已存储的图片,对这条 prompt 执行模型的图片输入检查,并把已存储图片与编码图片一起计入 `maxImagesPerMessage` 和 `maxMessageImageBytes`,已存储图片按其上传的字节数计入。发送会等到每个图片草稿都取得凭证;图片上传失败时,缩略图变成重试控件。命令只接受文件凭证,所以命令提交仍以内联方式编码图片。continuable 子代理的输入框继续内联图片,因为 Host 只为普通 Session 暂存上传;SDK 和其他线上调用方仍可发送编码图片。 + +## 考虑过的替代方案 + +- **专用的图片凭证 prompt 部分。** 已否决:凭证本身已经指明暂存的内容,新增一种部分类型会扩大 `session/prompt` 线上联合类型、它的生成目录以及每个按部分类型分支的消费方,却不改变 Host 准入的内容。 +- **单独的图片上传路由或 Remote 方法。** 已否决:一条带显式 `mediaType` 参数的路由复用了 Session 寻址、载体选择、进度和失败信封;精确字节的 Remote 兜底没有图片调用方。 +- **加入时上传、发送时再规整。** 已否决:规整是图片准入中开销最大的部分,推迟它会保留这次改动要消除的发送耗时。 + +## 后果 + +- 发送图片消息只传引用,规整开销移到图片加入的时刻。 +- 无法解码、超过单张上限等逐张拒绝在图片加入时出现:缩略图说明拒绝原因而不提供重试,按 Enter 也会提示同样的原因。模型的图片输入检查以及单条消息的图片数量与总字节上限仍在发送时执行。 +- 命令只接受文件凭证,所以带图片的命令提交也要像消息一样等图片上传完成,再以内联方式编码图片:同一张图会再存储一次,未被引用的凭证一直留到 Session 结束。 +- 图片上传中移除会取消上传,取消的上传不会取得凭证;已开始存储或已上传完成的图片仍留在 `DSH_HOME` 下,附件存储从不删除。Host 在远端时,用户一加入图片,字节就到达服务器。 +- 暂存的图片凭证与通用文件同样只存在内存里:在加入与发送之间重启 Host 会得到 `FILE_NOT_STAGED`,用户需要重新加入图片。 +- 与文件草稿一样,图片草稿在页面刷新后或其 Session 的客户端作用域释放后不会恢复。 + +## 测试 + +Host 侧覆盖经原始路由暂存图片、单张字节上限一超出即停止读取、不支持的类型、子代理拒绝、命令拒绝图片凭证、凭证解析为准入后的图片、已上传图片的图片输入检查、多个凭证合计的单条消息字节上限,以及在最后一个分块之后或存储期间取消的上传不签发凭证。附件准入覆盖已存储图片原样通过,以及合并后的图片数量与总字节上限。客户端覆盖图片上传的开始、重试、取消、发送闸门、发送时引用凭证而不重新编码、没有上传的草稿以内联方式发送图片,附件栏的进度、重试与拒绝状态,以及发送被挡住时按 Enter 的说明。 + +## 相关记录 + +[通用文件上传](2026-08-26-generic-file-upload.zh.md) 记录负责图片上传所复用的后台上传队列、载体、暂存生命周期与文件存储。 diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 1cee326a29..3b3a9e5c7b 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -17,8 +17,8 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} -{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"exit_plan_mode"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index e58ee67be0..42f006222d 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -179,6 +179,7 @@ interface FixtureWindow extends Window { __DSH_BOOT__?: WebBootGraph __ModuleLoader__?: ClientModuleLoaderTarget __DSH_TRANSPORT__?: { readonly rpc: RemoteMock['rpc'] } + __DSH_FILE_UPLOAD__?: { readonly fetch: AssembledRemote['fileUpload'] } } class ResizeObserverStub { @@ -253,6 +254,7 @@ export function installAssembledBootEnv(): void { delete win.__DSH_BOOT__ delete win.__ModuleLoader__ delete win.__DSH_TRANSPORT__ + delete win.__DSH_FILE_UPLOAD__ document.body.innerHTML = '' document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' @@ -281,6 +283,7 @@ export function mountAssembledApp(options: AssembledBootOptions = {}): Assembled const remote = createAssembledRemote(options.remote) mountedRemote = remote.mock win.__DSH_TRANSPORT__ = { rpc: remote.mock.rpc } + win.__DSH_FILE_UPLOAD__ = { fetch: remote.fileUpload } history.replaceState(null, '', '/') const root = document.createElement('div') root.id = 'root' diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index be74dc87b6..401508bc2c 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -5,6 +5,7 @@ * Fixture Sessions have no retained Host terminals to restore. */ +import { Buffer } from 'node:buffer' import { readFileSync } from 'node:fs' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -106,6 +107,8 @@ export interface AssembledRemoteOptions { export interface AssembledRemote { readonly mock: RemoteMock + /** Page-owned upload carrier: stages an image upload as the Host would and answers its receipt. */ + readonly fileUpload: (input: string | URL, init: RequestInit) => Promise } const fixtureSource = readFileSync( @@ -132,6 +135,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass return [sessionId, next] as const })) const attachments = new Map([['fixture:image', structuredClone(fixture.attachment)]]) + const stagedImages = new Map>() const blankSessionProjections = fixture.control.value.projections['fx-gamma'] if (blankSessionProjections === undefined) { throw new Error('assembled fixture: blank Session projections missing') @@ -312,6 +316,10 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass const summary = sessions.find(candidate => candidate.sessionId === sessionId) if (summary === undefined) throw new Error(`assembled fixture: no Session ${sessionId}`) const content = recordArray(request, 'content').map((part) => { + if (isRecord(part) && part.type === 'file') { + const image = stagedImages.get(recordString(part, 'receiptId')) + return image === undefined ? part : { type: 'image', attachment: image } + } if (!isRecord(part) || part.type !== 'image') return part const attachmentId = `assembled:${randomUUID()}` const data = recordString(part, 'data') @@ -364,7 +372,28 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass }) }) - return { mock } + const fileUpload = async (input: string | URL, init: RequestInit): Promise => { + const url = new URL(String(input), 'http://assembled.test/') + const mediaType = url.searchParams.get('mediaType') + if (!url.pathname.endsWith('/api/session/uploadFileBinary') || mediaType === null || !(init.body instanceof Blob)) { + throw new Error(`assembled fixture: unexpected upload ${url.pathname}${url.search}`) + } + const bytes = new Uint8Array(await init.body.arrayBuffer()) + const name = url.searchParams.get('name') + const image = { + attachmentId: `assembled:${randomUUID()}`, + mediaType, + bytes: bytes.byteLength, + width: 160, + height: 90, + ...name === null ? {} : { name }, + } + attachments.set(image.attachmentId, ok({ attachment: image, data: Buffer.from(bytes).toString('base64') })) + const receiptId = `assembled-receipt:${randomUUID()}` + stagedImages.set(receiptId, image) + return new Response(JSON.stringify({ ok: true, value: { receiptId, image } }), { status: 200 }) + } + return { mock, fileUpload } } function eventOf( diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index dffc30778d..6033c8f06f 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium, type Browser, type Page } from 'playwright' +import { chromium, webkit, type Browser, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, @@ -12,6 +12,45 @@ import { openSettings, connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCA const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url)) +it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'keeps a foreign model drag from editing another provider input in $name', async ({ engine }) => { + const scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) + let browser: Browser | undefined + try { + browser = await engine.launch() + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('dialog', { name: '开始使用', exact: true }).getByRole('button', { name: '添加 API Key', exact: true }).click() + await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).getByRole('button', { name: '稍后配置' }).click() + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置', exact: true }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + const setup = dialog.getByText('DeepSeek', { exact: true }).locator('xpath=ancestor::li') + await setup.getByText('自定义设置', { exact: true }).click() + await dialog.getByRole('button', { name: '添加模型提供商' }).click() + await dialog.getByRole('tab', { name: '自定义模型 API' }).click() + const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' }) + await custom.getByRole('button', { name: '添加模型', exact: true }).click() + const target = custom.getByLabel('模型 ID 1', { exact: true }) + await target.fill('foreign-model') + const before = scaffold.ctx.settings.describe({ redactSecrets: true }).map(row => ({ ns: row.ns, value: row.value })) + const source = setup.getByRole('button', { name: '调整模型顺序 1', exact: true }) + await source.dragTo(target) + expect(await target.inputValue()).toBe('foreign-model') + expect(await setup.getByLabel('模型 ID 1', { exact: true }).inputValue()).toBe('deepseek-flash') + expect(scaffold.ctx.settings.describe({ redactSecrets: true }).map(row => ({ ns: row.ns, value: row.value }))).toEqual(before) + expect(tripwire.pageErrors).toEqual([]) + } finally { + try { + await browser?.close() + } finally { + await scaffold.close() + } + } + }, 60_000, +) + describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => { let scaffold: WebScaffold let browser: Browser diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index dc532d598d..120b33549a 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -25,6 +25,7 @@ - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: deepseek-flash @@ -33,6 +34,7 @@ - text: DeepSeek-V4.1-Flash - button "模型选项 1" - button "删除模型 1" + - button "调整模型顺序 2" - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index 9015847ef4..fd9cc97859 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -32,6 +32,7 @@ - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - button "获取可用模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: gpt-6-astra diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index f2aa8d44b9..be77f7ceee 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -40,6 +40,7 @@ - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - button "获取可用模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: acme-large diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index 07d8f4c169..ea3046ce5b 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -40,6 +40,7 @@ - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - button "获取可用模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: acme-large diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 312a5cf8d9..6ea83d310a 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -25,6 +25,7 @@ - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: deepseek-flash @@ -46,6 +47,7 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 + - button "调整模型顺序 2" - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index f64f3276aa..55526bebec 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -26,6 +26,7 @@ - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" + - button "调整模型顺序 1" - textbox "模型 ID 1": - /placeholder: 模型 ID - text: private-preview diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 1f5710f51a..ddf3d37380 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -14,6 +14,11 @@ - button "Asked questions" [expanded] - button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - button "Ask question 1/1 answered" +- group "Questions and answers": + - term: Checkpoint Ready to continue? + - definition: "Yes" + - text: {{clock}} + - button "Copy" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index ffe42cf8be..903a4d6021 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -12,6 +12,11 @@ - status: Completed - button "Completed in {{duration}}" [disabled] - button "Asked questions" +- group "Questions and answers": + - term: Checkpoint Ready to continue? + - definition: "Yes" + - text: {{clock}} + - button "Copy" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index fd1a329253..32c4c09089 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -179,7 +179,8 @@ describe('web e2e: Markdown image rendering', () => { await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image') const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w') try { - await oversized.truncate(20 * 1024 * 1024 + 1) + // One byte over attachment-local's default maxImageBytes (50 MiB), which also bounds /api/file. + await oversized.truncate(50 * 1024 * 1024 + 1) } finally { await oversized.close() } diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 7363d59e97..ac0de149af 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Request, Route } from 'playwright' -import { chromium } from 'playwright' +import { chromium, webkit } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -536,6 +536,136 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('saves model drag order, discards canceled moves, and preserves the other provider', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-order')) + const dialog = page.getByRole('dialog', { name: '设置' }) + const edit = dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }) + const settings = () => scaffold.ctx.settings.describe({ redactSecrets: true }).find(row => row.ns === 'llm-pi-ai')?.value + await edit.click() + await dialog.getByText('自定义设置').click() + for (const [position, id] of [[2, 'order-second'], [3, 'order-third']] as const) { + await dialog.getByRole('button', { name: '添加模型', exact: true }).click() + await dialog.getByLabel(`模型 ID ${String(position)}`, { exact: true }).fill(id) + await dialog.getByLabel(`显示名称 ${String(position)}`, { exact: true }).fill(id) + } + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor() + const before = structuredClone(settings()) + const path = join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') + const document = await readFile(path, 'utf8') + + for (const save of [false, true]) { + await edit.click() + await dialog.getByText('自定义设置').click() + // Keep the last insertion gap inside the scrollport before native dragging starts. + await dialog.getByRole('button', { name: '添加模型', exact: true }).scrollIntoViewIfNeeded() + const source = dialog.getByRole('button', { name: '调整模型顺序 1', exact: true }) + const target = dialog.getByRole('button', { name: `调整模型顺序 ${save ? '3' : '2'}`, exact: true }) + await source.hover() + const from = await source.boundingBox() + if (from === null) throw new Error('Model drag handle is not visible') + await page.mouse.down() + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 10, { steps: 3 }) + // Native dragging needs an initial motion; the second hover follows any scroll at the dialog edge. + await target.hover() + const targetBox = await target.boundingBox() + if (targetBox === null) throw new Error('Model drop target is not visible') + await target.hover({ position: { x: targetBox.width / 2, y: targetBox.height * 0.75 } }) + const insertion = await target.evaluate((button) => { + const rect = button.closest('[data-row-key]')!.getBoundingClientRect() + return { x: rect.left + 20, y: rect.bottom + 4 } + }) + await page.mouse.move(insertion.x, insertion.y, { steps: 3 }) + await page.mouse.up() + await page.locator('body > div[inert][aria-hidden="true"]:has(button[aria-label^="调整模型顺序 "])').waitFor({ state: 'detached' }) + await expect.poll(() => dialog.getByLabel(`模型 ID ${save ? '3' : '2'}`, { exact: true }).inputValue()).toBe('acme-large') + await expect.poll(() => dialog.locator('[data-row-key]').evaluateAll(rows => rows.flatMap(row => row.getAnimations()).length)).toBe(0) + expect(await readFile(path, 'utf8')).toBe(document) + await dialog.getByRole('button', { name: save ? '保存' : '取消', exact: true }).click() + await dialog.getByLabel('模型 ID 1', { exact: true }).waitFor({ state: 'detached' }) + if (!save) expect(settings()).toEqual(before) + } + + await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor() + expect(settings()).toMatchObject({ providers: { + 'acme-gateway': { models: [ + { id: 'order-second', name: 'order-second' }, + { id: 'order-third', name: 'order-third' }, + { id: 'acme-large', input: ['image'], reasoningEfforts: { off: null, low: 'low', high: 'deep' } }, + ] }, + 'minimax-cn': { apiKeyEnv: 'MINIMAX_CN_API_KEY', baseURL: 'https://gateway.minimax.example/v1' }, + } }) + await edit.click() + await dialog.getByText('自定义设置').click() + expect(await Promise.all([1, 2, 3].map(position => + dialog.getByLabel(`模型 ID ${String(position)}`, { exact: true }).inputValue(), + ))).toEqual(['order-second', 'order-third', 'acme-large']) + const viewport = page.viewportSize() + if (viewport === null) throw new Error('Model ordering checks require an explicit viewport') + try { + await page.setViewportSize({ width: 390, height: 844 }) + for (const name of ['调整模型顺序 1', '模型选项 1', '删除模型 1']) { + const box = await dialog.getByRole('button', { name, exact: true }).boundingBox() + expect(box).not.toBeNull() + if (box === null) throw new Error(`Model control is not visible: ${name}`) + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(390) + } + } finally { + await page.setViewportSize(viewport) + } + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'keeps keyboard reorder focus and visibility in $name', async ({ engine }) => { + const keyboardBrowser = await engine.launch() + try { + const keyboardPage = await keyboardBrowser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + await keyboardPage.goto(scaffold.authenticatedUrl) + await openSettings(keyboardPage, 'zh') + await keyboardPage.getByRole('button', { name: '模型', exact: true }).click() + const dialog = keyboardPage.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click() + await dialog.getByText('自定义设置').click() + const initialFirst = await dialog.getByLabel('模型 ID 1', { exact: true }).inputValue() + for (let position = 4; position <= 9; position++) { + await dialog.getByRole('button', { name: '添加模型', exact: true }).click() + await dialog.getByLabel(`模型 ID ${String(position)}`, { exact: true }).fill(`keyboard-${String(position)}`) + } + const source = await dialog.getByRole('button', { name: '调整模型顺序 1', exact: true }).elementHandle() + if (source === null) throw new Error('Keyboard reorder handle is missing') + await source.focus() + for (let position = 2; position <= 9; position++) { + await keyboardPage.keyboard.press('ArrowDown') + await expect.poll(() => source.evaluate(button => document.activeElement === button)).toBe(true) + expect(await source.getAttribute('aria-label')).toBe(`调整模型顺序 ${String(position)}`) + await expect.poll(() => source.evaluate(button => button.closest('[data-row-key]')!.getAnimations().length)).toBe(0) + expect(await source.evaluate((button) => { + const rect = button.getBoundingClientRect() + let top = 0 + let bottom = innerHeight + for (let parent = button.parentElement; parent !== null; parent = parent.parentElement) { + if (getComputedStyle(parent).overflowY === 'visible') continue + const clip = parent.getBoundingClientRect() + top = Math.max(top, clip.top) + bottom = Math.min(bottom, clip.bottom) + } + return rect.top >= top && rect.bottom <= bottom + })).toBe(true) + } + expect(await dialog.getByLabel('模型 ID 9', { exact: true }).inputValue()).toBe(initialFirst) + await keyboardPage.keyboard.press('ArrowUp') + await expect.poll(() => source.evaluate(button => document.activeElement === button)).toBe(true) + expect(await source.getAttribute('aria-label')).toBe('调整模型顺序 8') + await dialog.getByRole('button', { name: '取消', exact: true }).click() + } finally { + await keyboardBrowser.close() + } + }, 60_000, + ) + it('confirms an identified provider deletion before removing its profile and key', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).not.toContain('openai:') diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index f6978205c1..fd606099de 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -244,12 +244,13 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('button', { name: /删除模型/ }).first().click() } await settings.getByRole('button', { name: '添加模型', exact: true }).click() - const customModelId = settings.getByLabel('模型 ID 1') + const model = settings.locator('[data-row-key]') + const customModelId = model.getByLabel('模型 ID 1') await customModelId.fill('private-preview') - await settings.getByLabel('显示名称 1').fill('Private Preview') + await model.getByLabel('显示名称 1').fill('Private Preview') await settings.getByRole('button', { name: '模型选项 1' }).click() - await settings.getByLabel('上下文窗口 1').fill('131072') - await settings.getByLabel('最大输出 token 数 1').fill('64K') + await model.getByLabel('上下文窗口 1').fill('131072') + await model.getByLabel('最大输出 token 数 1').fill('64K') expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 74625e72f0..cc698a8186 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -131,26 +131,29 @@ describe('web e2e: requested SVG is explicitly delivered', () => { .toBe(await card.locator('svg').evaluate(icon => icon.innerHTML)) await expect.poll(() => changes.evaluate(element => element.isConnected ? element.getBoundingClientRect().height : undefined), { timeout: 10_000 }).toBe(62) - const appearance = await page.evaluate(() => { + // Earlier locator reads do not guarantee both cards remain mounted when the + // turn tail updates. Observe their presence and styles in one browser task. + await expect.poll(() => page.evaluate(() => { + const presentedCard = document.querySelector('[data-presented-file]') + const changedCard = document.querySelector('[data-changed-files]') + if (presentedCard === null || changedCard === null) return null const previous = document.body.getAttribute('data-ds-dark-theme') try { return [false, true].map((dark) => { document.body.toggleAttribute('data-ds-dark-theme', dark) - const cards = ['[data-presented-file]', '[data-changed-files]'].map((selector) => { - const card = document.querySelector(selector)! + const appearance = [presentedCard, changedCard].map((card) => { const tile = card.querySelector('svg')!.parentElement! return { fill: getComputedStyle(tile).backgroundColor, tileBorder: getComputedStyle(tile).border, cardBorder: getComputedStyle(card).border } }) - return { fills: cards.map(card => card.fill), matchingTiles: cards[0]!.tileBorder === cards[1]!.tileBorder, - matchingCards: cards[0]!.cardBorder === cards[1]!.cardBorder } + return { fills: appearance.map(card => card.fill), matchingTiles: appearance[0]!.tileBorder === appearance[1]!.tileBorder, + matchingCards: appearance[0]!.cardBorder === appearance[1]!.cardBorder } }) } finally { if (previous === null) document.body.removeAttribute('data-ds-dark-theme') else document.body.setAttribute('data-ds-dark-theme', previous) } - }) - expect(appearance).toEqual([ + }), { timeout: 10_000 }).toEqual([ { fills: ['color(srgb 1 1 1 / 0.5)', 'color(srgb 1 1 1 / 0.5)'], matchingTiles: true, matchingCards: true }, { fills: ['color(srgb 1 1 1 / 0.05)', 'color(srgb 1 1 1 / 0.05)'], matchingTiles: true, matchingCards: true }, ]) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index b999cab7cf..d999685580 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -361,6 +361,7 @@ describe('web e2e: resident question composer round trip', () => { const composer = page.locator('[data-question-key]') await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0) + expect(await page.locator('[data-question-reply]').count()).toBe(0) const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) @@ -539,7 +540,34 @@ describe('web e2e: resident question composer round trip', () => { expect(await page.locator('[data-question-key]').count()).toBe(0) expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) - // The default golden pins Compact mode before process disclosure. + const reply = page.locator('[data-question-reply]') + await reply.waitFor({ state: 'visible', timeout: 15_000 }) + expect(await reply.count()).toBe(1) + expect(await reply.getByText('Which color do you prefer?', { exact: true }).count()).toBe(1) + expect(await reply.getByText('Blue, Include accessibility notes', { exact: true }).count()).toBe(1) + expect(await reply.getByText('Green', { exact: true }).count()).toBe(0) + expect(await reply.getByText('A cool recessive hue', { exact: false }).count()).toBe(0) + expect(await reply.locator('[aria-expanded]').count()).toBe(0) + const processControl = page.locator('[data-turn-process]').first() + const processGroups = page.locator('[data-step-process]') + await expect.poll(() => processControl.isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => processControl.getAttribute('aria-expanded')).toBe('false') + expect(await processGroups.count()).toBeGreaterThan(0) + await expect.poll(async () => { + for (const group of await processGroups.all()) if (await group.isVisible()) return false + return true + }).toBe(true) + expect(await reply.isVisible()).toBe(true) + expect(await page.getByText('DONE', { exact: true }).isVisible()).toBe(true) + await processControl.click() + await expect.poll(() => processControl.getAttribute('aria-expanded')).toBe('true') + await expect.poll(() => processGroups.first().isVisible()).toBe(true) + await processControl.click() + await expect.poll(() => processControl.getAttribute('aria-expanded')).toBe('false') + await expect.poll(() => processGroups.first().isVisible()).toBe(false) + expect(await reply.isVisible()).toBe(true) + expect(await reply.getByText('Blue, Include accessibility notes', { exact: true }).isVisible()).toBe(true) + // Compact mode retains the answered batch outside the folded process. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) // Keep the ask_user_question card's readable answer in the expanded golden @@ -549,9 +577,11 @@ describe('web e2e: resident question composer round trip', () => { // accessible name is the bare summary, as before timed questions existed. const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true }) await answeredRow.click() - await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + await page.locator('[data-tool="ask_user_question"]') + .getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1) - expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1) + expect(await page.locator('[data-tool="ask_user_question"]') + .getByText('Include accessibility notes', { exact: true }).count()).toBe(1) expect(await page.getByText(/"answers"/).count()).toBe(0) expect(await page.getByRole('button', { name: 'View answers', exact: true }).count()).toBe(0) const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) @@ -1138,18 +1168,20 @@ describe.skipIf(MODE === 'record')('web e2e: late-answered question transcript', const { page: latePage, scaffold, tripwire } = seeded onTestFailed(() => saveFailureShot(latePage, 'web-e2e-question-late-answered-row')) const reply = latePage.locator('[data-question-reply]') - await reply.waitFor({ state: 'attached', timeout: 15_000 }) + await reply.waitFor({ state: 'visible', timeout: 15_000 }) expect(await reply.count()).toBe(1) expect(await latePage.locator('[data-chat-flow-kind="turn-trigger"]').count()).toBe(0) - expect(await reply.isVisible()).toBe(false) - // Whether the answer beat the timeout is the agent's pacing, not something - // a reader of the row has to reason about. + expect(await reply.getByText('Which color do you prefer?', { exact: true }).count()).toBe(1) + expect(await reply.getByText('Green, Answered after the timeout', { exact: true }).count()).toBe(1) + expect(await reply.getByText('Blue', { exact: true }).count()).toBe(0) + expect(await reply.getByText('A restful mid-spectrum hue', { exact: false }).count()).toBe(0) + expect(await reply.locator('[aria-expanded]').count()).toBe(0) await expandTurnProcesses(latePage) - await reply.waitFor({ state: 'visible', timeout: 15_000 }) + expect(await reply.isVisible()).toBe(true) const row = latePage.locator('[data-tool="ask_user_question"]') await row.waitFor({ timeout: 15_000 }) await row.getByRole('button', { name: 'Ask question', exact: true }).click() - await latePage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + await row.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) expect(await latePage.getByText('Green', { exact: true }).count()).toBeGreaterThanOrEqual(1) expect(await latePage.getByText('Answered after the timeout', { exact: true }).count()).toBeGreaterThanOrEqual(1) // The recorded timeout is the row's state, never its transcript copy. diff --git a/apps/web/tests/question-ptc.e2e.ts b/apps/web/tests/question-ptc.e2e.ts new file mode 100644 index 0000000000..aa77ce6ba3 --- /dev/null +++ b/apps/web/tests/question-ptc.e2e.ts @@ -0,0 +1,116 @@ +/** PTC answers stay visible through real Web composition, process folding, and reload. */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const question = 'Which PTC answer should remain visible?' +const args = JSON.stringify({ + description: 'Ask for a retained answer', + code: `return await tools.ask_user_question(${JSON.stringify({ questions: [{ + id: 'choice', question, options: [{ label: 'Blue' }, { label: 'Green' }], + }] })});`, +}) +const script: ReplayEntry[] = [ + { kind: 'chunks', chunks: [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: ToolCallId('ptc-question'), name: 'run_code', argumentsDelta: args }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('ptc-question'), name: 'run_code', arguments: args } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] }, + { kind: 'chunks', chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'PTC answer recorded.' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'PTC answer recorded.' } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] }, +] + +describe.skipIf(webSnapshotMode() === 'record')('web e2e: PTC answered question', () => { + let directory: string | undefined + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + let tripwire: ReturnType + const events: SessionEvent[] = [] + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'dsh-question-ptc-')) + const replayOverride = join(directory, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(script)) + scaffold = await launchWebScaffold({ + agentPresets: { default: 'ptc' }, + replayFixture: join(directory, 'override-only.jsonl'), replayOverride, + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (directory !== undefined) await rm(directory, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length > 0) throw new AggregateError(failures, 'PTC question cleanup failed') + }) + + it('records a real subcall answer once and retains its bubble when process rows fold', async () => { + const settled = scaffold!.whenTurnSettled(60_000) + const input = page.locator('[data-composer-input]').first() + await input.fill('Ask me which color to retain.') + await input.press('Enter') + const composer = page.locator('[data-question-key]') + await composer.getByRole('radio', { name: 'Blue', exact: true }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + await settled + const dispatch = events.find(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'ask_user_question') + expect(dispatch?.type).toBe('tool/ptc-dispatch') + if (dispatch?.type !== 'tool/ptc-dispatch') throw new Error('missing PTC question settlement') + expect(dispatch.data.isError).toBe(false) + expect(events.filter(event => event.type === 'tool/ptc-dispatch-start' && event.data.subCallId === dispatch.data.subCallId)).toHaveLength(1) + const bubble = page.locator('[data-question-reply]') + await bubble.waitFor({ state: 'visible' }) + expect(await bubble.count()).toBe(1) + expect(await bubble.getAttribute('data-question-reply')).toBe(String(dispatch.data.subCallId)) + expect(await bubble.getByText(question, { exact: true }).isVisible()).toBe(true) + expect(await bubble.getByText('Blue', { exact: true }).isVisible()).toBe(true) + expect(await bubble.getByText('Green', { exact: true }).count()).toBe(0) + const process = page.locator('[data-turn-process]').first() + await expect.poll(() => process.isEnabled()).toBe(true) + await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false') + await process.click() + await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true') + await process.click() + await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false') + expect(await bubble.isVisible()).toBe(true) + await page.reload({ waitUntil: 'load' }) + await bubble.waitFor({ state: 'visible' }) + expect(await bubble.count()).toBe(1) + expect(await bubble.getByText('Blue', { exact: true }).isVisible()).toBe(true) + for (const width of [390, 1440]) { + await page.setViewportSize({ width, height: 900 }) + const bounds = await bubble.evaluate((row) => { + const wrapper = row.firstElementChild! + return { width: wrapper.getBoundingClientRect().width, limit: row.getBoundingClientRect().width * 0.82, + overflow: document.documentElement.scrollWidth - window.innerWidth } + }) + expect(bounds.width).toBeLessThanOrEqual(bounds.limit + 1) + expect(bounds.overflow).toBeLessThanOrEqual(1) + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) +}) diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts index 502bb0d7c9..fa6fe753a0 100644 --- a/apps/web/tests/queue-image.e2e.ts +++ b/apps/web/tests/queue-image.e2e.ts @@ -100,6 +100,9 @@ describe('web e2e: queued image submission', () => { await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 }) await pasteImage(page, await readFile(PNG)) await page.getByRole('img', { name: 'queued.png' }).waitFor({ timeout: 10_000 }) + // The image uploads as soon as it is added, and Send waits for its receipt. + await page.locator('button[aria-busy="false"]', { has: page.getByRole('img', { name: 'queued.png' }) }) + .waitFor({ timeout: 10_000 }) const releasePrompt = Promise.withResolvers() const releaseImage = Promise.withResolvers() let cleanupPromise: Promise | undefined diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 0ca667960a..bb8c9835b0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/subagent-fork-schedule.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", + "tests/question-ptc.e2e.ts", "tests/approval-composer.e2e.ts", "tests/ptc-escalation.e2e.ts", "tests/standalone-string-editor.e2e.ts", diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 6ffe3c2b47..8dea01c100 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -25,7 +25,8 @@ sequenceDiagram Note over Agent,Driver: claim pending next-step input plus one queued prompt Driver-->>SDK: agent/inbox/spliced pure deletion Driver-->>SDK: agent/inbox/claimed { message, turn } per message - Driver->>Prompt: system-prompt/assemble waterfall + Driver->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step + Driver->>Prompt: system-prompt/assemble waterfall with the step tool set Driver->>Hooks: agent/pre-step waterfall Hooks-->>Driver: authoritative reject or enter(messages) alt proposed step rejected, first batch empty, or pre-step failed @@ -65,6 +66,7 @@ sequenceDiagram end end Driver->>Session: step/end + Driver->>Tools: dispose the step tool set opt natural stop and next-step inbox empty Driver->>Hooks: agent/turn-stopping serial terminal checkpoint end diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md index c4b2fa70db..66c5dd90bf 100644 --- a/docs/agent-lifecycle.zh.md +++ b/docs/agent-lifecycle.zh.md @@ -27,7 +27,8 @@ sequenceDiagram Note over Agent,Driver: claim pending next-step input plus one queued prompt Driver-->>SDK: agent/inbox/spliced pure deletion Driver-->>SDK: agent/inbox/claimed { message, turn } per message - Driver->>Prompt: system-prompt/assemble waterfall + Driver->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step + Driver->>Prompt: system-prompt/assemble waterfall with the step tool set Driver->>Hooks: agent/pre-step waterfall Hooks-->>Driver: authoritative reject or enter(messages) alt proposed step rejected, first batch empty, or pre-step failed @@ -67,6 +68,7 @@ sequenceDiagram end end Driver->>Session: step/end + Driver->>Tools: dispose the step tool set opt natural stop and next-step inbox empty Driver->>Hooks: agent/turn-stopping serial terminal checkpoint end diff --git a/docs/architecture.md b/docs/architecture.md index 284d3ca4db..93d431ada3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ A **step** is one model request plus the tools it calls. A **turn** is zero or m ```text turn/start claim next-step input plus one queued message - snapshot Session model selection; assemble prompt sections + tool schemas + snapshot Session model selection; assemble prompt sections + step-admitted tool schemas -> agent/pre-step; project runtime context at fallback reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty -> close the turn with no step diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 08b1fcc16a..687bf69911 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -87,7 +87,7 @@ AgentLoop 初始化 Agent,失败则回滚,并在启动已排队工作前等 ```text turn/start claim next-step input plus one queued message - snapshot Session model selection; assemble prompt sections + tool schemas + snapshot Session model selection; assemble prompt sections + step-admitted tool schemas -> agent/pre-step; project runtime context at fallback reject | enter(messages, startsRequestSeries?) reject, or a first enter rewritten empty -> close the turn with no step diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1f3235b3a4..106cc44fd3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -356,14 +356,14 @@ export interface Config { ## `@deepseek-ai/dsh-attachment-local` -- `source`: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) +- `source`: [`packages/attachment/attachment-local/src/index.ts:65`](../packages/attachment/attachment-local/src/index.ts) ```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 submitted image. Default: 20 MiB. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 50 MiB. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number @@ -371,7 +371,7 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 65,535px. */ maxImageDimension?: number /** Total-pixel budget of the stored provider-independent normalized image. */ normalizedImageMaxPixels?: number @@ -2481,7 +2481,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` - `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents` -- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +- `source`: [`packages/plan/plan-mode/src/index.ts:67`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -3654,7 +3654,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:263`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 92fc08897d..7a6a262d92 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -358,14 +358,14 @@ export interface Config { ## `@deepseek-ai/dsh-attachment-local` -- `source`: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) +- `source`: [`packages/attachment/attachment-local/src/index.ts:65`](../packages/attachment/attachment-local/src/index.ts) ```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 submitted image. Default: 20 MiB. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 50 MiB. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number @@ -373,7 +373,7 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 65,535px. */ maxImageDimension?: number /** Total-pixel budget of the stored provider-independent normalized image. */ normalizedImageMaxPixels?: number @@ -2483,7 +2483,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` - `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents` -- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +- `source`: [`packages/plan/plan-mode/src/index.ts:67`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -3656,7 +3656,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:263`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index acc84e2f3a..042af3ba02 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -24,8 +24,8 @@ en: 1b527d85759324f9 zh: 54aa40db2705b95c /fiber/the-fiber-class/fiber-config: - en: ee804c03c39e7806 - zh: 4353ba7817ec61ec + en: 1efe137a7ef0e599 + zh: bb4b9818fb48f4e9 /fiber/the-fiber-class/fiber-state: en: 999ad29dfc29661b zh: 20245d11c338e3f1 @@ -50,15 +50,24 @@ /fiber/the-fiber-class/fiber-geteffects: en: 4d739a9c186fd9c2 zh: f80fd3b87e79b004 +/fiber/the-fiber-class/fiber-rawconfig: + en: 898aab51825bd8c3 + zh: b9ab6339574002df +/fiber/the-fiber-class/fiber-effectiveconfig: + en: 93ac680251e7023f + zh: 8ea7d7de5db39b8e +/fiber/the-fiber-class/fiber-resolveconfig-config: + en: d7319852e2a1c3a9 + zh: 3300fac049227006 /fiber/the-fiber-class/fiber-await: - en: 1d6258cbea61cd18 - zh: 2f10dda8918dadc5 + en: c9a6544cacdbc28e + zh: 636bf93efad95bc9 /fiber/the-fiber-class/fiber-restart: - en: 62d68dedb20b4fa9 - zh: 5a735ae0ff1266f4 + en: 6ffc1baba98d689f + zh: 7e13bc92225c7e0a /fiber/the-fiber-class/fiber-update-config-nosave: - en: b57ce0f6260c2cb8 - zh: 3bfb6c5bc252a2c2 + en: 5c176a6e67073e92 + zh: ee07edc8dbf7c1f3 /fiber/effect: en: 007d5b214e8a8610 zh: d74c26fbb1b8aeb6 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 3044301eff..e85cc7b3e6 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -80,11 +80,11 @@ The context this fiber's plugin runs in (extends the parent context). ### fiber.config ```ts cordis-catalog -/** The validated plugin config (updated by `update()`). */ +/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */ public config: any ``` -The validated plugin config (updated by `update()`). +The validated plugin config (updated by `update()`); read through effectiveConfig. [Source](../../vendor/cordis/src/fiber.ts#L190) @@ -209,6 +209,53 @@ Return metadata for currently registered effects. [Source](../../vendor/cordis/src/fiber.ts#L568) +### fiber.rawConfig + +```ts cordis-catalog +/** Raw input retained for the next activation, without evaluating expressions or validating it. */ +get rawConfig(): unknown +``` + +Raw input retained for the next activation, without evaluating expressions or validating it. + +[Source](../../vendor/cordis/src/fiber.ts#L642) + +### fiber.effectiveConfig + +```ts cordis-catalog +/** + * The config object published by the existing activation/update lifecycle, without cloning or re-parsing. + * It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + */ +get effectiveConfig(): unknown +``` + +The config object published by the existing activation/update lifecycle, without cloning or re-parsing. It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + +[Source](../../vendor/cordis/src/fiber.ts#L655) + +### fiber.resolveConfig(config) + +```ts cordis-catalog +/** + * Evaluate and validate a candidate using this fiber's `internal/config` hooks and runtime schema. + * Does not publish the result, replace raw input, or commit running volatile references. + * Required injections must be available when a hook uses them. + * @param config Raw candidate input. + * @returns The candidate's resolved config, retaining the schema's result identity. + * @throws {ValidationError} when the runtime schema rejects the candidate; hook errors propagate. + */ +resolveConfig(config: unknown): unknown +``` + +Evaluate and validate a candidate using this fiber's `internal/config` hooks and runtime schema. Does not publish the result, replace raw input, or commit running volatile references. Required injections must be available when a hook uses them. + +- `config` — Raw candidate input. + +**Returns** The candidate's resolved config, retaining the schema's result identity. + +[Source](../../vendor/cordis/src/fiber.ts#L667) + ### fiber.await() ```ts cordis-catalog @@ -225,7 +272,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L704) +[Source](../../vendor/cordis/src/fiber.ts#L731) ### fiber.restart() @@ -243,7 +290,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L718) +[Source](../../vendor/cordis/src/fiber.ts#L745) ### fiber.update(config, noSave?) @@ -271,7 +318,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** nothing; the restart runs behind the `internal/update` waterfall. -[Source](../../vendor/cordis/src/fiber.ts#L736) +[Source](../../vendor/cordis/src/fiber.ts#L764) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index bee2f523f1..d529013e73 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -82,11 +82,11 @@ public readonly ctx: Context ### fiber.config ```ts cordis-catalog -/** The validated plugin config (updated by `update()`). */ +/** The validated plugin config (updated by `update()`); read through {@link effectiveConfig}. */ public config: any ``` -经过校验的插件配置(由 `update()` 更新)。 +经过校验的插件配置(由 `update()` 更新);通过 `effectiveConfig` 读取。 [源码](../../vendor/cordis/src/fiber.ts#L190) @@ -211,6 +211,53 @@ getEffects() [源码](../../vendor/cordis/src/fiber.ts#L568) +### fiber.rawConfig + +```ts cordis-catalog +/** Raw input retained for the next activation, without evaluating expressions or validating it. */ +get rawConfig(): unknown +``` + +保留供下次激活使用的原始输入,不执行表达式求值或验证。 + +[源码](../../vendor/cordis/src/fiber.ts#L642) + +### fiber.effectiveConfig + +```ts cordis-catalog +/** + * The config object published by the existing activation/update lifecycle, without cloning or re-parsing. + * It is undefined before the first resolution and remains readable after unload; callers requiring a live instance check `state`. + */ +get effectiveConfig(): unknown +``` + +既有激活/更新生命周期发布的配置对象,不克隆或重新解析。首次解析前为 undefined,卸载后仍可读取;需要运行实例的调用方应检查 `state`。 + +[源码](../../vendor/cordis/src/fiber.ts#L655) + +### fiber.resolveConfig(config) + +```ts cordis-catalog +/** + * Evaluate and validate a candidate using this fiber's `internal/config` hooks and runtime schema. + * Does not publish the result, replace raw input, or commit running volatile references. + * Required injections must be available when a hook uses them. + * @param config Raw candidate input. + * @returns The candidate's resolved config, retaining the schema's result identity. + * @throws {ValidationError} when the runtime schema rejects the candidate; hook errors propagate. + */ +resolveConfig(config: unknown): unknown +``` + +通过此 fiber 的 `internal/config` 钩子和运行时 schema 对候选值求值并验证。此操作不发布结果、不替换原始输入,也不提交运行中的 volatile 引用。钩子访问的注入服务必须已经可用。 + +- `config`:原始候选输入。 + +**返回**候选配置的解析结果,保留 schema 返回对象的身份。 + +[源码](../../vendor/cordis/src/fiber.ts#L667) + ### fiber.await() ```ts cordis-catalog @@ -227,7 +274,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L704) +[源码](../../vendor/cordis/src/fiber.ts#L731) ### fiber.restart() @@ -245,7 +292,7 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L718) +[源码](../../vendor/cordis/src/fiber.ts#L745) ### fiber.update(config, noSave?) @@ -273,7 +320,7 @@ update(config: any, noSave = false) **返回**无返回值;重启由 `internal/update` waterfall 执行。 -[源码](../../vendor/cordis/src/fiber.ts#L736) +[源码](../../vendor/cordis/src/fiber.ts#L764) ## Effect diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 149951aaec..997d450ce3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -99,7 +99,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | -| `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | +| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | `gateway` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2b52dc0d52..fd9d7781b0 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | @@ -101,7 +101,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | | `agent/request` | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic) | -| `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | +| `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | | `internal/service` | - | `gateway` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 1887734b33..de2200d56e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -940,12 +940,6 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools - pkg_experimental_creation_recommendations --> pkg_llm - pkg_experimental_creation_recommendations --> pkg_session - pkg_experimental_creation_recommendations --> pkg_skill - pkg_experimental_creation_recommendations --> pkg_tools - pkg_experimental_creation_recommendations --> pkg_typert_protocol - pkg_experimental_creation_recommendations --> pkg_util_values pkg_experimental_cua_macos --> pkg_agent pkg_experimental_cua_macos --> pkg_computer_use pkg_experimental_cua_macos --> pkg_subprocess @@ -1100,6 +1094,13 @@ flowchart TD pkg_experimental_auto_review --> pkg_session pkg_experimental_auto_review --> pkg_tools pkg_experimental_auto_review --> pkg_working_directory + pkg_experimental_creation_recommendations --> pkg_llm + pkg_experimental_creation_recommendations --> pkg_session + pkg_experimental_creation_recommendations --> pkg_skill + pkg_experimental_creation_recommendations --> pkg_tool_executor_ptc + pkg_experimental_creation_recommendations --> pkg_tools + pkg_experimental_creation_recommendations --> pkg_typert_protocol + pkg_experimental_creation_recommendations --> pkg_util_values pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_preset_registry @@ -1737,7 +1738,6 @@ flowchart TD | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1768,6 +1768,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 3c583f955e..b1b8eaa9bd 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -942,12 +942,6 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools - pkg_experimental_creation_recommendations --> pkg_llm - pkg_experimental_creation_recommendations --> pkg_session - pkg_experimental_creation_recommendations --> pkg_skill - pkg_experimental_creation_recommendations --> pkg_tools - pkg_experimental_creation_recommendations --> pkg_typert_protocol - pkg_experimental_creation_recommendations --> pkg_util_values pkg_experimental_cua_macos --> pkg_agent pkg_experimental_cua_macos --> pkg_computer_use pkg_experimental_cua_macos --> pkg_subprocess @@ -1102,6 +1096,13 @@ flowchart TD pkg_experimental_auto_review --> pkg_session pkg_experimental_auto_review --> pkg_tools pkg_experimental_auto_review --> pkg_working_directory + pkg_experimental_creation_recommendations --> pkg_llm + pkg_experimental_creation_recommendations --> pkg_session + pkg_experimental_creation_recommendations --> pkg_skill + pkg_experimental_creation_recommendations --> pkg_tool_executor_ptc + pkg_experimental_creation_recommendations --> pkg_tools + pkg_experimental_creation_recommendations --> pkg_typert_protocol + pkg_experimental_creation_recommendations --> pkg_util_values pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_preset_registry @@ -1739,7 +1740,6 @@ flowchart TD | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1770,6 +1770,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index f31885878b..6ebf3b09d3 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -12,8 +12,8 @@ en: 97355667ed6fb44b zh: 90e9bf879f97f05a /session-persistence-event-catalog/event-envelope: - en: 689f781d95689e20 - zh: 602f6a7f0835d698 + en: 981db6b1928eda50 + zh: d77bc66cf678099d /session-persistence-event-catalog/events: en: f4776ba3d0fd53ce zh: e86769f50a69055a @@ -45,11 +45,11 @@ en: 44426b6cd0dc01cf zh: 44426b6cd0dc01cf /session-persistence-event-catalog/events/assistant/assistant-attempt-log-only: - en: 5b002a8dcefbfc71 - zh: e44f53ed4bf1f929 + en: 4e684124f71bd73b + zh: 445ad50a3a4fb635 /session-persistence-event-catalog/events/assistant/assistant-message-surface: - en: a0f8289f97125e4f - zh: 1ff54770086eb016 + en: 87d9bb62e2aea4b9 + zh: 68ef3b05d4aad7d3 /session-persistence-event-catalog/events/command: en: 2cecb5b0ee955732 zh: 2cecb5b0ee955732 @@ -84,8 +84,8 @@ en: 3344204b1858edb1 zh: 3344204b1858edb1 /session-persistence-event-catalog/events/developer/developer-message-surface: - en: cd036bde54c62690 - zh: 5a21179b33551ed5 + en: 927f25c5dbd69c82 + zh: 9c0331f6e61d7e2b /session-persistence-event-catalog/events/feedback: en: f50dbac6867fa64d zh: f50dbac6867fa64d @@ -153,17 +153,17 @@ en: 4294f78d26ed985b zh: 4294f78d26ed985b /session-persistence-event-catalog/events/plan/plan-mode-log-only: - en: 862175bfe8692ef7 - zh: a8ab123bf674cf77 + en: 9ae5537eeae238de + zh: 6be761c7fd83fdce /session-persistence-event-catalog/events/request: en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 /session-persistence-event-catalog/events/request/request-context-log-only: - en: 799784e7cd019889 - zh: db6c59d673b4acff + en: 9167d83823675b3e + zh: dfe9095419cf76f4 /session-persistence-event-catalog/events/request/request-header-log-only: - en: fb095eddad22b5f5 - zh: 49f1a3ac0a9ec57c + en: 6d9e26081d1e3aec + zh: 36d30d0bff197d1e /session-persistence-event-catalog/events/sandbox: en: ec841ab8439335b0 zh: ec841ab8439335b0 @@ -180,8 +180,8 @@ en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e /session-persistence-event-catalog/events/session/session-end-seed-log-only: - en: 70c3c0d6614f2d30 - zh: bb4dd52f5bcf351c + en: f285c670d1bf0e71 + zh: 14ec5689a5943156 /session-persistence-event-catalog/events/session/session-title-log-only: en: 6c6a27578dff5828 zh: 9a5add2bc0df2d0a @@ -198,11 +198,11 @@ en: bd0dcbbc352b771a zh: bd0dcbbc352b771a /session-persistence-event-catalog/events/step/step-end-log-only: - en: 2de61d81867d485c - zh: 21025a396ef7c88f + en: 24bc99ca25809609 + zh: 420384e753feba64 /session-persistence-event-catalog/events/step/step-start-log-only: - en: cb8f05bf3b59adef - zh: 686b33b689c4a841 + en: 269a493d00348184 + zh: 80feab63a0431c60 /session-persistence-event-catalog/events/subagent: en: 9630752e97ddbfbc zh: 9630752e97ddbfbc @@ -219,8 +219,8 @@ en: 8cac41d2fb575975 zh: 8cac41d2fb575975 /session-persistence-event-catalog/events/system/system-message-surface: - en: 92b1f61ac638da71 - zh: 2d60ba9d38abda05 + en: 23aedcadb0c95a51 + zh: f58e5195075ddb21 /session-persistence-event-catalog/events/team: en: a640800a38b3c874 zh: a640800a38b3c874 @@ -246,8 +246,8 @@ en: 28a24430609494b2 zh: 28a24430609494b2 /session-persistence-event-catalog/events/tool/tool-call-log-only: - en: 03f36237cbf9e267 - zh: 83fe2e69bbbfd3c1 + en: b5dfd3b4c54281c3 + zh: 3ac01ceab4a36d06 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: en: a62b38c00afed655 zh: 793bef93e856eafa @@ -255,8 +255,8 @@ en: da841b1ed3b22c87 zh: 4c9a4d4bc5bd76b6 /session-persistence-event-catalog/events/tool/tool-result-surface: - en: 283e3cf1fdd57f05 - zh: 0bc3a15ae4f47aaf + en: 722a3bf051b384eb + zh: 01e1258be37a82ce /session-persistence-event-catalog/events/tool-workflow: en: 1eac7deac6af15f3 zh: 1eac7deac6af15f3 @@ -276,17 +276,17 @@ en: d7e8c999a0851a0c zh: d7e8c999a0851a0c /session-persistence-event-catalog/events/turn/turn-end-log-only: - en: ce5ef6916c95ea16 - zh: 9652c8d40b5dfb6d + en: 38d2280f449d3112 + zh: 0f7f05475ea514a3 /session-persistence-event-catalog/events/turn/turn-start-log-only: - en: 999ffb7ad4e600aa - zh: a8720bf8578431ae + en: 853b7f23d092c48a + zh: 53e312793b609198 /session-persistence-event-catalog/events/user: en: d864a88c03e8971c zh: d864a88c03e8971c /session-persistence-event-catalog/events/user/user-message-surface: - en: 55128b5458eae10f - zh: 4198ea89093484f6 + en: ba681b5ec685d6e8 + zh: e4b14b585d2b5804 /session-persistence-event-catalog/events/web: en: d278748103cf1640 zh: d278748103cf1640 @@ -1014,8 +1014,8 @@ en: 62630a70a477d6ea zh: b6489613448ad40e /session-persistence-event-catalog/resolved-persistence-types/epochheader: - en: 3786becd1ca9c697 - zh: ea8e6fd4f6590567 + en: c804cb9e2e31c5ec + zh: 3ed7ac59cd1441b7 /session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange: en: 54c487394ce0db1d zh: 48d7c9affbe841cb @@ -1182,11 +1182,11 @@ en: 488050ba0b6438b4 zh: 2bf607b5ad51e39d /session-persistence-event-catalog/resolved-persistence-types/requestcontext: - en: b1ade5ed2c42b1ad - zh: f1a6126bec25657b + en: 00058b6c2220cdcb + zh: a3426a8c69123e2e /session-persistence-event-catalog/resolved-persistence-types/requestheaderreason: - en: 0db3aa2b122a4454 - zh: 15b6cfd8896935b8 + en: 8e563a11c123255f + zh: ebf891f6a3cb93d3 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: en: c9fa5343ea99733b zh: d6de41351c57a73f @@ -1239,8 +1239,8 @@ en: d34acab659e74957 zh: 25cabc6c9fb44a7e /session-persistence-event-catalog/resolved-persistence-types/surfaceop: - en: 95f8ef2fb8a7c154 - zh: 04cdad161fb88cc3 + en: a62be7c2cb8a4af7 + zh: 616fd1ac4bd3995a /session-persistence-event-catalog/resolved-persistence-types/systemmessage: en: 94876079d6696df9 zh: d25c7ad9f5054eb7 @@ -1290,8 +1290,8 @@ en: fdac0df45529e14c zh: c5e38de83a0e0a42 /session-persistence-event-catalog/resolved-persistence-types/toolrestriction: - en: 9d64c653e33bf2b6 - zh: 7bb427b48756f30f + en: d70be5d46cb1ef7d + zh: d3d5a05d62e443fe /session-persistence-event-catalog/resolved-persistence-types/toolresultmessage: en: 2258683c3cfa4ce3 zh: 5663aa76c911761b @@ -1317,8 +1317,8 @@ en: 4dc89c072fe5d0d1 zh: 4ed2c9e9e8b4f7e2 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: c5374a9e24478226 - zh: 9df00ba3b3423646 + en: 971afcae4e1b5956 + zh: c4f1f4fda2193853 /session-persistence-event-catalog/resolved-persistence-types/usermessage: en: 9ade9e697252b33c zh: 8352f8c900d2f8e4 @@ -1350,8 +1350,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 967168de7dce17f6 - zh: c43778dcbfea301e + en: a9d7dc010ada133d + zh: 9bd682adee72d04e /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1401,8 +1401,8 @@ en: a74e3dfa51c987af zh: 6c3c87974d7a7260 /session-persistence-event-catalog/resolved-persistence-types/active~2: - en: a3de6a6fe9672c7a - zh: a357481e27b3b81f + en: c861888d6239b700 + zh: a9b2271d3c6b8737 /session-persistence-event-catalog/resolved-persistence-types/agentpreset: en: 1afbe8196f161601 zh: a320a36a8928817c @@ -1413,8 +1413,8 @@ en: a117573a5d58cb3c zh: d5ad59e17a3a815d /session-persistence-event-catalog/resolved-persistence-types/arguments-callid-name-step: - en: 99e67b046fa9cf40 - zh: 88060ee577d02d20 + en: 036101ee99efbf72 + zh: a324d7f9f671bab0 /session-persistence-event-catalog/resolved-persistence-types/callid-files-turn: en: 5f4d3f66ed86a14d zh: 7c729360fa1279e6 @@ -1446,8 +1446,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 39c67256532a22f9 - zh: 59f823e98630c5e2 + en: 17649d71411cb827 + zh: abb3138d38c76459 /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1488,14 +1488,14 @@ en: 4b7a8793663d3fef zh: 249459c825d330c3 /session-persistence-event-catalog/resolved-persistence-types/endseq-op-startseq: - en: 318404925fed44c8 - zh: 6cc8b0fcd8d11540 + en: 1a533265eb56a8b8 + zh: 57480f4b53713570 /session-persistence-event-catalog/resolved-persistence-types/error-kind-error: - en: 72572b3d4f2f0c01 - zh: 4c4234325c1713fc + en: 8d0448ef77e0da08 + zh: 25770329c9db129a /session-persistence-event-catalog/resolved-persistence-types/error-message-meta-step: - en: 8cc3e4d05fa25012 - zh: 2c2d1c1936dd99ce + en: 9ffa836bf88fd7b4 + zh: 7ca8bc7ebefeadb8 /session-persistence-event-catalog/resolved-persistence-types/failure-kind-aborted: en: 349cd90f2598fd12 zh: 1eda02d520ad5d76 @@ -1503,11 +1503,11 @@ en: 51af619ed8532a43 zh: a5ad878c63d0e046 /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: a2feaf957fb7b7d8 - zh: 82e0db96fe47b78b + en: b90d2c0fd43619c0 + zh: 80d1045ffc187ba3 /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: e80728bf3f38de33 - zh: e1e20d266cea7849 + en: 820fdb254290f790 + zh: 5d39ae22c614f85b /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1524,14 +1524,14 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: f70fda862ff7400b - zh: 25deb32f46e0d2c5 + en: dd7a830a875303ba + zh: 3c6b9642340e565c /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: en: 2a03d9713f87000f zh: 52ec039cf54ee3e3 /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: - en: ec10cd8e9c202a81 - zh: e08f859bcb13f0d4 + en: 89fb3f919d181c86 + zh: 4380624236500687 /session-persistence-event-catalog/resolved-persistence-types/kind-aborted-reason: en: 48424cbed3fbc269 zh: 2ce88c04ad002104 @@ -1572,8 +1572,8 @@ en: 78e34e6d0d70339e zh: 072127e40cfb9686 /session-persistence-event-catalog/resolved-persistence-types/kind-forked: - en: dc750041126fe853 - zh: 736505cd1e57db62 + en: e2588043ee1a8d50 + zh: d87a5bff12b9190f /session-persistence-event-catalog/resolved-persistence-types/kind-hook: en: 78278a869370695f zh: 6cdc47f1876f3b23 @@ -1620,14 +1620,14 @@ en: a8a338be3b91008f zh: c7632c42c869c2f6 /session-persistence-event-catalog/resolved-persistence-types/kind-interrupted: - en: b2dea1e08facaa40 - zh: 71c9b8d4e3d1d2b8 + en: 452b4a96d072a9b9 + zh: d63c8ab84bd37dbb /session-persistence-event-catalog/resolved-persistence-types/kind-legacy: en: c6f68741f8631d4f zh: 090b58f2b86c02d0 /session-persistence-event-catalog/resolved-persistence-types/kind-max-tokens: - en: 4746f91df74cc1dd - zh: d0427e9191359299 + en: f85b7a64a6a5f1f7 + zh: 3f4ad5183e1020fd /session-persistence-event-catalog/resolved-persistence-types/kind-model-selection: en: a5165486dca91116 zh: b7051ca8167806ef @@ -1854,11 +1854,11 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: c59d020828d8211f - zh: 154a30cb69e79489 + en: 5a0bbf4c76266ac5 + zh: 0b20854f90f9d74d /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: 43f0c6042d410afd - zh: a0b68de4e5d220bd + en: 81620af099525ee9 + zh: 812db2fdf9e5d309 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1896,8 +1896,8 @@ en: 82b0989dad6bbfc0 zh: 0789b63f1d59b2ed /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: - en: dcaa676f106cfdfd - zh: e3941fea97c943fb + en: 762219ab10a2d9b1 + zh: ce31f3b385595b96 /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: en: f778769acb264dd5 zh: eba9b81d04e9f57b @@ -1914,8 +1914,8 @@ en: 8ebd37343b638773 zh: bf7ffa9a60ca42a8 /session-persistence-event-catalog/resolved-persistence-types/reason-turn: - en: a4ba4d0ee1396381 - zh: b6f595f8a696d512 + en: 2d45d5c5e3959d12 + zh: df4d0d7fca4e26bc /session-persistence-event-catalog/resolved-persistence-types/role-user: en: 04b34b8ffe7e7764 zh: a792d5371516fd9d @@ -1926,11 +1926,11 @@ en: 7a2ab7b62244f43b zh: 1084e3f8876feebc /session-persistence-event-catalog/resolved-persistence-types/step-stream-turn: - en: 84004f9b9c6bf24c - zh: ce1abe27b0bc43e2 + en: d911fd8591d66386 + zh: cba2966a909b9293 /session-persistence-event-catalog/resolved-persistence-types/step-turn: - en: 989fad01cdd866ba - zh: 4f08dafda68f0c77 + en: 4bbf63488cce88c9 + zh: 46b40433a749c133 /session-persistence-event-catalog/resolved-persistence-types/targets: en: 5360af614fe753e3 zh: 96427c489cc71de6 @@ -1941,8 +1941,8 @@ en: 8ab873d30e062fd5 zh: a138832c6e299ebb /session-persistence-event-catalog/resolved-persistence-types/turn: - en: c58b89cec274f956 - zh: 056a29ed1ada9690 + en: ea32b4165dbfb182 + zh: 57f48af06cfa201c /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: en: d7587116f8744b8b zh: bd99358ce20931cc diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c01c7ae0bb..aa31ec3bec 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -160,7 +160,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:433`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:441`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:495`](../packages/core/session/src/types.ts) ## Events @@ -290,7 +290,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:40`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) @@ -320,7 +320,7 @@ Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) ### `command/*` @@ -508,7 +508,7 @@ Source: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliv } ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -735,7 +735,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/ '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/*` @@ -753,7 +753,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) @@ -772,7 +772,7 @@ Source: [`packages/core/session/src/types.ts:403`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -849,7 +849,7 @@ Source: [`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) @@ -911,7 +911,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) @@ -922,7 +922,7 @@ Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -998,7 +998,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p 'system/message': { turn: number; step: number; message: SystemMessage } ``` -Source: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) ### `team/*` @@ -1091,7 +1091,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) @@ -1172,7 +1172,7 @@ Source: [`packages/core/tool-executor-ptc/src/types.ts:45`](../packages/core/too } ``` -Source: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1252,7 +1252,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) @@ -1268,7 +1268,7 @@ Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) ### `user/*` @@ -1287,7 +1287,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) ### `web/*` @@ -3406,7 +3406,7 @@ Attribution-only additions: `personalized-pages`, `tmux-context`, `user-question SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -Sources: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4543,7 +4543,7 @@ Sources: [`packages/llm/llm/src/types.ts:448`](../packages/llm/llm/src/types.ts) SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4562,7 +4562,7 @@ Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) One of: @@ -4900,7 +4900,7 @@ Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packa SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -Sources: [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) One of: @@ -5208,7 +5208,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts) SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -Sources: [`packages/core/tools/src/contracts.ts:326`](../packages/core/tools/src/contracts.ts) +Sources: [`packages/core/tools/src/contracts.ts:376`](../packages/core/tools/src/contracts.ts) | Property | Presence | Type | |---|---|---| @@ -5365,7 +5365,7 @@ Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -Sources: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5598,7 +5598,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:379`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5784,7 +5784,7 @@ Array of `unknown` (opaque). SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -Sources: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Sources: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) | Property | Presence | Type | |---|---|---| @@ -5835,7 +5835,7 @@ Sources: [`packages/interaction/commands/src/types.ts:105`](../packages/interact SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -Sources: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5994,7 +5994,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) +Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6214,7 +6214,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:38`](../packages/compacti SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -Sources: [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:466`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6228,7 +6228,7 @@ Sources: [`packages/core/session/src/types.ts:465`](../packages/core/session/src SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -Sources: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6241,7 +6241,7 @@ Sources: [`packages/core/session/src/types.ts:213`](../packages/core/session/src SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -Sources: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6283,7 +6283,7 @@ Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -Sources: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6297,7 +6297,7 @@ Sources: [`packages/core/session/src/types.ts:391`](../packages/core/session/src SHA-256: `58d643f2ec961461c8b14b737f081f047cd5d11890df8682305c55118dd5d1bf` -Sources: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6380,7 +6380,7 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6408,7 +6408,7 @@ Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/typ SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -Sources: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6576,7 +6576,7 @@ Sources: [`packages/session/session-title/src/types.ts:29`](../packages/session/ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -Sources: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6757,7 +6757,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -Sources: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6781,7 +6781,7 @@ Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -Sources: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +Sources: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7642,7 +7642,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -Sources: [`packages/core/tools/src/index.ts:18`](../packages/core/tools/src/index.ts) +Sources: [`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/index.ts) | Property | Presence | Type | |---|---|---| @@ -7654,7 +7654,7 @@ Sources: [`packages/core/tools/src/index.ts:18`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7821,7 +7821,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/exper SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7902,7 +7902,7 @@ Sources: [`packages/interaction/permission-presets/src/index.ts:53`](../packages SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -Sources: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7956,7 +7956,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:82`](../packages/compacti SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -Sources: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7970,7 +7970,7 @@ Sources: [`packages/core/session/src/types.ts:356`](../packages/core/session/src SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -Sources: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8021,7 +8021,7 @@ Sources: [`packages/todo/tool-todo/src/types.ts:33`](../packages/todo/tool-todo/ SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -Sources: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +Sources: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index e2b8825638..32301d9d68 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -162,7 +162,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:433`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:441`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:495`](../packages/core/session/src/types.ts) ## 事件 @@ -292,7 +292,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) @@ -322,7 +322,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) ### `command/*` @@ -510,7 +510,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -737,7 +737,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/*` @@ -755,7 +755,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) @@ -774,7 +774,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -851,7 +851,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) @@ -913,7 +913,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) @@ -924,7 +924,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -1000,7 +1000,7 @@ export type SessionEvent = { 'system/message': { turn: number; step: number; message: SystemMessage } ``` -来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) ### `team/*` @@ -1093,7 +1093,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) @@ -1174,7 +1174,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1254,7 +1254,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) @@ -1270,7 +1270,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) ### `user/*` @@ -1289,7 +1289,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) ### `web/*` @@ -3408,7 +3408,7 @@ SHA-256: `243d11dd918e46dee2902e3739a4ae3d711796d88cd0dbaa4dd51a9fe872ad1f` SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -来源:[`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4545,7 +4545,7 @@ SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -来源:[`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4564,7 +4564,7 @@ SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -来源:[`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4902,7 +4902,7 @@ SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -来源:[`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) 以下类型之一: @@ -5210,7 +5210,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -来源:[`packages/core/tools/src/contracts.ts:326`](../packages/core/tools/src/contracts.ts) +来源:[`packages/core/tools/src/contracts.ts:376`](../packages/core/tools/src/contracts.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5367,7 +5367,7 @@ SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -来源:[`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5600,7 +5600,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:379`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5786,7 +5786,7 @@ SHA-256: `b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7` SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -来源:[`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) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5837,7 +5837,7 @@ SHA-256: `a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86` SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5996,7 +5996,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) +来源:[`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6216,7 +6216,7 @@ SHA-256: `9e41386b3a0c9572b0d63078492ebb3997da7d3a830d44e0259418bd02f4bcb2` SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -来源:[`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:466`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6230,7 +6230,7 @@ SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -来源:[`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6243,7 +6243,7 @@ SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6285,7 +6285,7 @@ SHA-256: `693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057` SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:392`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6299,7 +6299,7 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` SHA-256: `58d643f2ec961461c8b14b737f081f047cd5d11890df8682305c55118dd5d1bf` -来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6382,7 +6382,7 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6410,7 +6410,7 @@ SHA-256: `6889dc45ee6d68d2182ab54eda30d71691014b3742c4119db4e5ea673020aa04` SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6578,7 +6578,7 @@ SHA-256: `1b5649238876e28811e80f10ec67a00c197ad3babd4ecc61a22b09064b88c597` SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -来源:[`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6759,7 +6759,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -来源:[`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6783,7 +6783,7 @@ SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -来源:[`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +来源:[`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7644,7 +7644,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -来源:[`packages/core/tools/src/index.ts:18`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:28`](../packages/core/tools/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7656,7 +7656,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:388`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7823,7 +7823,7 @@ SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7904,7 +7904,7 @@ SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7958,7 +7958,7 @@ SHA-256: `5dd766ccd1702a1142ad8b4bd44f2242f8e8a6752669ad3de0a5d224329b1438` SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:357`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7972,7 +7972,7 @@ SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8023,7 +8023,7 @@ SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 0d49b330a2..4bafacc964 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -120,7 +120,7 @@ {"digest":"15d5dfdd822aa35e115afd74a8982825a493880457774e6850bc1520b50875e4","names":[],"sources":[]}, {"digest":"16d0507cb94b4cfc84a87446b1ba7652d6ec1d65ee1684789e747e30d542800e","names":[],"sources":[]}, {"digest":"17aae616ef020b8b9f011ec398ec935450fb06d3e2a0722c456f63aeaf5ff728","names":[],"sources":[]}, - {"digest":"17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3","names":[],"sources":["packages/core/session/src/types.ts:428"]}, + {"digest":"17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3","names":[],"sources":["packages/core/session/src/types.ts:429"]}, {"digest":"18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0","names":["packages/schedule/schedule/src/types.ts#ScheduleDeleteChange"],"sources":["packages/schedule/schedule/src/types.ts:230"]}, {"digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f","names":["packages/session/session-persistence-jsonl/src/format.ts#HeaderLine"],"sources":["packages/session/session-persistence-jsonl/src/format.ts:83"]}, {"digest":"18fcdd273a2f2f9d813a3be5e71df5903a963a5c88e46b90b14de6e2db890da0","names":[],"sources":[]}, @@ -136,7 +136,7 @@ {"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5","names":[],"sources":[]}, {"digest":"1c2686c0682e7bc51d61117b2652bd4a80707e331aa276d6ef915ae32534a330","names":[],"sources":[]}, {"digest":"1c5a58f4599bd4a9711712551ffe2442a5e5fcd0758bafe630cc4ead850bdaa7","names":[],"sources":[]}, - {"digest":"1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d","names":[],"sources":["packages/core/session/src/types.ts:342"]}, + {"digest":"1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d","names":[],"sources":["packages/core/session/src/types.ts:343"]}, {"digest":"1cde1bedde79c5e8a56aee9c05c8e0a579cf5bc611deb85b93fa331a16ca978f","names":["packages/compaction/compaction-image-offload/src/projection.ts#ImageOffloadTarget"],"sources":["packages/compaction/compaction-image-offload/src/projection.ts:9"]}, {"digest":"1d5f04a03ea75fc8eeece544129eba0f49f0c82046e9c404461920390135c7b2","names":[],"sources":[]}, {"digest":"1d76406cbb2ddadb0dbbb51e9d8bc6cf678fed455cf8eb512f36f7c86d37a62f","names":[],"sources":["packages/llm/llm/src/types.ts:157"]}, @@ -151,7 +151,7 @@ {"digest":"1f1385dbc3e875c021e387c7096d671d606f22daec51b25c80348735b6bbfa10","names":[],"sources":[]}, {"digest":"1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0","names":["packages/core/session/src/types.ts#TurnEndCancelCause"],"sources":["packages/core/session/src/types.ts:197"]}, {"digest":"1fdad624334d354820dfb3a26e28d0bc9fc1b62a33f87bab63f715b3f4391bfd","names":[],"sources":[]}, - {"digest":"20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5","names":[],"sources":["packages/plan/plan-mode/src/index.ts:52"]}, + {"digest":"20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5","names":[],"sources":["packages/plan/plan-mode/src/index.ts:53"]}, {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/experimental/agent-team/src/types.ts:138"]}, {"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d","names":[],"sources":[]}, @@ -194,7 +194,7 @@ {"digest":"326a9bfda27dda5882fb3cbe6e715df41dae2298fdae3d66437457268a665693","names":["packages/llm/llm/src/call-config.ts#LlmCallConfigAdapterDefaults"],"sources":["packages/llm/llm/src/call-config.ts:54"]}, {"digest":"3305a4bb6b6c29b482ba91d59c60b47e8f98282d3dacc8c29e4a4c2712209bdd","names":[],"sources":[]}, {"digest":"33341aac09312300de5a178e6cc6f6544a4876f7f710cfea55eda473c8d329ef","names":["packages/llm/llm/src/assistant-stream.ts#AssistantStreamRecord"],"sources":["packages/llm/llm/src/assistant-stream.ts:20"]}, - {"digest":"335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5","names":["packages/core/session/src/types.ts#SurfaceOp"],"sources":["packages/core/session/src/types.ts:463"]}, + {"digest":"335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5","names":["packages/core/session/src/types.ts#SurfaceOp"],"sources":["packages/core/session/src/types.ts:464"]}, {"digest":"347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd","names":[],"sources":[]}, {"digest":"34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66","names":[],"sources":[]}, {"digest":"35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc","names":[],"sources":[]}, @@ -217,7 +217,7 @@ {"digest":"3bec87f9e6ab3da03346f49923d3c51621debc0159df68605e543c3b2a026f90","names":[],"sources":["packages/llm/llm/src/types.ts:471"]}, {"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1","names":[],"sources":[]}, {"digest":"3c2535ca0a2062f172f15344f8040755002b5ac506cf94fc4ebaa822b15feb8c","names":[],"sources":[]}, - {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:326"]}, + {"digest":"3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f","names":["packages/core/tools/src/contracts.ts#ToolRestriction"],"sources":["packages/core/tools/src/contracts.ts:376"]}, {"digest":"3d7d07060a7be77984feb651ea2bf1925d3e0477911d9d3ed86330d1e8ca6182","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackItem"],"sources":["packages/feedback/message-feedback/src/types.ts:20"]}, {"digest":"3e0eff5946255ff05d88a10839c60305135801f97794ea48d409f8503303317e","names":[],"sources":[]}, {"digest":"3e2ece4967cfebe95f450a93f45523dff99d6749f6269f2f60f84dfecfcf3693","names":[],"sources":[]}, @@ -249,7 +249,7 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:194"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:379","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:380","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:62","packages/web/web-search-deepseek/src/provider.ts:66"]}, @@ -265,15 +265,15 @@ {"digest":"525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336","names":[],"sources":[]}, {"digest":"529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05","names":[],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734","names":[],"sources":[]}, - {"digest":"5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777","names":[],"sources":["packages/core/session/src/types.ts:376"]}, - {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:274"]}, + {"digest":"5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777","names":[],"sources":["packages/core/session/src/types.ts:377"]}, + {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:275"]}, {"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac","names":[],"sources":[]}, {"digest":"53bb37430d38c7256e3868155ca5a03f4d8a46f4b124b2d22152e3752c0cb574","names":[],"sources":[]}, {"digest":"53cf1704ae0709c7678bcf070c83c619fc48ca1b5c173b74cfa8702f22a8e492","names":[],"sources":[]}, - {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:356"]}, + {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:357"]}, {"digest":"557e148d30b79ee48645850832b30c717d7c3b64641deb5d6133b8b4e91ee0b8","names":[],"sources":[]}, {"digest":"5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c","names":[],"sources":[]}, - {"digest":"56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23","names":[],"sources":["packages/core/session/src/types.ts:222"]}, + {"digest":"56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23","names":[],"sources":["packages/core/session/src/types.ts:223"]}, {"digest":"570e85022434613394418ad33e77e9f1f7a05699b33d0b62eae0677a638c3833","names":[],"sources":[]}, {"digest":"576a2de8355eb09850963ff5953420b172e274d9283666eb7d889097db120796","names":[],"sources":[]}, {"digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc","names":[],"sources":[]}, @@ -282,7 +282,7 @@ {"digest":"5808e36c95355f0e745201b5db1b6bcec4ebf5adb75d3e82d7d59cc89635ab80","names":["packages/llm/llm/src/types.ts#FinishReason"],"sources":["packages/llm/llm/src/types.ts:165"]}, {"digest":"587bb11da730e06aa52b62c087d217d6f8f22459b25f31cb9e1e1baf17944e5e","names":[],"sources":[]}, {"digest":"589d0d91239681f89ee485459dc8c2f73ea47183b5534289d966637024a2c35d","names":[],"sources":[]}, - {"digest":"58d643f2ec961461c8b14b737f081f047cd5d11890df8682305c55118dd5d1bf","names":[],"sources":["packages/core/session/src/types.ts:312"]}, + {"digest":"58d643f2ec961461c8b14b737f081f047cd5d11890df8682305c55118dd5d1bf","names":[],"sources":["packages/core/session/src/types.ts:313"]}, {"digest":"58f94390b982f8a5ac1f4f235ba612557f4a1dc4a995d8b1cec63ce7639decb0","names":[],"sources":[]}, {"digest":"5abab3a27fd2736660ecddd94b28d6bbf114986dd894880153bd1be3ccacb563","names":[],"sources":[]}, {"digest":"5b232a9b9bfcc0fc6794dab7865bcf203a65785c5de5aa647a51228bff628413","names":[],"sources":[]}, @@ -329,14 +329,14 @@ {"digest":"6ab45ac76839722df912f3ba50f1c6b41f2d614bde11f1aabfae04117ccdc80d","names":[],"sources":[]}, {"digest":"6b23ed9ce1b397e602788dbe033693bf97642a69e23fcab1cc45bf6f13b6d619","names":[],"sources":[]}, {"digest":"6bbe793f6a93dc8d50a4d4d3fbb6f8ed15650bcb7e151d28669628f7b7c3fc25","names":["packages/goal/goal/src/types.ts#GoalSnapshot"],"sources":["packages/goal/goal/src/types.ts:60"]}, - {"digest":"6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5","names":[],"sources":["packages/core/session/src/types.ts:331"]}, + {"digest":"6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5","names":[],"sources":["packages/core/session/src/types.ts:332"]}, {"digest":"6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800","names":[],"sources":["packages/core/session/src/types.ts:192"]}, {"digest":"6c1cccb433ddc153facb380c7dda956237ba5c31f8a532a69142dc277890cc9b","names":[],"sources":[]}, {"digest":"6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d","names":[],"sources":["packages/core/session/src/types.ts:193"]}, {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:388"]}, + {"digest":"6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def","names":[],"sources":["packages/api/session-controller/src/types.ts:389"]}, {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, @@ -359,7 +359,7 @@ {"digest":"763c8a20af487263a0080548274f7437ef4a86e0ba8769127d1ced10a72c664d","names":[],"sources":[]}, {"digest":"76632533f67879dcb346fcae640540dbc89b2472b0bce88c59f7e48fc5c0e668","names":[],"sources":[]}, {"digest":"78a9be340090b735a39980e7222ce730c972fbcf1de7c9e0aef2125884b7caee","names":[],"sources":[]}, - {"digest":"79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985","names":[],"sources":["packages/core/session/src/types.ts:213"]}, + {"digest":"79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985","names":[],"sources":["packages/core/session/src/types.ts:214"]}, {"digest":"79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99","names":[],"sources":["packages/todo/tool-todo/src/types.ts:33"]}, {"digest":"7a651f3b672c52fb615f22c0ce9cb3cc5b22aad92e6bbeefb0808fd7b9233a50","names":[],"sources":[]}, {"digest":"7af85bf70d4eafce63e739adae38ea85501e68df3a0e22f97f3a0be90fd9e4cb","names":[],"sources":[]}, @@ -417,7 +417,7 @@ {"digest":"8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af","names":["packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:237"]}, {"digest":"8f324cea3916c0517261e88167def8efb3a9994c1135e23d40ed9fd48f8c7bd4","names":[],"sources":[]}, {"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]}, - {"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:18"]}, + {"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:28"]}, {"digest":"904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd","names":[],"sources":[]}, {"digest":"929c90ad641192d1a3de9d1aa5adb889856e2f4e20188e9c057b25eee88aa13c","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:37"]}, {"digest":"930c1cd289def119d443e19dd90008916bf035f0a4d23e07241e7f050f2b89b7","names":["packages/interaction/user-approval/src/types.ts#ApprovalOutcome"],"sources":["packages/interaction/user-approval/src/types.ts:32"]}, @@ -442,7 +442,7 @@ {"digest":"99f0e98184709ad10f27bfdeaafb4457d9d2b413f402331764798cdb7d3cbe4d","names":[],"sources":[]}, {"digest":"9a2a9029f8d7ede05336980d8342737557f487b913bfd28853d0ab5214600ab5","names":[],"sources":["packages/web/web-search-deepseek/src/provider.ts:64"]}, {"digest":"9a410ddf56792b2b7cd4a224a2e54dc4dee30a8e8e6b1c2b9a1d19992b217adc","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowAgentStartData"],"sources":["packages/workflow/tool-workflow/src/types.ts:20"]}, - {"digest":"9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7","names":["packages/core/session/src/types.ts#EpochHeader"],"sources":["packages/core/session/src/types.ts:241"]}, + {"digest":"9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7","names":["packages/core/session/src/types.ts#EpochHeader"],"sources":["packages/core/session/src/types.ts:242"]}, {"digest":"9ac52f0f84fec2eb9f71c2937e497d759db5ca0da4836d93a679d113684141f2","names":[],"sources":[]}, {"digest":"9acbe713f3f641921dd3333b240aa72e176d795327c24e8d9db9b68b16771b49","names":[],"sources":[]}, {"digest":"9b4fd974c124c83743efe030ba4e1250e087505f3f74588716a521fb8d453267","names":[],"sources":[]}, @@ -475,7 +475,7 @@ {"digest":"a720757f704a2512e9d2f20da4a093e582472176236a75ab8b8eb7aaff17d85a","names":[],"sources":[]}, {"digest":"a7241f5a94c73b544eef17a344b3f09ba12943066c611b40a1cf8e78dc9543ae","names":[],"sources":[]}, {"digest":"a7cf43ce7c2a4c038feed1885cd7a00d5c6ee2d90a7e0d56b46f78a3e1ca327f","names":[],"sources":[]}, - {"digest":"a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735","names":[],"sources":["packages/core/session/src/types.ts:384","packages/core/tool-executor-ptc/src/types.ts:27"]}, + {"digest":"a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735","names":[],"sources":["packages/core/session/src/types.ts:385","packages/core/tool-executor-ptc/src/types.ts:27"]}, {"digest":"a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86","names":[],"sources":["packages/interaction/commands/src/types.ts:105"]}, {"digest":"a8a2b9011fc067862df7ce40748868467e5778283d7ac193c13f77684a914c57","names":[],"sources":[]}, {"digest":"a9059a81430b320792bbb7c2ee1f8f6c77ff71e40ee44c18aec72e157c522694","names":[],"sources":[]}, @@ -488,10 +488,10 @@ {"digest":"aa93010d0e521ad7c42273f58d5c9b8bf1dfc94edf1b805a3971b364a35c1877","names":[],"sources":[]}, {"digest":"aacda84a69739238aa782eea902d3073017d9d74b9808de2bf5a83c9738cbdc6","names":[],"sources":[]}, {"digest":"aae343bc3d663c83f2e8b5e1c7eea5d509dc7a5361bc8bd8b5ac3bb8d3b1ed56","names":[],"sources":[]}, - {"digest":"ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7","names":[],"sources":["packages/core/session/src/types.ts:215","packages/llm/llm/src/types.ts:159"]}, + {"digest":"ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7","names":[],"sources":["packages/core/session/src/types.ts:216","packages/llm/llm/src/types.ts:159"]}, {"digest":"aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba","names":[],"sources":[]}, {"digest":"abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c","names":["packages/web/web-search-deepseek/src/provider.ts#DeepSeekSearchLlmRequest"],"sources":["packages/web/web-search-deepseek/src/provider.ts:55"]}, - {"digest":"ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585","names":[],"sources":["packages/core/session/src/types.ts:391"]}, + {"digest":"ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585","names":[],"sources":["packages/core/session/src/types.ts:392"]}, {"digest":"ad22a5ca5bbaab2e1440779cd3648e85ddfc6f73b5207394eda699555cf31430","names":[],"sources":[]}, {"digest":"ad3b56aa2fc1ad4250e7399295d34a8eae592e1907e85852e2291b2e498a30bc","names":[],"sources":[]}, {"digest":"ad92f473a03ae72599ff26c86e897c25e16269519ad2a87340f0bea946517af8","names":[],"sources":[]}, @@ -505,7 +505,7 @@ {"digest":"b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d","names":[],"sources":[]}, {"digest":"b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1","names":[],"sources":[]}, {"digest":"b0b0eb1b16490f6bee135b2377e8f8c39227974c799ad7d49ff418af23ecbc35","names":["packages/attachment/attachment/src/types.ts#ImageAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:11"]}, - {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:255"]}, + {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:256"]}, {"digest":"b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7","names":[],"sources":[]}, {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/experimental/agent-team/src/types.ts:230"]}, {"digest":"b1f62380f6746fedd629e71065e3583cef83334875b499ac9665c2eb87acfbf5","names":[],"sources":[]}, @@ -545,7 +545,7 @@ {"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3","names":[],"sources":[]}, {"digest":"bb94f3217aa56c8876bbff0b8460d6a73cbead545659abb29e76cd0cf2cb92d6","names":[],"sources":[]}, {"digest":"bbc5696e3f13c758237b316e3cf08cda0b9fd51ffe672539883457769faa185d","names":[],"sources":[]}, - {"digest":"bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825","names":[],"sources":["packages/core/session/src/types.ts:465"]}, + {"digest":"bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825","names":[],"sources":["packages/core/session/src/types.ts:466"]}, {"digest":"bd499eac047efe091a4df7382eadd06d25cf0cb31fee3981e8b24d85762841cd","names":[],"sources":[]}, {"digest":"bf16d2e98a7f13894ded2cffe5ce857bf473954a0e3c0ed9bc7739e2e1c7b78a","names":[],"sources":[]}, {"digest":"c0ea97bc89dfc46fa94eab956f5c3ee6af2f964aa7db6327cb18b451f207bef7","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowAgentEndData"],"sources":["packages/workflow/tool-workflow/src/types.ts:29"]}, @@ -622,8 +622,8 @@ {"digest":"e41bdebe246abe17154de7e7a1d81035437eee8d20e7f1edff0d91e2598ad828","names":[],"sources":[]}, {"digest":"e42b0f40b5d0b628c6e1c44a8fb9eda0780037a81959e1b93d2dfb47b46b0e87","names":[],"sources":[]}, {"digest":"e438a9d4fb883b3e4a8d9fc8913aa1550ed50eb384bfbf943100a5605b537498","names":[],"sources":[]}, - {"digest":"e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654","names":[],"sources":["packages/core/session/src/types.ts:298"]}, - {"digest":"e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1","names":[],"sources":["packages/core/session/src/types.ts:362"]}, + {"digest":"e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654","names":[],"sources":["packages/core/session/src/types.ts:299"]}, + {"digest":"e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1","names":[],"sources":["packages/core/session/src/types.ts:363"]}, {"digest":"e4cac694f8b6e1d52e48c699464f1c4c26111ef87e558f7931040d00fdc8d486","names":[],"sources":[]}, {"digest":"e5d3828df1ec3e2a66879de0659e0f4866d9cfffc00825ee731a66f8a0c03d98","names":[],"sources":[]}, {"digest":"e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344","names":["packages/schedule/schedule/src/types.ts#EveryScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:244"]}, @@ -639,18 +639,18 @@ {"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:197"]}, {"digest":"ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8","names":[],"sources":[]}, {"digest":"ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93","names":[],"sources":[]}, - {"digest":"ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7","names":[],"sources":["packages/core/session/src/types.ts:229"]}, + {"digest":"ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7","names":[],"sources":["packages/core/session/src/types.ts:230"]}, {"digest":"edbbfff1128099fda7b792c56330d2cb04debc9790988e66d1c3f4026e813e0e","names":[],"sources":[]}, {"digest":"edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf","names":[],"sources":[]}, {"digest":"ee577a0cc18664c9daee50e9455153321155604f8f1817362f8f4a4b6fb9e1ad","names":[],"sources":[]}, {"digest":"eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4","names":["packages/schedule/schedule/src/types.ts#ScheduleCreateChange"],"sources":["packages/schedule/schedule/src/types.ts:223"]}, {"digest":"eefbea819caeeea91ccb02dfafc16c68dd7f961cf9c1b2be25a2084509cf5c5a","names":[],"sources":[]}, - {"digest":"ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545","names":[],"sources":["packages/core/session/src/types.ts:300","packages/core/session/src/types.ts:302"]}, + {"digest":"ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545","names":[],"sources":["packages/core/session/src/types.ts:301","packages/core/session/src/types.ts:303"]}, {"digest":"ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21","names":[],"sources":[]}, {"digest":"ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45","names":[],"sources":[]}, {"digest":"f05fb85e715af4ab4de0b6ba111166dd0435bd8bb74a0fa0f32b9d174113fc7c","names":[],"sources":[]}, {"digest":"f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b","names":["packages/core/agent/src/types.ts#ModelSelection"],"sources":["packages/core/agent/src/types.ts:79"]}, - {"digest":"f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402","names":["packages/core/session/src/types.ts#TurnEndReason"],"sources":["packages/core/session/src/types.ts:233"]}, + {"digest":"f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402","names":["packages/core/session/src/types.ts#TurnEndReason"],"sources":["packages/core/session/src/types.ts:234"]}, {"digest":"f1996af2602e6f62b5ab179ca5c4af00d8753938b74cc6ef102de09e99e74a7e","names":[],"sources":[]}, {"digest":"f19bee84bf3d20904553cdcd57465232bc1c2a194f54c4d195292ba0487223a6","names":["packages/attachment/attachment/src/types.ts#FileAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:39"]}, {"digest":"f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515","names":[],"sources":["packages/llm/llm/src/types.ts:476"]}, @@ -671,7 +671,7 @@ {"digest":"f731ffe80810b0289b3e694d8f40af6f01865c02a622ee457be90ca808b66e87","names":["packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent"],"sources":["packages/subagent/subagent/src/catalog.ts:28"]}, {"digest":"f789af5ab844827eb5a4f357c0afae1eeed108c6c22d8ca1a56e586b349a7c85","names":[],"sources":[]}, {"digest":"f88f1b7b6bb733f685f5fb6a8744a5db9199fa53c90463ea1a33bad51153671d","names":[],"sources":[]}, - {"digest":"f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d","names":[],"sources":["packages/core/session/src/types.ts:289","packages/deliverables/workspace-changes/src/types.ts:106"]}, + {"digest":"f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d","names":[],"sources":["packages/core/session/src/types.ts:290","packages/deliverables/workspace-changes/src/types.ts:106"]}, {"digest":"f97f47b0eb87905ed77cc27c5e4ed75ef805cb372c98905253709de1aab9b63c","names":[],"sources":[]}, {"digest":"f99e707d67fd708b9c282bad0428e8bc8c69777d93d283bf152f20fd9eb3e36f","names":[],"sources":[]}, {"digest":"f9cfc89e8fe085b4fcc9cd32b74b6f103608c54d7d8cd97b47c01de34f252ba3","names":[],"sources":[]}, diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index b548deda3d..621444176d 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -6,8 +6,8 @@ en: 564dfcaaca384e23 zh: 5d215635919b5bc4 /durable-attachments/identity-and-verified-metadata: - en: 3ddfd5100061dc6a - zh: 3eedb90c8813d015 + en: 96378c208b220c6f + zh: 7af93f6a79520732 /durable-attachments/commit-and-verified-read-payloads: - en: 01c9eb5d67e3c7c3 - zh: abf32dddcca5fce1 + en: f79753272bb0ec70 + zh: 61830c41021f9274 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 12ae675e3c..2e330672f7 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -56,7 +56,7 @@ interface ImageAttachmentLimits { } ``` -The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 20 MiB, 64,000,000 pixels, and 8192 pixels on either side. These source limits precede the independent normalization stage, which limits the long edge to 2048 pixels and encoded data to 4 MiB by default. +The local backend admits at most 20 images and 200 MiB of encoded source data per message. One source may use up to 50 MiB, 64,000,000 pixels, and 65,535 pixels on either side. These source limits precede the independent normalization stage, which limits the long edge to 2048 pixels and encoded data to 4 MiB by default. 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. @@ -80,10 +80,16 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +/** + * Host prompt content whose upload receipts are resolved. An image part + * carries encoded bytes awaiting admission, or references an image this + * service already stored for an upload the same Session staged, with + * `encodedBytes` holding the byte length of that upload. + */ type AttachmentAdmissionPart = | PromptContentPart | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef; readonly encodedBytes: number } ``` ```ts type-equiv @@ -167,7 +173,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version at an exact route-chosen target size and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after upload receipt resolution, replaces base64 image uploads with durable references, passes durable file references and already stored images unchanged, and counts every image toward the per-message image count and aggregate byte limits, a stored image by the bytes of its upload. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version at an exact route-chosen target size and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -200,9 +206,12 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one Host prompt and replace each uploaded image with its durable reference. - * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. - * @param content - prompt parts in message order after file receipt resolution. + * Admit one Host prompt and replace each encoded image with its durable reference. + * Text, durable file references, and already stored images pass through unchanged. + * Every image part counts toward `maxImagesPerMessage` and `maxMessageImageBytes`; + * a stored image counts the bytes of the upload that stored it. Refused content + * starts no writes. A prompt without encoded images performs no storage operation. + * @param content - prompt parts in message order after upload receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ @@ -320,7 +329,7 @@ registerAgentResolver(resolve: AgentResolver): () => void * @param signal - caller cancellation before storage begins. * @returns the staged receipt and durable file reference. */ -@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise +@Remote('upload') async upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise /** * Persist raw chunks for one Session without aggregating the upload. @@ -329,13 +338,27 @@ registerAgentResolver(resolve: AgentResolver): () => void */ async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise +/** + * Persist one prompt image for a Session: read at most the smaller of the + * deployment's per-image and per-message byte limits, then validate, + * normalize, and store the image the way prompt admission does, so a later + * prompt cites it by receipt. + * Cancellation before storage begins stores nothing; a cancelled upload + * never receives a receipt, although an image whose storage already began + * stays in the content-addressed store. + * @param request - Session identity, ordered bytes, the declared media type that this deployment + * must accept, cancellation, and optional display name. + * @returns the staged receipt and durable normalized image reference. + */ +async uploadImageStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly mediaType: string readonly signal?: AbortSignal readonly name?: string }): Promise + /** * Resolve one staged receipt for the receiving Agent's exact Session. * @param agent - receiving Agent. * @param receiptId - opaque receipt minted for one completed upload. - * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + * @returns the staged file or image, or `undefined` for an unknown or foreign receipt. */ -resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined +resolve(agent: Agent, receiptId: FileUploadReceiptId): StagedAttachment | undefined /** * Bind receipts while one prompt enters an Agent inbox. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 06ea726f19..057ed080f4 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -56,7 +56,7 @@ interface ImageAttachmentLimits { } ``` -本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 20 MiB、64,000,000 像素和单边 8192 像素。这些源文件限制先于独立的规范化阶段执行;该阶段默认把长边限制为 2048 像素,把编码数据限制为 4 MiB。 +本地后端每条消息最多准入 20 张图片,源图编码数据总量不超过 200 MiB。单张源图不得超过 50 MiB、64,000,000 像素和单边 65,535 像素。这些源文件限制先于独立的规范化阶段执行;该阶段默认把长边限制为 2048 像素,把编码数据限制为 4 MiB。 引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。 @@ -80,10 +80,16 @@ type PromptContentPart = ``` ```ts type-equiv -/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +/** + * Host prompt content whose upload receipts are resolved. An image part + * carries encoded bytes awaiting admission, or references an image this + * service already stored for an upload the same Session staged, with + * `encodedBytes` holding the byte length of that upload. + */ type AttachmentAdmissionPart = | PromptContentPart | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef; readonly encodedBytes: number } ``` ```ts type-equiv @@ -167,7 +173,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在上传凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,让持久文件引用和已存储的图片原样通过,并把每张图片计入单条消息的图片数量与总字节上限,已存储的图片按其上传的字节数计入。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -200,9 +206,12 @@ abstract validateImage(input: SaveImageAttachment): Promise async saveImages(inputs: readonly SaveImageAttachment[]): Promise /** - * Admit one Host prompt and replace each uploaded image with its durable reference. - * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. - * @param content - prompt parts in message order after file receipt resolution. + * Admit one Host prompt and replace each encoded image with its durable reference. + * Text, durable file references, and already stored images pass through unchanged. + * Every image part counts toward `maxImagesPerMessage` and `maxMessageImageBytes`; + * a stored image counts the bytes of the upload that stored it. Refused content + * starts no writes. A prompt without encoded images performs no storage operation. + * @param content - prompt parts in message order after upload receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ @@ -320,7 +329,7 @@ registerAgentResolver(resolve: AgentResolver): () => void * @param signal - caller cancellation before storage begins. * @returns the staged receipt and durable file reference. */ -@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise +@Remote('upload') async upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise /** * Persist raw chunks for one Session without aggregating the upload. @@ -329,13 +338,27 @@ registerAgentResolver(resolve: AgentResolver): () => void */ async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise +/** + * Persist one prompt image for a Session: read at most the smaller of the + * deployment's per-image and per-message byte limits, then validate, + * normalize, and store the image the way prompt admission does, so a later + * prompt cites it by receipt. + * Cancellation before storage begins stores nothing; a cancelled upload + * never receives a receipt, although an image whose storage already began + * stays in the content-addressed store. + * @param request - Session identity, ordered bytes, the declared media type that this deployment + * must accept, cancellation, and optional display name. + * @returns the staged receipt and durable normalized image reference. + */ +async uploadImageStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly mediaType: string readonly signal?: AbortSignal readonly name?: string }): Promise + /** * Resolve one staged receipt for the receiving Agent's exact Session. * @param agent - receiving Agent. * @param receiptId - opaque receipt minted for one completed upload. - * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + * @returns the staged file or image, or `undefined` for an unknown or foreign receipt. */ -resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined +resolve(agent: Agent, receiptId: FileUploadReceiptId): StagedAttachment | undefined /** * Bind receipts while one prompt enters an Agent inbox. diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index a55599bd51..5e92dac5a5 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -15,8 +15,8 @@ en: ee04468005fae8e1 zh: 1347d554102951f2 /plan-mode/the-exit-tool-and-the-plan-command: - en: b5bbc440552d7ade - zh: 17fb1cfed1bc48ef + en: 32ecfb7086dd1578 + zh: 0aaeaa69bb63f255 /plan-mode/the-service: - en: 07c3eec12e0dc75c - zh: 183d73f6672e2c5a + en: f3e6d5a7fe0b67f0 + zh: 774cc97e493991fa diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 68e982ec34..a37207d868 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -30,13 +30,13 @@ A missing, blank, or non-string `section` and any unknown key fail at plugin loa ## 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 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-questions seam](user-questions.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 dismissed review returns `{ approved: false }` and concludes the turn, so plan mode stays active until the user's next message. 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) is disclosed progressively: it is admitted from the first step whose selected plan state is active and stays admitted for the rest of the Session, so the first plan-mode entry adds it to the request tool declarations and later transitions change only the plan guidance. Native and PTC execution use the same admitted set; a call before the first entry returns `UNKNOWN_TOOL`, and a call while plan mode is not committed fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.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 dismissed review returns `{ approved: false }` and concludes the turn, so plan mode stays active until the user's next message. 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 pending entry before it is appended and becomes visible to a request. ## The service -`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--planmodecontroller). +`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 conditional exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodecontroller). @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`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. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the conditional exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. ```ts cordis-catalog /** diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index 25c31455ad..8f45d70c5b 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -30,13 +30,13 @@ interface PlanModeConfig { ## 退出工具与 `/plan` 命令 -[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交。放弃评审返回 `{ approved: false }` 并结束当前轮次,计划模式保持激活,直到用户发出下一条消息。评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 +[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 采用渐进式披露:从已选计划状态首次为激活的步骤起准入,并在会话剩余部分保持准入,因此首次进入计划模式时它进入请求的工具声明,之后的切换只改变计划指引。原生与 PTC 执行使用同一准入集合;首次进入之前调用返回 `UNKNOWN_TOOL`,计划模式未提交时调用会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交。放弃评审返回 `{ approved: false }` 并结束当前轮次,计划模式保持激活,直到用户发出下一条消息。评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 当 [`ctx.commands`](commands.zh.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。 ## 服务 -`ctx.planMode` 拥有已记录的计划状态,在步骤开始时应用并叙述选中的状态,还拥有 `plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodecontroller)。 +`ctx.planMode` 拥有已记录的计划状态,在步骤开始时应用并叙述选中的状态,还拥有 `plan:policy` 段落、`/plan` 命令和按条件准入的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodecontroller)。 @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`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. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the conditional exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. Agent prompt assembly fails while the projection reader is not ready; callers must await plugin activation before running an agent. ```ts cordis-catalog /** diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 8413bb42c9..e9369b0702 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -708,8 +708,9 @@ interface TurnEndReasonMap { blocked: { kind: 'blocked' } /** * The turn failed. `error` is always a structured failure: the `LlmError` - * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }` - * flattened from any other error. + * facts verbatim, or `{ message: errorChain(error), code }` flattened from + * any other error, where `code` is `TOOL_ACTIVATION_FAILED` for a failed + * tool condition and `UNKNOWN` otherwise. */ error: { kind: 'error'; error: LlmFailure } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 2a7400af4f..7971f5e7fd 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -712,8 +712,9 @@ interface TurnEndReasonMap { blocked: { kind: 'blocked' } /** * The turn failed. `error` is always a structured failure: the `LlmError` - * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }` - * flattened from any other error. + * facts verbatim, or `{ message: errorChain(error), code }` flattened from + * any other error, where `code` is `TOOL_ACTIVATION_FAILED` for a failed + * tool condition and `UNKNOWN` otherwise. */ error: { kind: 'error'; error: LlmFailure } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index c87334dcd2..ee803c1dfc 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -193,12 +193,12 @@ refreshContext(assembly: PromptAssembly, context: AssembleContext = {}): PromptA /** * Assemble global and scoped providers, detach tool parameters, apply - * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals. The returned waterfall value is authoritative - * except that an effective complete section is restored afterwards as the - * sole prompt section. - * @param context - the optional scope and plugin-defined assembly fields. - * @returns the post-waterfall assembly with any complete prompt enforced. + * canonical ordering, then run the presentation waterfall. Scoped sections + * and variables shadow globals. The waterfall's text is authoritative except + * that an effective complete section is restored afterwards as the sole + * prompt section; tool declarations stay provider-owned. + * @param context - the optional scope, step tool set, and plugin-defined assembly fields. + * @returns the post-waterfall assembly with ordered tool declarations and any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` @@ -213,13 +213,13 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system- #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. +Presentation waterfall over assembled sections, contexts, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** - * Expert waterfall over the assembled sections, contexts, tools, and variables. + * Presentation waterfall over assembled sections, contexts, and variables. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners - * receive only that scope's assemblies. The returned value is authoritative. + * receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned. * A supplied signal controls only this explicit assembly request and must not * be retained to control later turns. A registered complete section is * restored after this waterfall, so listeners cannot add to or replace @@ -228,7 +228,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * @param context - the caller's per-assembly context. * @mode waterfall */ -'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise +'system-prompt/assemble'(this: Scoped, assembly: PromptPresentation, context: AssembleContext, next: () => Promise): Promise ``` Types: [Scoped](scope.md) diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index 6ca4ca97e8..fad3105e64 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -193,12 +193,12 @@ refreshContext(assembly: PromptAssembly, context: AssembleContext = {}): PromptA /** * Assemble global and scoped providers, detach tool parameters, apply - * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals. The returned waterfall value is authoritative - * except that an effective complete section is restored afterwards as the - * sole prompt section. - * @param context - the optional scope and plugin-defined assembly fields. - * @returns the post-waterfall assembly with any complete prompt enforced. + * canonical ordering, then run the presentation waterfall. Scoped sections + * and variables shadow globals. The waterfall's text is authoritative except + * that an effective complete section is restored afterwards as the sole + * prompt section; tool declarations stay provider-owned. + * @param context - the optional scope, step tool set, and plugin-defined assembly fields. + * @returns the post-waterfall assembly with ordered tool declarations and any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` @@ -213,13 +213,13 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system- #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. +Presentation waterfall over assembled sections, contexts, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** - * Expert waterfall over the assembled sections, contexts, tools, and variables. + * Presentation waterfall over assembled sections, contexts, and variables. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners - * receive only that scope's assemblies. The returned value is authoritative. + * receive only that scope's assemblies. The returned text is authoritative; tool declarations remain provider-owned. * A supplied signal controls only this explicit assembly request and must not * be retained to control later turns. A registered complete section is * restored after this waterfall, so listeners cannot add to or replace @@ -228,7 +228,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * @param context - the caller's per-assembly context. * @mode waterfall */ -'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise +'system-prompt/assemble'(this: Scoped, assembly: PromptPresentation, context: AssembleContext, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index b05b723c74..f35d7ae166 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -14,6 +14,9 @@ /tools/toolrestriction-one-scope-s-live-filter-over-what-it-inherits: en: 8c3fc4ca26f7e314 zh: 2e27263cb25243b9 +/tools/conditional-activation-step-owned-admission: + en: 472eefe8fab01e57 + zh: 0b98e74f87a6c9c0 /tools/executors-and-raw-call-batches: en: 14522295539ea2fb zh: 572c462f2c6b70ad diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index bff827d584..e94206a685 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -177,6 +177,50 @@ interface ToolRestriction { } ``` +## Conditional activation — step-owned admission + +A registration may carry a `when` condition. AgentLoop calls `ctx.tools.resolve()` once per proposed step with the captured model selection; the returned set decides that step's tool declarations, executor sections, and every call in its batch, including PTC program subcalls. `ctx.tools.executable()` applies the set at execution and, without one, excludes conditional registrations. A same-name replacement or a new restriction stops unstarted calls from using an admitted registration, and the loop disposes the set once the step's batch settles. + +```ts type-equiv +/** Inputs for a tool's step-time activation condition. */ +interface ToolActivationContext { + /** Agent preparing the step. */ + readonly agent: Agent + /** Provider, model, and optional reasoning effort captured for the step. */ + readonly modelSelection: Readonly + /** Cancellation of step preparation; asynchronous conditions must observe it. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Lifecycle-owned conditions for a registered tool. */ +interface ToolRegistrationOptions { + /** + * Decide whether the step's requests may call the tool. Omission enables + * it. False excludes it from both declarations and dispatch; rejection fails + * step preparation. Conditions must not mutate registrations or start tool work. + * @param context - preparing agent, captured model selection, and cancellation signal. + * @returns whether the tool participates in this step. + */ + readonly when?: (context: ToolActivationContext) => boolean | Promise +} +``` + +```ts type-equiv +/** One step's admitted registrations, also used by nested PTC calls. */ +interface ResolvedToolSet { + /** Close admission after the step's tool batch settles. */ + dispose(): void + /** + * Read admitted registrations that remain visible in their original scope. + * @param scope - exact requesting agent; another scope is never admitted. + * @returns original definitions excluding removed or restricted registrations. + */ + view(scope: ScopeKey | undefined): ReadonlyMap +} +``` + ## Executors and raw call batches `ctx.tools.execute()` delegates a complete raw model-call batch to the nearest scoped executor. The service selects the current provider when each batch starts; prompt assembly returns only model input. `agent` is absent for standalone service calls; `log` is absent for programmatic calls outside a model step, such as initialization hooks. An Agent without `log` still scopes tool access but produces no root call/result events. Tool-owned events may still use its Session. @@ -202,6 +246,11 @@ interface ToolBatchInput { * Undefined for standalone calls outside an Agent, such as a service invocation. */ readonly agent?: Agent + /** + * Step admission for every call in the batch, including program subcalls. + * Undefined admits only unconditional registrations. + */ + readonly toolSet?: ResolvedToolSet /** * Committed model step receiving root call/result events; requires `agent`. * Undefined when `agent` is absent or for calls outside a model response, such @@ -288,6 +337,8 @@ interface ToolExecutionInput { readonly arguments: unknown /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent + /** Step admission shared by Native and PTC dispatch. Conditional tools require it. */ + readonly toolSet?: ResolvedToolSet /** * Opaque token of the enclosing transport execution, when one exists. PTC * mode sets this on SDK sub-dispatches so commit-style observers can wait for @@ -594,9 +645,10 @@ Concrete tool registry and scoped executor dispatcher. * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. * @param definition - tool schema, execution, and optional finalization/presentation callbacks. + * @param options - optional step-time activation condition. * @returns the exact disposer that unregisters the tool. */ -register(definition: ToolDefinition): () => void +register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void /** * Restrict global tools for the calling agent scope. Empty filters, unknown @@ -614,24 +666,46 @@ restrict(filter: ToolRestriction): () => void * actually executed. * @param name - the tool name as registered. * @param scope - the viewing scope (the agent); omitted = the global view. + * @param toolSet - step admission; omit only to inspect registered definitions. * @returns the definition the scope resolves, or undefined when none is visible. */ -get(name: string, scope?: ScopeKey): ToolDefinition | undefined +get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined + +/** + * Resolve the definition a call may execute. With a step's admitted set, the + * set decides; without one, conditional registrations are unavailable. + * @param name - the tool name as registered. + * @param scope - the calling agent; omitted = the global view. + * @param toolSet - step admission, or omitted for a call outside a model step. + * @returns the executable definition, or undefined when the call must fail as `UNKNOWN_TOOL`. + */ +executable(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined + +/** + * Resolve conditions once for this step without changing shared registrations. + * Cancellation or a rejected condition publishes no partial selection. Removed + * registrations cannot be restored by a late condition result. + * @param context - preparing agent, captured model selection, and cancellation. + * @returns the complete selection; the caller disposes it after its tool batch settles. + */ +async resolve(context: ToolActivationContext): Promise /** * Read the visible capability definitions for descriptions and program bindings. * @param scope - viewing Agent or preset; omitted selects the global view. - * @returns definitions after scoped shadowing and restrictions. + * @param toolSet - step admission; omit only to inspect registered definitions. + * @returns definitions after scoped shadowing and restrictions, limited to the step's admission when given. */ -definitions(scope?: ScopeKey): readonly ToolDefinition[] +definitions(scope?: ScopeKey, toolSet?: ResolvedToolSet): readonly ToolDefinition[] /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. * @param scope - the viewing scope (the agent); omitted = the global view. + * @param toolSet - step admission, or omitted for registered-definition inspection. * @returns one deep-cloned schema per visible tool. */ -schemas(scope?: ScopeKey): ToolSchema[] +schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[] /** * Provide the executor for the calling scope; an unscoped contribution is the deployment default. diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 53dbd43fb7..e18dfbbe5d 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -177,6 +177,50 @@ interface ToolRestriction { } ``` +## 条件准入 — 步骤拥有的准入集合 + +注册可以携带 `when` 条件。AgentLoop 为每个拟议步骤以捕获的模型选择调用一次 `ctx.tools.resolve()`;返回的集合决定该步骤的工具声明、执行器分区以及批次中的每次调用,包括 PTC 程序的子调用。`ctx.tools.executable()` 在执行时应用集合,没有集合时排除条件注册。同名替换或新的限制会阻止尚未开始的调用使用已准入的注册,步骤的批次结束后循环释放集合。 + +```ts type-equiv +/** Inputs for a tool's step-time activation condition. */ +interface ToolActivationContext { + /** Agent preparing the step. */ + readonly agent: Agent + /** Provider, model, and optional reasoning effort captured for the step. */ + readonly modelSelection: Readonly + /** Cancellation of step preparation; asynchronous conditions must observe it. */ + readonly signal: AbortSignal +} +``` + +```ts type-equiv +/** Lifecycle-owned conditions for a registered tool. */ +interface ToolRegistrationOptions { + /** + * Decide whether the step's requests may call the tool. Omission enables + * it. False excludes it from both declarations and dispatch; rejection fails + * step preparation. Conditions must not mutate registrations or start tool work. + * @param context - preparing agent, captured model selection, and cancellation signal. + * @returns whether the tool participates in this step. + */ + readonly when?: (context: ToolActivationContext) => boolean | Promise +} +``` + +```ts type-equiv +/** One step's admitted registrations, also used by nested PTC calls. */ +interface ResolvedToolSet { + /** Close admission after the step's tool batch settles. */ + dispose(): void + /** + * Read admitted registrations that remain visible in their original scope. + * @param scope - exact requesting agent; another scope is never admitted. + * @returns original definitions excluding removed or restricted registrations. + */ + view(scope: ScopeKey | undefined): ReadonlyMap +} +``` + ## 执行器与原始调用批次 `ctx.tools.execute()` 把完整的原始模型调用批次委托给最近作用域的执行器。服务在每个批次开始时选择当前提供者;提示词组装仅返回模型输入。`agent` 在独立服务调用中不存在;`log` 在模型步骤之外的程序调用(例如初始化钩子)中不存在。有 Agent 而没有 `log` 时,调用仍受该 Agent 的工具作用域约束,但不记录根调用/结果事件。工具自身的事件仍可使用 Agent 的 Session。 @@ -202,6 +246,11 @@ interface ToolBatchInput { * Undefined for standalone calls outside an Agent, such as a service invocation. */ readonly agent?: Agent + /** + * Step admission for every call in the batch, including program subcalls. + * Undefined admits only unconditional registrations. + */ + readonly toolSet?: ResolvedToolSet /** * Committed model step receiving root call/result events; requires `agent`. * Undefined when `agent` is absent or for calls outside a model response, such @@ -288,6 +337,8 @@ interface ToolExecutionInput { readonly arguments: unknown /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent + /** Step admission shared by Native and PTC dispatch. Conditional tools require it. */ + readonly toolSet?: ResolvedToolSet /** * Opaque token of the enclosing transport execution, when one exists. PTC * mode sets this on SDK sub-dispatches so commit-style observers can wait for @@ -594,9 +645,10 @@ Concrete tool registry and scoped executor dispatcher. * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. * @param definition - tool schema, execution, and optional finalization/presentation callbacks. + * @param options - optional step-time activation condition. * @returns the exact disposer that unregisters the tool. */ -register(definition: ToolDefinition): () => void +register(definition: ToolDefinition, options: ToolRegistrationOptions = {}): () => void /** * Restrict global tools for the calling agent scope. Empty filters, unknown @@ -614,24 +666,46 @@ restrict(filter: ToolRestriction): () => void * actually executed. * @param name - the tool name as registered. * @param scope - the viewing scope (the agent); omitted = the global view. + * @param toolSet - step admission; omit only to inspect registered definitions. * @returns the definition the scope resolves, or undefined when none is visible. */ -get(name: string, scope?: ScopeKey): ToolDefinition | undefined +get(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined + +/** + * Resolve the definition a call may execute. With a step's admitted set, the + * set decides; without one, conditional registrations are unavailable. + * @param name - the tool name as registered. + * @param scope - the calling agent; omitted = the global view. + * @param toolSet - step admission, or omitted for a call outside a model step. + * @returns the executable definition, or undefined when the call must fail as `UNKNOWN_TOOL`. + */ +executable(name: string, scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolDefinition | undefined + +/** + * Resolve conditions once for this step without changing shared registrations. + * Cancellation or a rejected condition publishes no partial selection. Removed + * registrations cannot be restored by a late condition result. + * @param context - preparing agent, captured model selection, and cancellation. + * @returns the complete selection; the caller disposes it after its tool batch settles. + */ +async resolve(context: ToolActivationContext): Promise /** * Read the visible capability definitions for descriptions and program bindings. * @param scope - viewing Agent or preset; omitted selects the global view. - * @returns definitions after scoped shadowing and restrictions. + * @param toolSet - step admission; omit only to inspect registered definitions. + * @returns definitions after scoped shadowing and restrictions, limited to the step's admission when given. */ -definitions(scope?: ScopeKey): readonly ToolDefinition[] +definitions(scope?: ScopeKey, toolSet?: ResolvedToolSet): readonly ToolDefinition[] /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. * @param scope - the viewing scope (the agent); omitted = the global view. + * @param toolSet - step admission, or omitted for registered-definition inspection. * @returns one deep-cloned schema per visible tool. */ -schemas(scope?: ScopeKey): ToolSchema[] +schemas(scope?: ScopeKey, toolSet?: ResolvedToolSet): ToolSchema[] /** * Provide the executor for the calling scope; an unscoped contribution is the deployment default. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.i18n.yaml new file mode 100644 index 0000000000..3ca37a1f2a --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.md +/: + en: 1dc343c5aa10de02 + zh: 59ac1c2052255919 +/declare-conditional-tools-through-step-admission: + en: 4edb4c80b402de77 + zh: 856160ec69c515ea +/declare-conditional-tools-through-step-admission/change: + en: e140ab052beb78ed + zh: 9603c44ae7731ab8 +/declare-conditional-tools-through-step-admission/migration: + en: 364c2a62343a608b + zh: c00ab4611b1fc4e3 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.md new file mode 100644 index 0000000000..30d37bca41 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "Tool declarations come only from step-admitted registrations: `system-prompt/assemble` no longer carries tools, conditional tools need a resolved step set, and `exit_plan_mode` is declared only after a Session first enters plan mode." +--- + +# Declare conditional tools through step admission + +English | [中文](guide.zh.md) + +## Change + +`ctx.tools.register(definition, { when })` adds a synchronous or asynchronous condition. AgentLoop resolves conditions once per proposed step with `{ agent, modelSelection, signal }`, before prompt assembly and `agent/pre-step`, and uses the resulting `ResolvedToolSet` for tool declarations, Native dispatch, PTC bindings, and nested calls in that step and its retries. A false condition removes the tool from all of them; a thrown condition ends the turn with `TOOL_ACTIVATION_FAILED`. + +The `system-prompt/assemble` waterfall now receives sections, contexts, and variables (`PromptPresentation`) without a tool array, and a returned `tools` field is ignored. Plugins that removed or added tool declarations in that waterfall are affected; their removals no longer reach the request, and a listener that reads `assembly.tools` receives `undefined` and fails at runtime. + +Model tool calls run only through their step's admitted set. A call to a conditional tool without a resolved set, a call after the set is disposed, and a pending call whose registration was replaced or removed during the step return `UNKNOWN_TOOL` instead of running. Replacement applies to every tool, so a hot reload, MCP reconnect, or skill reload that re-registers a tool mid-step fails that step's unstarted calls of it. Callers that pass no set, such as the experimental `claude-code-mods` `$.tool.call`, cannot call conditional tools. + +`exit_plan_mode` is declared from the first step whose selected plan state is active and stays declared for the rest of the Session, so requests of a Session that never entered plan mode no longer list it. Existing Sessions require no format migration. + +## Migration + +1. Replace tool filtering and reads of `assembly.tools` in `system-prompt/assemble` listeners with a `when` condition on the registration; read the step's provider, model, and reasoning effort from the condition's `modelSelection`. Keep only section, context, and variable changes in the waterfall. +2. Make tool guidance follow the same admission: section callbacks pass `context.toolSet` to `ctx.tools.get()` or `ctx.tools.schemas()`. +3. For programmatic calls of conditional tools, call `ctx.tools.resolve({ agent, modelSelection, signal })`, pass the returned `toolSet` to `assemble()` and the `ctx.tools.execute()` batch, and dispose it after the batch settles. A custom executor that does not use the shared pipeline looks up each call with `ctx.tools.executable(name, agent, toolSet)`. +4. Update expectations that listed `exit_plan_mode` before a Session entered plan mode. Verify that a disabled tool is absent from the request, that calling its name returns `UNKNOWN_TOOL` without reaching the implementation, and that a failing condition ends the turn with `TOOL_ACTIVATION_FAILED`. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.zh.md new file mode 100644 index 0000000000..ee1644a36e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/conditional-tools/guide.zh.md @@ -0,0 +1,25 @@ +--- +kind: upgrade-guide +description: "工具声明只来自步骤准入的注册:`system-prompt/assemble` 不再携带工具,条件工具需要已解析的步骤集合,`exit_plan_mode` 只在会话首次进入计划模式后声明。" +--- + +# 通过步骤准入声明条件工具 + +[English](guide.md) | 中文 + +## 变更 + +`ctx.tools.register(definition, { when })` 可以附加同步或异步条件。AgentLoop 在提示词组装与 `agent/pre-step` 之前,以 `{ agent, modelSelection, signal }` 为每个拟议步骤计算一次条件,并在该步骤及其重试中用得到的 `ResolvedToolSet` 决定工具声明、原生分发、PTC 绑定和嵌套调用。条件返回 false 时,工具从以上所有位置移除;条件抛错时,轮次以 `TOOL_ACTIVATION_FAILED` 结束。 + +`system-prompt/assemble` waterfall 现在接收不含工具数组的段落、上下文与变量(`PromptPresentation`),返回的 `tools` 字段会被忽略。在这个 waterfall 中删除或添加工具声明的插件会受到影响:它们做的删除不再进入请求;读取 `assembly.tools` 的监听器会拿到 `undefined`,并在运行时出错。 + +模型的工具调用只通过所属步骤的准入集合执行。没有已解析集合的条件工具调用、集合释放之后的调用,以及注册在步骤内被替换或移除时尚未开始的调用,都会返回 `UNKNOWN_TOOL`,不会执行。替换规则适用于所有工具,因此在步骤中途重新注册工具的热重载、MCP 重连或 skill 重载会让该步骤里这个工具尚未开始的调用失败。不传集合的调用方(例如实验性 `claude-code-mods` 的 `$.tool.call`)无法调用条件工具。 + +`exit_plan_mode` 从已选计划状态首次为激活的步骤起声明,并在会话剩余部分保持声明,因此从未进入计划模式的会话,其请求不再列出它。现有 Session 无需格式迁移。 + +## 迁移 + +1. 把 `system-prompt/assemble` 监听器中的工具过滤和对 `assembly.tools` 的读取改为注册时的 `when` 条件;从条件的 `modelSelection` 读取该步骤的提供方、模型与推理强度。waterfall 中只保留段落、上下文与变量的修改。 +2. 让工具指引跟随同一准入:段落回调把 `context.toolSet` 传给 `ctx.tools.get()` 或 `ctx.tools.schemas()`。 +3. 程序化调用条件工具时,调用 `ctx.tools.resolve({ agent, modelSelection, signal })`,把返回的 `toolSet` 传给 `assemble()` 与 `ctx.tools.execute()` 批次,并在批次结束后释放。不使用共享 pipeline 的自定义执行器用 `ctx.tools.executable(name, agent, toolSet)` 查找每次调用。 +4. 更新在会话进入计划模式之前仍列出 `exit_plan_mode` 的期望。验证被禁用的工具不出现在请求中,用其名称调用会返回 `UNKNOWN_TOOL` 且不进入实现,条件失败时轮次以 `TOOL_ACTIVATION_FAILED` 结束。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 6f7e665a4f..93bc2cc11f 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -18,8 +18,8 @@ en: ec10ba53a00eddc6 zh: 857373a4e0fefee4 /configure-models/select-a-model: - en: 6a94264c99535caf - zh: f97bb0dc6a40c0d3 + en: 281b2f40645aa5eb + zh: 861bfe9584d9d30f /configure-models/advanced-configuration: en: b882ec69a6899d8a zh: 66a1713aca0e6f1c diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index c1118e469f..144b33d835 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -40,6 +40,8 @@ Discovery reads the listing formats common gateways publish, but not every endpo Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. +To change a provider’s model order, edit it in **Settings → Models**, open **Customized settings**, and drag a model’s three-line handle. You can also focus the handle and press the up or down arrow key. **Apply** keeps the order; **Cancel** discards it. The model picker follows the saved order within that provider. + If a saved default names a provider that was deleted, the composer displays **Select model** and blocks input until another model is selected. ## Advanced configuration diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index ebf6147681..e20d1eb4aa 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -40,6 +40,8 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和 已配置的提供商会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 +在**设置 → 模型**中编辑提供商,打开**自定义设置**,再拖动模型的三横线手柄,即可调整该提供商内的模型顺序。也可以聚焦手柄后按上下方向键。保存会保留顺序;取消会放弃修改。模型选择器会按该提供商保存的顺序列出模型。 + 如果已保存默认值指向已删除的提供商,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 ## 进阶配置 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 0bc8aff3f8..6df1300fd1 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,14 +15,14 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 8fcfa14545bd9d1c - zh: c10c2bba792699ef + en: aabd5be911e477f5 + zh: 2f7fdb44bc8777bc /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b /session-controller/session-media-references: - en: 49a3d0e8e92017d3 - zh: 91302aa3dd6e9378 + en: 25e40cb508265c01 + zh: c8ce39a94997af00 /session-controller/configuration: en: c92590fbaf75e45e zh: fcac67017bb0e3ab diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index b935dfe606..abbb52cd63 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. @@ -86,7 +86,7 @@ References keep local Session data, scoped Contexts, and history streams alive, ## Session media references -`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 50 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 57262c5267..58bdbbf48b 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 @@ -86,7 +86,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 ## 会话媒体引用 -当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 50 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 59e32cd431..c91f6e5b02 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -7,9 +7,9 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { - AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef, + AttachmentAdmissionPart, ImageAttachmentRef, } from '@deepseek-ai/dsh-attachment' -import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' +import type { FileUploadReceiptId, StagedAttachment } from '@deepseek-ai/dsh-client-file-upload/types' import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage, @@ -313,7 +313,12 @@ export class SessionCommandController { async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } - const hasImage = request.content.some(part => part.type === 'image') + const admission = resolvePromptFileReceipts( + request.content, + receiptId => this.ctx.fileUploads.resolve(agent, receiptId), + ) + // Encoded images and image upload receipts both make an image prompt. + const hasImage = admission.content.some(part => part.type === 'image') const admit = async (): Promise => { try { if (hasImage) { @@ -329,10 +334,6 @@ export class SessionCommandController { } } } - const admission = resolvePromptFileReceipts( - request.content, - receiptId => this.ctx.fileUploads.resolve(agent, receiptId), - ) const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) if (this.ctx.agents.get(agent.id) !== agent) { @@ -556,14 +557,15 @@ export class SessionCommandController { } } +/** Replace each file part with the file or image its staged receipt names. */ function resolvePromptFileReceipts( content: SessionPromptRequest['content'], - stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, + staged: (receiptId: FileUploadReceiptId) => StagedAttachment | undefined, ): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } { const receiptIds = new Set() const resolved = content.map((part): AttachmentAdmissionPart => { if (part.type !== 'file') return part - const attachment = stagedFile(part.receiptId) + const attachment = staged(part.receiptId) if (attachment === undefined) { throw new RemoteError( 'session/attachment-invalid', @@ -572,7 +574,7 @@ function resolvePromptFileReceipts( ) } receiptIds.add(part.receiptId) - return { type: 'file', attachment } + return attachment }) return { content: resolved, receiptIds: [...receiptIds] } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index f25a35d4ae..8c6da77a60 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -71,7 +71,8 @@ export type SessionProjectionValues = Partial /** * Browser-submitted prompt content; the Host promotes image bytes to durable * references. File parts carry the opaque receipt returned by a preceding - * `uploadFile` call on the same Session. + * upload on the same Session; the receipt of an image upload enters the + * message as that image. */ export type PromptContentPart = | { readonly type: 'text'; readonly text: string } diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 305bace577..0f6aff10f2 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -6,7 +6,8 @@ import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { - FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, + SaveImageAttachment, } from '@deepseek-ai/dsh-attachment' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm' @@ -21,8 +22,28 @@ import { SessionCommandController } from '../src/commands.ts' import type { SessionRequestId } from '../src/types.ts' const SESSION = SessionId('upload-session') +const IMAGE_LIMITS: ImageAttachmentLimits = { + maxImageBytes: 4, + maxImagesPerMessage: 2, + maxMessageImageBytes: 8, + maxImagePixels: 64, + maxImageDimension: 8, + mediaTypes: ['image/png'], +} +const STAGED_IMAGE: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'12'.repeat(32)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + name: 'shot.png', +} -async function uploadHarness(origin?: 'subagent'): Promise<{ +async function* chunks(...parts: readonly number[][]): AsyncIterable { + for (const part of parts) yield Uint8Array.from(part) +} + +async function uploadHarness(origin?: 'subagent', imageLimits: ImageAttachmentLimits = IMAGE_LIMITS): Promise<{ ctx: Context controller: SessionCommandController uploads: FileUploads @@ -31,6 +52,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ saveFile: ReturnType saveFileStream: ReturnType saveImages: ReturnType + resolveModelInfo: ReturnType disposeAgent: () => Promise uploadRoute: (request: Request) => Promise }> { @@ -79,10 +101,10 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ bytes, } }) - const saveImages = vi.fn((): Promise => + const saveImages = vi.fn((_inputs: readonly SaveImageAttachment[]): Promise => Promise.reject(new Error('fixture did not expect image persistence'))) ctx.provide('attachments', Object.setPrototypeOf( - { saveFile, saveFileStream, saveImages }, + { saveFile, saveFileStream, saveImages, imageLimits }, AttachmentStore.prototype, ) as never) let uploadRoute: ((request: Request) => Promise) | undefined @@ -94,10 +116,16 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ }, }, } as never) + const resolveModelInfo = vi.fn((): Promise<{ + readonly provider: string + readonly id: string + readonly name: string + readonly inputModalities?: readonly string[] + }> => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' })) ctx.provide('llm', { listModels: async () => [{ id: 'fixture-model', name: 'Model' }], listProviders: () => [{ id: 'fixture', name: 'Fixture' }], - resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), + resolveModelInfo, } as never) const agents = new ApiSessionAgentController(ctx) vi.spyOn(agents, 'resolveAgent').mockResolvedValue({ agent }) @@ -112,6 +140,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ saveFile, saveFileStream, saveImages, + resolveModelInfo, disposeAgent, uploadRoute, } @@ -142,7 +171,7 @@ describe('Session file uploads', () => { expect(saveFile).toHaveBeenCalledTimes(1) expect(receipt.file.name).toBe('notes.pdf') expect(receipt.file.bytes).toBe(3) - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined() const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) ctx.commands.register({ @@ -203,6 +232,116 @@ describe('Session file uploads', () => { ]) }) + it('stages an uploaded image and admits its receipt as that image, which commands do not accept', async () => { + const { ctx, controller, uploads, agent, followup, saveImages } = await uploadHarness() + saveImages.mockResolvedValueOnce([STAGED_IMAGE]) + const receipt = await uploads.uploadImageStream({ + sessionId: SESSION, data: chunks([1, 2], [3, 4]), mediaType: 'image/png', name: 'shot.png', + }) + expect(saveImages).toHaveBeenCalledWith([{ data: Uint8Array.of(1, 2, 3, 4), mediaType: 'image/png', name: 'shot.png' }]) + expect(receipt.image).toEqual(STAGED_IMAGE) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'image', attachment: STAGED_IMAGE, encodedBytes: 4 }) + const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'files', description: 'Use staged files', input: { hint: '', attachments: true }, + handler: commandHandler, + }) + await ctx.commands.execute( + agent, + '/files inspect', + [{ type: 'file', receiptId: receipt.receiptId }], + new AbortController().signal, + ) + expect(commandHandler).not.toHaveBeenCalled() + await controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'text', text: 'look' }, + ]), USER_SOURCE) + expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { type: 'image', attachment: STAGED_IMAGE }, + { type: 'text', text: 'look' }, + ]) + expect(saveImages).toHaveBeenCalledTimes(1) + }) + + it('refuses an uploaded image for a model without image input', async () => { + const { controller, uploads, followup, saveImages, resolveModelInfo } = await uploadHarness() + saveImages.mockResolvedValueOnce([STAGED_IMAGE]) + const receipt = await uploads.uploadImageStream({ sessionId: SESSION, data: chunks([1]), mediaType: 'image/png' }) + resolveModelInfo.mockResolvedValue({ provider: 'fixture', id: 'fixture-model', name: 'Fixture', inputModalities: ['text'] }) + await expect(controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }) + expect(followup).not.toHaveBeenCalled() + }) + + it('refuses image receipts whose uploads together exceed the message image-byte limit', async () => { + const { controller, uploads, followup, saveImages } = await uploadHarness(undefined, { + ...IMAGE_LIMITS, maxMessageImageBytes: 5, + }) + saveImages.mockResolvedValue([STAGED_IMAGE]) + const first = await uploads.uploadImageStream({ sessionId: SESSION, data: chunks([1, 2, 3]), mediaType: 'image/png' }) + const second = await uploads.uploadImageStream({ sessionId: SESSION, data: chunks([4, 5, 6]), mediaType: 'image/png' }) + await expect(controller.prompt(promptRequest([ + { type: 'file', receiptId: first.receiptId }, + { type: 'file', receiptId: second.receiptId }, + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'IMAGES_TOO_LARGE' } }) + expect(followup).not.toHaveBeenCalled() + }) + + it('issues no receipt for an image upload cancelled after its last chunk or during storage', async () => { + const { uploads, saveImages } = await uploadHarness() + const afterBody = new AbortController() + await expect(uploads.uploadImageStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2, 3) + afterBody.abort() + })(), + mediaType: 'image/png', + signal: afterBody.signal, + })).rejects.toThrow('failed to store file upload: AbortError') + expect(saveImages).not.toHaveBeenCalled() + const duringStorage = new AbortController() + const stored = Promise.withResolvers() + saveImages.mockReturnValueOnce(stored.promise) + const uploading = uploads.uploadImageStream({ + sessionId: SESSION, data: chunks([1, 2, 3]), mediaType: 'image/png', signal: duringStorage.signal, + }) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + duringStorage.abort() + stored.resolve([STAGED_IMAGE]) + // The image whose storage began stays stored; only its receipt is withheld. + await expect(uploading).rejects.toThrow('failed to store file upload: AbortError') + }) + + it('refuses an image past the per-image byte limit without reading further, and an unaccepted type', async () => { + const { uploads, saveImages } = await uploadHarness() + const overLimit = (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2, 3) + yield Uint8Array.of(4, 5) + throw new Error('read past the limit') + })() + await expect(uploads.uploadImageStream({ sessionId: SESSION, data: overLimit, mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'IMAGE_TOO_LARGE' } }) + await expect(uploads.uploadImageStream({ sessionId: SESSION, data: chunks([1]), mediaType: 'image/bmp' })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'UNSUPPORTED_IMAGE_TYPE' } }) + expect(saveImages).not.toHaveBeenCalled() + }) + + it('stops reading an image at the message byte limit when it is below the per-image limit', async () => { + const { uploads, saveImages } = await uploadHarness(undefined, { + ...IMAGE_LIMITS, maxImageBytes: 8, maxMessageImageBytes: 3, + }) + const overLimit = (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + yield Uint8Array.of(3, 4) + throw new Error('read past the limit') + })() + await expect(uploads.uploadImageStream({ sessionId: SESSION, data: overLimit, mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'IMAGE_TOO_LARGE' } }) + expect(saveImages).not.toHaveBeenCalled() + }) + it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => { const { uploads, saveFileStream } = await uploadHarness() const stream = (async function* (): AsyncIterable {})() @@ -320,6 +459,9 @@ describe('Session file uploads', () => { details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, }) expect(child.saveFile).not.toHaveBeenCalled() + await expect(child.uploads.uploadImageStream({ sessionId: SESSION, data: chunks([1]), mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'subagent/attachment-invalid', details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' } }) + expect(child.saveImages).not.toHaveBeenCalled() }) it('isolates receipts by Session object even when Session ids match', async () => { @@ -335,7 +477,7 @@ describe('Session file uploads', () => { expect(() => owner.uploads.bindPrompt(other.agent, [receipt.receiptId], 'other-prompt')) .toThrow(expect.objectContaining({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })) owner.uploads.retirePrompt(other.agent, 'owner-prompt') - expect(owner.uploads.resolve(owner.agent, receipt.receiptId)).toEqual(receipt.file) + expect(owner.uploads.resolve(owner.agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) owner.uploads.retirePrompt(owner.agent, 'owner-prompt') expect(owner.uploads.resolve(owner.agent, receipt.receiptId)).toBeUndefined() }) @@ -345,9 +487,9 @@ describe('Session file uploads', () => { uploads.retirePrompt(agent, 'not-staged') const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) uploads.retirePrompt(agent, 'other-request') - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -364,7 +506,7 @@ describe('Session file uploads', () => { content: [{ type: 'text', text: 'extension event' }], source: { kind: 'user', rpcId: 1 } as never, }), { surfaceOp: 'append' }) - expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual({ type: 'file', attachment: observed.file }) first.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'observed' }], source: { kind: 'user', rpcId: 'req-1' as SessionRequestId }, @@ -466,7 +608,7 @@ describe('Session file uploads', () => { { type: 'file', receiptId: receipt.receiptId }, ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') - expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual({ type: 'file', attachment: receipt.file }) }) it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 520e5af483..be08b2fdbd 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -18,8 +18,8 @@ en: 912a19be4b59ff8a zh: 9c14694cf2ebbaf2 /deepseek-ai-dsh-attachment-local/use-this-package/minimal-configuration: - en: b34e09c3236f81f1 - zh: c541b5dc22c8f307 + en: 9597c3d9779cb93d + zh: 900baa94138cc7de /deepseek-ai-dsh-attachment-local/use-this-package/where-your-images-are-stored-and-how-long-they-last: en: 4ee90b8f74baa38c zh: 9c577709ec55eb82 @@ -36,8 +36,8 @@ en: dcd9857653645420 zh: a27259a09f17465d /deepseek-ai-dsh-attachment-local/understand-the-implementation/write-and-read-paths: - en: 609ccc784a87b770 - zh: bc702582cf2b3316 + en: 62ea104a00a7fdb8 + zh: b08575274ca244b4 /deepseek-ai-dsh-attachment-local/understand-the-implementation/source-map: en: 05af85aa85934d01 zh: 1c89e80d496dc0a3 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 4303813905..4844be3670 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -38,11 +38,11 @@ Mount the plugin with no required configuration. The defaults below define what | Field | Default | Meaning | |---|---|---| | `dshHome` | resolved | Explicit harness home; omitted follows `$DSH_HOME`, then `~/.dsh` | -| `maxImageBytes` | `20 MiB` | Maximum encoded source bytes accepted for one image | +| `maxImageBytes` | `50 MiB` | Maximum encoded source bytes accepted for one image | | `maxImagesPerMessage` | `20` | Maximum image count accepted in one submitted message | | `maxMessageImageBytes` | `200 MiB` | Maximum aggregate encoded source bytes in one submitted message | | `maxImagePixels` | `64,000,000` | Maximum source width multiplied by height | -| `maxImageDimension` | `8192` | Maximum source width or height | +| `maxImageDimension` | `65,535` | Maximum source width or height; the default leaves admission to `maxImagePixels`, and normalization downscales long sides | | `normalizedImageMaxPixels` | `2048 × 2048` | Total-pixel budget of the stored normalized image | | `normalizedImageMaxDimension` | `8192` | Maximum long edge after applying the total-pixel budget | | `normalizedImageMaxBytes` | `4 MiB` | Encoded-byte target; the smallest quality-ladder output is kept when none fits | @@ -83,7 +83,7 @@ This section explains the durability and verification design behind the storage, Objects land at `/attachments/v1/objects//`; equal bytes deduplicate to one object and one `sha256:` id. Before the first write, the process syncs every ancestor directory of the home down to the filesystem root once, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then stage bytes in `v1/tmp`, sync the temporary file, publish with an atomic exclusive hard link, and sync the publication directories — on Windows, filesystem metadata journaling owns entry durability. Once the save resolves, the reported reference is durable. -Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. +Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 50 MiB, 64 million pixels, and 65,535 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. Request versions live below `/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to the route-chosen target, resizing by the long edge only so the encoder derives the short edge as the route predicts, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, target dimensions, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 4c52dab3ac..a079785984 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -38,11 +38,11 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `dshHome` | 自动解析 | 显式 harness home;省略时依次跟随 `$DSH_HOME` 与 `~/.dsh` | -| `maxImageBytes` | `20 MiB` | 单张图片接受的最大编码源字节数 | +| `maxImageBytes` | `50 MiB` | 单张图片接受的最大编码源字节数 | | `maxImagesPerMessage` | `20` | 单条提交消息接受的最大图片数量 | | `maxMessageImageBytes` | `200 MiB` | 单条提交消息接受的最大编码源图字节总数 | | `maxImagePixels` | `64,000,000` | 源图宽度与高度乘积的最大值 | -| `maxImageDimension` | `8192` | 源图接受的最大宽度或高度 | +| `maxImageDimension` | `65,535` | 源图接受的最大宽度或高度;默认值让 `maxImagePixels` 单独把关,长边由规范化缩小 | | `normalizedImageMaxPixels` | `2048 × 2048` | 已存规范化图片的总像素预算 | | `normalizedImageMaxDimension` | `8192` | 应用总像素预算后的最大长边 | | `normalizedImageMaxBytes` | `4 MiB` | 编码字节目标;没有候选满足时保留质量阶梯中的最小输出 | @@ -83,7 +83,7 @@ kind: "package-reference" 对象存放在 `/attachments/v1/objects//`;相同字节会去重为同一个对象和同一个 `sha256:` 标识符。首次写入前,进程会把 home 的每个祖先目录逐级同步到文件系统根目录,因此绝不会把另一个进程已创建但尚未同步的目录误认为安全边界。随后,写入过程把字节暂存到 `v1/tmp`、同步临时文件、以原子且排他的硬链接发布,并同步发布目录——在 Windows 上,文件系统元数据日志负责目录项持久性。保存操作完成后,返回的引用已具备持久性。 -准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 +准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 50 MiB、6400 万像素与单边 65,535 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 请求版本位于由 `dshCachePath` 解析的 `/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 34959d088a..2965fef0cc 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -31,15 +31,19 @@ export type { PreparedImageFile } from './store.ts' export { readRequestImageFile, requestImageVariantId } from './request-image.ts' /** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ -export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024 +export const DEFAULT_MAX_IMAGE_BYTES = 50 * 1024 * 1024 /** Default maximum images in one prompt. */ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 200 * 1024 * 1024 /** Default maximum intrinsic pixels for one submitted image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 -/** Default per-side pixel cap for one submitted image. */ -export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 +/** + * Default per-side pixel cap for one submitted image: the largest side a JPEG + * can carry. Normalization downscales a long side, so by default the + * decoded-pixel limit alone bounds admission. + */ +export const DEFAULT_MAX_IMAGE_DIMENSION = 65_535 /** * Default total-pixel budget of the stored normalized image. A larger source * is admitted and downscaled proportionally, so admission bounds what rides @@ -61,7 +65,7 @@ export const MAX_IMAGE_COMPRESSION_CONCURRENCY = 8 export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 50 MiB. */ maxImageBytes?: number /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number @@ -69,7 +73,7 @@ export interface Config { maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 65,535px. */ maxImageDimension?: number /** Total-pixel budget of the stored provider-independent normalized image. */ normalizedImageMaxPixels?: number diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 00a427e16e..6482190ebe 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -21,11 +21,11 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) - expect(DEFAULT_MAX_IMAGE_BYTES).toBe(20 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(50 * 1024 * 1024) expect(DEFAULT_MAX_IMAGES_PER_MESSAGE).toBe(20) expect(DEFAULT_MAX_MESSAGE_IMAGE_BYTES).toBe(200 * 1024 * 1024) expect(DEFAULT_MAX_IMAGE_PIXELS).toBe(64_000_000) - expect(DEFAULT_MAX_IMAGE_DIMENSION).toBe(8192) + expect(DEFAULT_MAX_IMAGE_DIMENSION).toBe(65_535) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 4f941409fe..b2b7ba57ad 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -39,8 +39,8 @@ en: af7deef72fd2fa93 zh: f217d52ce3971135 /deepseek-ai-dsh-attachment/understand-the-implementation/service-operations: - en: 4a35cdba790c3b8b - zh: 08d4a18bbad4b182 + en: b94d13fe133d8ce6 + zh: dd4064d20d510afc /deepseek-ai-dsh-attachment/understand-the-implementation/source-map: en: 4fb4c96184c38169 zh: 015b06e4bfff4fcd diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 43eb4d8ae8..2acdc13c42 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -72,7 +72,7 @@ This section explains the design decisions behind the seam and the service opera ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file and image references to `ctx.attachments.admitPromptContent()`; the method persists the encoded images, passes references unchanged, and counts every image toward the per-message image count and aggregate byte limits, a stored image by the bytes of the upload that stored it. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 4fa95d20f3..06cbe5631a 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -72,7 +72,7 @@ kind: "package-reference" ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件与图片引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化编码图片,让引用原样通过,并把每张图片计入单条消息的图片数量与总字节上限,已存储的图片按存储它的那次上传的字节数计入。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index d5854336db..ba7f567884 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,5 +1,6 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ +import { Buffer } from 'node:buffer' import { Context, Service } from '@deepseek-ai/cordis' import { admitEncodedFile as admitFileInput, admitEncodedImages } from './admission.ts' import { AttachmentError, isAttachmentError as matchesAttachmentError } from './error.ts' @@ -11,6 +12,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, ImageRequestTarget, + PromptContentPart, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -49,6 +51,28 @@ declare module '@deepseek-ai/cordis' { } } +/** Whether one admission part still carries encoded image bytes. */ +function isEncodedImage(part: AttachmentAdmissionPart): part is Extract { + return part.type === 'image' && !('attachment' in part) +} + +/** Whether one admission part references an image an earlier upload stored. */ +function isStoredImage( + part: AttachmentAdmissionPart, +): part is Extract { + return part.type === 'image' && 'attachment' in part +} + +/** Refuse one message whose images exceed the configured count or aggregate byte limit. */ +function assertMessageImages(limits: ImageAttachmentLimits, count: number, bytes: number): void { + if (count > limits.maxImagesPerMessage) { + throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + } + if (bytes > limits.maxMessageImageBytes) { + throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } +} + /** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */ export abstract class AttachmentStore extends Service { constructor(ctx: Context) { @@ -75,16 +99,10 @@ export abstract class AttachmentStore extends Service { * @returns durable references in the exact input order. */ protected validateImageBatch(inputs: readonly SaveImageAttachment[]): void { - const { maxImagesPerMessage, maxMessageImageBytes, mediaTypes } = this.imageLimits - if (inputs.length > maxImagesPerMessage) { - throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') - } - const totalBytes = inputs.reduce((sum, input) => sum + input.data.byteLength, 0) - if (totalBytes > maxMessageImageBytes) { - throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') - } + const limits = this.imageLimits + assertMessageImages(limits, inputs.length, inputs.reduce((sum, input) => sum + input.data.byteLength, 0)) for (const input of inputs) { - if (!mediaTypes.includes(input.mediaType)) { + if (!limits.mediaTypes.includes(input.mediaType)) { throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE') } } @@ -105,25 +123,35 @@ export abstract class AttachmentStore extends Service { } /** - * Admit one Host prompt and replace each uploaded image with its durable reference. - * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. - * @param content - prompt parts in message order after file receipt resolution. + * Admit one Host prompt and replace each encoded image with its durable reference. + * Text, durable file references, and already stored images pass through unchanged. + * Every image part counts toward `maxImagesPerMessage` and `maxMessageImageBytes`; + * a stored image counts the bytes of the upload that stored it. Refused content + * starts no writes. A prompt without encoded images performs no storage operation. + * @param content - prompt parts in message order after upload receipt resolution. * @returns admitted prompt parts in the same order as `content`. * @throws AttachmentError when the image batch is refused. */ async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise { - if (content.every(part => part.type !== 'image')) { - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - : { type: 'file', attachment: part.attachment }) + const encoded = content.filter(isEncodedImage) + const stored = content.filter(isStoredImage) + // Each upload passed the limits alone; the encoded batch below checks only itself. + if (stored.length > 0) { + assertMessageImages( + this.imageLimits, + encoded.length + stored.length, + encoded.reduce((sum, part) => sum + Buffer.byteLength(part.data, 'base64'), 0) + + stored.reduce((sum, part) => sum + part.encodedBytes, 0), + ) } - const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) + const refs = encoded.length === 0 ? [] : await admitEncodedImages(this, encoded) let next = 0 return content.map((part) => { if (part.type === 'text') return { type: 'text', text: part.text } if (part.type === 'file') return { type: 'file', attachment: part.attachment } + if ('attachment' in part) return { type: 'image', attachment: part.attachment } return { type: 'image', attachment: refs[next++] as ImageAttachmentRef } }) } diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 455d6c9de0..bc84ed6654 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -106,10 +106,16 @@ export type PromptContentPart = readonly name?: string } -/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +/** + * Host prompt content whose upload receipts are resolved. An image part + * carries encoded bytes awaiting admission, or references an image this + * service already stored for an upload the same Session staged, with + * `encodedBytes` holding the byte length of that upload. + */ export type AttachmentAdmissionPart = | PromptContentPart | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef; readonly encodedBytes: number } /** Host-admitted prompt content with every attachment represented by its durable reference. */ export type AdmittedPromptContentPart = diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index 71c56c0fed..bc50c6068a 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -10,6 +10,13 @@ const FILE_REF: FileAttachmentRef = { name: 'notes.md', bytes: 12, } +const STORED_REF: ImageAttachmentRef = { + attachmentId: 'stored-1' as ImageAttachmentRef['attachmentId'], + mediaType: 'image/png', + bytes: 8, + width: 2, + height: 2, +} /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { @@ -138,4 +145,50 @@ describe('AttachmentStore.admitPromptContent', () => { { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, ]) }) + + it('passes stored images through in part order and stores only the encoded ones', async () => { + const { store, mocks } = storeOf() + // The stored upload's 4 bytes and the encoded byte fill the budget exactly; the normalized 8 bytes do not count. + Object.defineProperty(store, 'imageLimits', { value: { maxImagesPerMessage: 2, maxMessageImageBytes: 5 } }) + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF, encodedBytes: 4 }, + { type: 'text', text: 'between' }, + { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + ])).resolves.toEqual([ + { type: 'image', attachment: STORED_REF }, + { type: 'text', text: 'between' }, + { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + ]) + expect(mocks.saveImages).toHaveBeenCalledTimes(1) + expect((mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[]).length).toBe(1) + }) + + it('counts stored images toward the image-count limit before storing anything', async () => { + const { store, mocks } = storeOf() + Object.defineProperty(store, 'imageLimits', { value: { maxImagesPerMessage: 1, maxMessageImageBytes: 100 } }) + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF, encodedBytes: 1 }, + { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + ])).rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' }) + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF, encodedBytes: 1 }, + { type: 'image', attachment: { ...STORED_REF, attachmentId: 'stored-2' as ImageAttachmentRef['attachmentId'] }, encodedBytes: 1 }, + ])).rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' }) + expect(mocks.saveImages).not.toHaveBeenCalled() + }) + + it('counts stored upload bytes with encoded bytes toward the message byte limit before storing anything', async () => { + const { store, mocks } = storeOf() + Object.defineProperty(store, 'imageLimits', { value: { maxImagesPerMessage: 4, maxMessageImageBytes: 5 } }) + // Two 3-byte uploads each fit alone; together they exceed one message. + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF, encodedBytes: 3 }, + { type: 'image', attachment: { ...STORED_REF, attachmentId: 'stored-2' as ImageAttachmentRef['attachmentId'] }, encodedBytes: 3 }, + ])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' }) + await expect(store.admitPromptContent([ + { type: 'image', attachment: STORED_REF, encodedBytes: 3 }, + { type: 'image', mediaType: 'image/png', data: PNG }, + ])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' }) + expect(mocks.saveImages).not.toHaveBeenCalled() + }) }) diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 4d8dae27e4..f96ddd36c6 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -246,13 +246,13 @@ describe('best-effort config failure recovery', () => { await ctx.loader.await() const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'webserver')! expect(entry.options.config).toEqual({ value: 'invalid' }) - expect(entry.fiber?.config).toEqual({ value: 1 }) + expect(entry.fiber?.effectiveConfig).toEqual({ value: 1 }) expect(ctx.get('validatedValue')).toBe(1) writeFileSync(join(dir, 'cordis.yml'), config(2)) await include.refresh() await ctx.loader.await() - expect(entry.fiber?.config).toEqual({ value: 2 }) + expect(entry.fiber?.effectiveConfig).toEqual({ value: 2 }) expect(ctx.get('validatedValue')).toBe(2) } finally { await ctx.fiber.dispose() diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 09e5653c56..63e0af5ea6 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -451,7 +451,7 @@ describe('boot with user patches', () => { try { const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'user-value' }) + expect(noop?.fiber?.effectiveConfig).toEqual({ value: 'user-value' }) expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true) } finally { await ctx.fiber.dispose() diff --git a/packages/boot/config-editor/src/index.ts b/packages/boot/config-editor/src/index.ts index ffec879f40..8fe686e478 100644 --- a/packages/boot/config-editor/src/index.ts +++ b/packages/boot/config-editor/src/index.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { isDeepStrictEqual } from 'node:util' -import { Context, FiberState, Service, resolveConfig } from '@deepseek-ai/cordis' +import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import yaml from 'js-yaml' import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -100,8 +100,7 @@ export class ConfigEditor extends Service { const next = change(current, inherited) const fiber = entry.fiber if (fiber.state !== FiberState.ACTIVE) throw new Error('Configuration plugin is no longer active') - const resolved: unknown = fiber.ctx.waterfall(fiber, 'internal/config', next, () => next) - resolveConfig(fiber.runtime as NonNullable, resolved) + fiber.resolveConfig(next) let before: string try { before = await readFile(path, 'utf8') } catch (error) { diff --git a/packages/boot/hmr/src/index.ts b/packages/boot/hmr/src/index.ts index e5c113db7b..709524d741 100644 --- a/packages/boot/hmr/src/index.ts +++ b/packages/boot/hmr/src/index.ts @@ -646,7 +646,7 @@ class Hmr extends Service { ...info, previous, fibers: [...info.runtime?.fibers ?? []].map((fiber) => { const entry = fiber.entry?.fiber?.uid === fiber.uid ? fiber.entry : undefined - const config: unknown = entry === undefined ? fiber._config : entry.options.config + const config: unknown = entry === undefined ? fiber.rawConfig : entry.options.config return { fiber, entry, config, moduleNamespace: entry?.moduleNamespace } }), })) diff --git a/packages/boot/hmr/tests/modules.spec.ts b/packages/boot/hmr/tests/modules.spec.ts index 5cf710ea07..33895a7106 100644 --- a/packages/boot/hmr/tests/modules.spec.ts +++ b/packages/boot/hmr/tests/modules.spec.ts @@ -298,7 +298,7 @@ it('leaves disposed child instances to their replacing parent', async () => { expect(seen).toContain('new:entry') expect(seen).not.toContain('new:child') expect(seen).toContain('new:independent') - expect(entry.fiber?._config).toEqual({ value: 'entry' }) + expect(entry.fiber?.rawConfig).toEqual({ value: 'entry' }) }) it.each(['v1', 'v2'] as const)('invalidates a disabled %s module without activating it', async (version) => { diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml index b8f993bd2d..ac25a61160 100644 --- a/packages/client/file-upload/README.i18n.yaml +++ b/packages/client/file-upload/README.i18n.yaml @@ -9,17 +9,17 @@ en: a6c9f156174554e1 zh: cd1a126905ad859b /deepseek-ai-dsh-client-file-upload/summary: - en: 430b79a11643da11 - zh: 5fbab8d425b7f15a + en: e53f2cfb8285a578 + zh: 1930ea2638e59a34 /deepseek-ai-dsh-client-file-upload/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-file-upload/use-this-package: - en: 9d5bbf52599b0d00 - zh: 7907542ce1e34549 + en: 7c19ce780c0d1357 + zh: 2d295b8b872b5f9f /deepseek-ai-dsh-client-file-upload/understand-the-implementation: - en: 77937960a540cf51 - zh: 52c478bd11200975 + en: 8a03794416a84817 + zh: 7b2510135b751f97 /deepseek-ai-dsh-client-file-upload/further-exploration: en: ac94eb9da9ecc296 zh: 25cc3362e2bea330 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md index b447226218..a80e602c13 100644 --- a/packages/client/file-upload/README.md +++ b/packages/client/file-upload/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. Exact bytes use the generated Remote. +This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. An image upload stages a normalized prompt image that a later prompt cites by receipt. ## Table of Contents @@ -25,7 +25,7 @@ This package lets browser features store a `Blob`, exact bytes, or a `ReadableSt ## Use this package -Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request. +Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`, or `ctx.fileUpload.uploadImage(sessionId, blob, mediaType, name, signal, onProgress)` for a prompt image. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request. ```yaml - id: file-upload @@ -44,7 +44,7 @@ The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest ins The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote for exact bytes. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each Blob or stream request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. -The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. Host callers supply an Agent with its runtime-owned context; receipt tables use that Agent's exact Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. +The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. A raw upload with a `mediaType` query parameter reads at most the smaller of the deployment's per-image and per-message image byte limits and stores the image through `ctx.attachments.saveImages`; its receipt records the upload's byte length and resolves to that image during prompt admission, where those bytes count toward the per-message image byte limit, and commands accept only file receipts. A cancelled image upload receives no receipt: cancellation before storage begins stores nothing, and an image whose storage already began stays stored. Host callers supply an Agent with its runtime-owned context; receipt tables use that Agent's exact Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. | File | Role | |---|---| diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md index 86bc9ef6f5..8e9ab5a976 100644 --- a/packages/client/file-upload/README.zh.md +++ b/packages/client/file-upload/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。精确字节使用生成的 Remote。 +本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。图片上传会暂存一张规整后的 prompt 图片,供后续 prompt 凭证引用。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。 +在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`;prompt 图片则调用 `ctx.fileUpload.uploadImage(sessionId, blob, mediaType, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。 ```yaml - id: file-upload @@ -44,7 +44,7 @@ kind: "package-reference" Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为精确字节调用生成的 Remote。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个 Blob 或 stream 请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 -Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。Host 调用方传入具有运行时所属上下文的 Agent;凭证表以该 Agent 的准确 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。带 `mediaType` 查询参数的原始上传最多读取部署配置的单张图片与单条消息图片字节上限中较小的一个,并通过 `ctx.attachments.saveImages` 存储图片;该凭证记录上传的字节数,在 prompt 准入时解析为这张图片,这些字节计入单条消息的图片总字节上限;命令只接受文件凭证。取消的图片上传不会取得凭证:存储开始前取消不存储任何内容,已开始存储的图片会保留。Host 调用方传入具有运行时所属上下文的 Agent;凭证表以该 Agent 的准确 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 | 文件 | 职责 | |---|---| diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts index 8bf1145193..758aaa6d81 100644 --- a/packages/client/file-upload/src/client/contract.ts +++ b/packages/client/file-upload/src/client/contract.ts @@ -1,6 +1,7 @@ +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import type { FileUploadValue } from '../types.ts' +import type { FileUploadValue, ImageUploadValue } from '../types.ts' /** Browser request body accepted by the background file-upload service. */ export type FileUploadBody = Blob | ReadableStream @@ -30,4 +31,24 @@ export interface FileUploadService { signal?: AbortSignal, onProgress?: (progress: FileUploadProgress) => void, ): Promise> + /** + * Store one prompt image for a Session through the background carrier. The + * Host validates, normalizes, and stores it as prompt admission would, and a + * later prompt cites the receipt in a file part. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser image Blob. + * @param mediaType - declared image media type. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer. + * @returns the staged receipt and normalized image reference, or a business error. + */ + uploadImage( + sessionId: SessionId, + data: Blob, + mediaType: ImageMediaType, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: FileUploadProgress) => void, + ): Promise> } diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index 556da15f10..46581c2873 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -1,13 +1,14 @@ /** Background browser upload implementation for Blob and byte-stream bodies. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment/types' import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { FILE_UPLOAD_ROUTE } from '../protocol.ts' import type { - ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, + ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, ImageUploadValue, } from '../types.ts' import type { FileUploadBody, FileUploadService } from './contract.ts' @@ -198,17 +199,7 @@ export class FileUploadRuntime extends Service implements FileUploadService { if (!(data instanceof Uint8Array)) { const query = new URLSearchParams({ sessionId }) if (name !== undefined) query.set('name', name) - const response = await this.post({ - path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, - body: data, - headers: { 'content-type': 'application/octet-stream' }, - ...(signal === undefined ? {} : { signal }), - ...(onProgress === undefined ? {} : { onProgress }), - }) - if (response.status !== 200) { - throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) - } - return parseFileUploadResult(response.body) + return parseUploadResult(await this.postRaw(query, data, signal, onProgress), fileUploadValue) } return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload( sessionId, @@ -219,6 +210,49 @@ export class FileUploadRuntime extends Service implements FileUploadService { signal, ) } + + /** + * Store one prompt image for a Session. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser image Blob. + * @param mediaType - declared image media type. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer. + * @returns the staged receipt and normalized image reference, or a business error. + */ + async uploadImage( + sessionId: SessionId, + data: Blob, + mediaType: ImageMediaType, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> { + const query = new URLSearchParams({ sessionId, mediaType }) + if (name !== undefined) query.set('name', name) + return parseUploadResult(await this.postRaw(query, data, signal, onProgress), imageUploadValue) + } + + /** Post one raw body to the upload route; a non-200 status is a transport failure. */ + private async postRaw( + query: URLSearchParams, + body: FileUploadBody, + signal: AbortSignal | undefined, + onProgress: ((progress: { readonly loaded: number; readonly total?: number }) => void) | undefined, + ): Promise { + const response = await this.post({ + path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, + body, + headers: { 'content-type': 'application/octet-stream' }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }) + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) + } + return response.body + } } function customTransport(customFetch: FileUploadFetch): FileUploadTransport { @@ -297,7 +331,7 @@ function workerTransport(): FileUploadTransport { } } -function parseFileUploadResult(body: string): RemoteResult { +function parseUploadResult(body: string, parseValue: (value: unknown) => T): RemoteResult { const value: unknown = JSON.parse(body) if (!isRecord(value) || typeof value.ok !== 'boolean') { throw new TypeError('file upload transport returned an invalid result') @@ -313,26 +347,60 @@ function parseFileUploadResult(body: string): RemoteResult { error: new RemoteError(error.code as never, error.message, error.details as never), } } - const result = value.value + return { ok: true, value: parseValue(value.value) } +} + +function fileUploadValue(result: unknown): FileUploadValue { const file = isRecord(result) ? result.file : undefined if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) - || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' - || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' || !isNonNegativeInteger(file.bytes)) { throw new TypeError('file upload transport returned an invalid receipt') } return { - ok: true, - value: { - receiptId: result.receiptId as FileUploadValue['receiptId'], - file: { - attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'], - name: file.name, - bytes: file.bytes, - }, + receiptId: result.receiptId as FileUploadValue['receiptId'], + file: { + attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'], + name: file.name, + bytes: file.bytes, }, } } +function imageUploadValue(result: unknown): ImageUploadValue { + const image = isRecord(result) ? result.image : undefined + if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(image) + || typeof image.attachmentId !== 'string' || !isImageMediaType(image.mediaType) + || !isNonNegativeInteger(image.bytes) || !isNonNegativeInteger(image.width) || !isNonNegativeInteger(image.height) + || (image.name !== undefined && typeof image.name !== 'string') + || (image.originalDimensions !== undefined && !isDimensions(image.originalDimensions))) { + throw new TypeError('file upload transport returned an invalid image receipt') + } + return { + receiptId: result.receiptId as ImageUploadValue['receiptId'], + image: { + attachmentId: image.attachmentId as ImageUploadValue['image']['attachmentId'], + mediaType: image.mediaType, + bytes: image.bytes, + width: image.width, + height: image.height, + ...(image.name === undefined ? {} : { name: image.name }), + ...(image.originalDimensions === undefined ? {} : { originalDimensions: image.originalDimensions }), + }, + } +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +function isImageMediaType(value: unknown): value is ImageMediaType { + return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' +} + +function isDimensions(value: unknown): value is { width: number; height: number } { + return isRecord(value) && isNonNegativeInteger(value.width) && isNonNegativeInteger(value.height) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/packages/client/file-upload/src/http-route.ts b/packages/client/file-upload/src/http-route.ts index 0f5539324e..b03c4b9562 100644 --- a/packages/client/file-upload/src/http-route.ts +++ b/packages/client/file-upload/src/http-route.ts @@ -4,17 +4,18 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { FileUploads } from './index.ts' -import type { FileUploadValue } from './types.ts' +import type { FileUploadValue, ImageUploadValue } from './types.ts' type FileUploadHttpResult = - | { readonly ok: true; readonly value: FileUploadValue } + | { readonly ok: true; readonly value: FileUploadValue | ImageUploadValue } | { readonly ok: false readonly error: { readonly code: string; readonly message: string; readonly details: object } } /** - * Handle one authenticated raw-byte upload. + * Handle one authenticated raw-byte upload. A `mediaType` query parameter + * stages the body as a prompt image; without it the body is stored verbatim. * @param service - Host upload service receiving streamed bytes. * @param request - authenticated HTTP request from Connection. * @returns JSON result using HTTP status 200 after request validation. @@ -33,16 +34,20 @@ export async function handleFileUploadHttp(service: FileUploads, request: Reques return new Response('sessionId is required', { status: 400 }) } const name = url.searchParams.get('name') ?? undefined + const imageMediaType = url.searchParams.get('mediaType') ?? undefined + const upload = { + sessionId: brandString(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + } let result: FileUploadHttpResult try { result = { ok: true, - value: await service.uploadStream({ - sessionId: brandString(sessionId), - data: requestBodyChunks(request.body), - signal: request.signal, - ...(name === undefined ? {} : { name }), - }), + value: imageMediaType === undefined + ? await service.uploadStream(upload) + : await service.uploadImageStream({ ...upload, mediaType: imageMediaType }), } } catch (error) { const failure = remoteErrorOf(error) diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts index acf99f22d2..1dfefe642b 100644 --- a/packages/client/file-upload/src/index.ts +++ b/packages/client/file-upload/src/index.ts @@ -3,26 +3,28 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-client-connection' import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, remoteErrorOf, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { handleFileUploadHttp } from './http-route.ts' import { FILE_UPLOAD_PATH } from './protocol.ts' -import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts' +import type { + EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue, ImageUploadValue, StagedAttachment, +} from './types.ts' export type * from './types.ts' declare module '@deepseek-ai/cordis' { interface Context { - /** Host storage and staged-receipt service for browser file uploads. */ + /** Host storage and staged-receipt service for browser file and image uploads. */ fileUploads: FileUploads } } interface StagedFileUpload { - readonly file: FileAttachmentRef + readonly attachment: StagedAttachment /** Prompt that accepted this receipt; absent until successful admission. */ requestId?: string } @@ -65,8 +67,11 @@ export class FileUploads extends TypertRemoteService { /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ constructor(ctx: Context) { super(ctx, 'fileUploads') - const resolve: CommandFileReceiptResolver = (agent, receiptId) => - this.resolve(agent, receiptId as FileUploadReceiptId) + // Commands accept staged files only; an image receipt is unknown to them. + const resolve: CommandFileReceiptResolver = (agent, receiptId) => { + const staged = this.resolve(agent, receiptId as FileUploadReceiptId) + return staged?.type === 'file' ? staged.attachment : undefined + } ctx.effect( () => ctx.commands.registerFileReceiptResolver(resolve), 'file-upload: command file receipt resolver', @@ -105,12 +110,13 @@ export class FileUploads extends TypertRemoteService { * @returns the staged receipt and durable file reference. */ @Remote('upload') - upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { + async upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({ + const file = await this.store(agent, async () => this.ctx.attachments.admitEncodedFile({ data: request.data, ...(request.name === undefined ? {} : { name: request.name }), })) + return { receiptId: this.stage(agent, { type: 'file', attachment: file }), file } } /** @@ -125,21 +131,68 @@ export class FileUploads extends TypertRemoteService { readonly name?: string }): Promise { const agent = await this.resolveAgent(request.sessionId) - return this.commit(agent, async () => this.ctx.attachments.saveFileStream({ + const file = await this.store(agent, async () => this.ctx.attachments.saveFileStream({ data: request.data, ...(request.signal === undefined ? {} : { signal: request.signal }), ...(request.name === undefined ? {} : { name: request.name }), })) + return { receiptId: this.stage(agent, { type: 'file', attachment: file }), file } + } + + /** + * Persist one prompt image for a Session: read at most the smaller of the + * deployment's per-image and per-message byte limits, then validate, + * normalize, and store the image the way prompt admission does, so a later + * prompt cites it by receipt. + * Cancellation before storage begins stores nothing; a cancelled upload + * never receives a receipt, although an image whose storage already began + * stays in the content-addressed store. + * @param request - Session identity, ordered bytes, the declared media type that this deployment + * must accept, cancellation, and optional display name. + * @returns the staged receipt and durable normalized image reference. + */ + async uploadImageStream(request: { + readonly sessionId: SessionId + readonly data: AsyncIterable + readonly mediaType: string + readonly signal?: AbortSignal + readonly name?: string + }): Promise { + const agent = await this.resolveAgent(request.sessionId) + const limits = this.ctx.attachments.imageLimits + const mediaType = limits.mediaTypes.find(type => type === request.mediaType) + if (mediaType === undefined) { + throw new RemoteError( + 'session/attachment-invalid' as never, + `Image type ${request.mediaType} is not accepted by this deployment.`, + { reason: 'UNSUPPORTED_IMAGE_TYPE' } as never, + ) + } + const staged = await this.store(agent, async () => { + // One image must also fit a whole message; stop at the smaller bound, as read-image does. + const data = await readAtMost(request.data, Math.min(limits.maxImageBytes, limits.maxMessageImageBytes), request.signal) + // readAtMost checks cancellation per chunk; an abort after the last chunk still stops here. + request.signal?.throwIfAborted() + const refs = await this.ctx.attachments.saveImages([{ + data, + mediaType, + ...(request.name === undefined ? {} : { name: request.name }), + }]) + request.signal?.throwIfAborted() + // saveImages returns one reference per input, in input order. + return { type: 'image' as const, attachment: refs[0] as ImageAttachmentRef, encodedBytes: data.byteLength } + }) + return { receiptId: this.stage(agent, staged), image: staged.attachment } } /** * Resolve one staged receipt for the receiving Agent's exact Session. * @param agent - receiving Agent. * @param receiptId - opaque receipt minted for one completed upload. - * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + * @returns the staged file or image, or `undefined` for an unknown or foreign receipt. */ - resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { - return this.stagedFiles.get(agent.session)?.get(receiptId)?.file + resolve(agent: Agent, receiptId: FileUploadReceiptId): StagedAttachment | undefined { + return this.stagedFiles.get(agent.session)?.get(receiptId)?.attachment } /** @@ -179,12 +232,13 @@ export class FileUploads extends TypertRemoteService { this.retire(agent.session, requestId) } - private async commit(agent: Agent, save: () => Promise): Promise { + /** Store one upload for an ordinary Agent; storage failures become Remote failures. */ + private async store(agent: Agent, save: () => Promise): Promise { this.assertOrdinaryAgent(agent) - let file: FileAttachmentRef try { - file = await save() + return await save() } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error if (this.ctx.attachments.isAttachmentError(error)) { throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never) } @@ -195,6 +249,10 @@ export class FileUploads extends TypertRemoteService { { cause: error }, ) } + } + + /** Stage one stored upload under a new receipt for the Agent's exact Session. */ + private stage(agent: Agent, attachment: StagedAttachment): FileUploadReceiptId { if (this.ctx.agents.get(agent.id) !== agent) { throw new RemoteError( 'session/not-found', @@ -208,8 +266,8 @@ export class FileUploads extends TypertRemoteService { this.stagedFiles.set(agent.session, staged) } const receiptId = randomUUID() as FileUploadReceiptId - staged.set(receiptId, { file }) - return { receiptId, file } + staged.set(receiptId, { attachment }) + return receiptId } private async resolveAgent(sessionId: SessionId): Promise { @@ -248,6 +306,31 @@ export class FileUploads extends TypertRemoteService { } } +/** Collect one body of at most `limit` bytes, refusing it as soon as it passes the limit. */ +async function readAtMost(data: AsyncIterable, limit: number, signal?: AbortSignal): Promise { + const chunks: Uint8Array[] = [] + let length = 0 + for await (const chunk of data) { + signal?.throwIfAborted() + length += chunk.byteLength + if (length > limit) { + throw new RemoteError( + 'session/attachment-invalid' as never, + 'Image exceeds the configured byte limit.', + { reason: 'IMAGE_TOO_LARGE' } as never, + ) + } + chunks.push(chunk) + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + function fileNotStaged(): RemoteError { return new RemoteError( 'session/attachment-invalid' as never, diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts index ff153925af..669b4670cc 100644 --- a/packages/client/file-upload/src/types.ts +++ b/packages/client/file-upload/src/types.ts @@ -1,6 +1,6 @@ /** Browser-safe request and receipt types for staged file uploads. */ -import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' /** Canonical encoded upload accepted by the Remote fallback. */ @@ -18,6 +18,23 @@ export interface FileUploadValue { readonly file: FileAttachmentRef } +/** Durable receipt for one staged image upload. */ +export interface ImageUploadValue { + /** Per-upload authority accepted only inside the receiving Agent scope. */ + readonly receiptId: FileUploadReceiptId + /** The normalized prompt image the Host stored. */ + readonly image: ImageAttachmentRef +} + +/** + * Durable attachment one staged receipt names: a verbatim file, or a + * normalized prompt image with `encodedBytes` holding the byte length of its + * upload, which counts toward the per-message image byte limit. + */ +export type StagedAttachment = + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef; readonly encodedBytes: number } + /** Host-minted authority for one staged file upload in one Agent scope. */ export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> diff --git a/packages/client/file-upload/tests/file-upload-http.host.spec.ts b/packages/client/file-upload/tests/file-upload-http.host.spec.ts index cd137857ce..ed34e34d0e 100644 --- a/packages/client/file-upload/tests/file-upload-http.host.spec.ts +++ b/packages/client/file-upload/tests/file-upload-http.host.spec.ts @@ -9,12 +9,14 @@ function request(input: { method?: string sessionId?: string name?: string + mediaType?: string contentType?: string body?: Uint8Array } = {}): Request { const query = new URLSearchParams() if (input.sessionId !== undefined) query.set('sessionId', input.sessionId) if (input.name !== undefined) query.set('name', input.name) + if (input.mediaType !== undefined) query.set('mediaType', input.mediaType) const suffix = query.size === 0 ? '' : `?${query.toString()}` return new Request(`http://host/api/session/uploadFileBinary${suffix}`, { method: input.method ?? 'POST', @@ -91,6 +93,27 @@ describe('background file upload Fetch route', () => { expect(await response.json()).toEqual({ ok: true, value }) }) + it('stages the body as a prompt image when the request names its media type', async () => { + const value: Awaited> = { + receiptId: 'receipt-2' as never, + image: { attachmentId: 'image-1' as never, mediaType: 'image/png', bytes: 2, width: 1, height: 1 }, + } + const imageChunks: Uint8Array[] = [] + const uploadImageStream = vi.fn(async (input) => { + for await (const chunk of input.data) imageChunks.push(chunk) + return value + }) + const service = Object.assign(uploads(Promise.resolve({})), { uploadImageStream }) + const response = await handleFileUploadHttp(service, request({ + sessionId: 's1', name: 'shot.png', mediaType: 'image/png', + contentType: 'application/octet-stream', body: Uint8Array.of(7, 8), + })) + expect(service.uploadStream).not.toHaveBeenCalled() + expect(uploadImageStream.mock.calls[0]?.[0]).toMatchObject({ sessionId: 's1', name: 'shot.png', mediaType: 'image/png' }) + expect(imageChunks).toEqual([Uint8Array.of(7, 8)]) + expect(await response.json()).toEqual({ ok: true, value }) + }) + it('returns business and internal storage failures and keeps an absent name absent', async () => { const business = uploads(Promise.reject(new RemoteError( 'session/attachment-invalid', 'denied', { reason: 'NOPE' }, diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index 2392feed55..3a4e0b22ea 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -492,4 +492,53 @@ describe('Session-addressed file upload', () => { }) await failed.fiber.dispose() }) + + it('uploads an image with its media type and parses the stored image', async () => { + const image = { + attachmentId: 'image-1', mediaType: 'image/png', bytes: 4, width: 2, height: 2, + name: 'shot.png', originalDimensions: { width: 4, height: 4 }, + } + const fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ + ok: true, value: { receiptId: 'receipt-2', image }, + }), { status: 200 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const { fiber, service } = await scopedService() + const file = new Blob(['data'], { type: 'image/png' }) + await expect(service.uploadImage(SESSION_ID, file, 'image/png', 'shot.png')).resolves.toEqual({ + ok: true, value: { receiptId: 'receipt-2', image }, + }) + expect(fetch).toHaveBeenCalledWith( + 'api/session/uploadFileBinary?sessionId=s1&mediaType=image%2Fpng&name=shot.png', + expect.objectContaining({ method: 'POST', body: file }), + ) + await fiber.dispose() + + const plain = { attachmentId: 'image-2', mediaType: 'image/webp', bytes: 1, width: 1, height: 1 } + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify({ + ok: true, value: { receiptId: 'receipt-4', image: plain }, + }), { status: 200 })), + } + const unnamed = await scopedService() + await expect(unnamed.service.uploadImage(SESSION_ID, file, 'image/webp')).resolves.toEqual({ + ok: true, value: { receiptId: 'receipt-4', image: plain }, + }) + await unnamed.fiber.dispose() + + for (const malformed of [ + null, + { receiptId: 'receipt-3', image: { ...image, mediaType: 'image/bmp' } }, + { receiptId: 'receipt-3', image: { ...image, width: -1 } }, + { receiptId: 'receipt-3', image: { ...image, name: 3 } }, + { receiptId: 'receipt-3', image: { ...image, originalDimensions: { width: 1 } } }, + ]) { + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify({ ok: true, value: malformed }), { status: 200 })), + } + const rejected = await scopedService() + await expect(rejected.service.uploadImage(SESSION_ID, file, 'image/png')) + .rejects.toThrow('file upload transport returned an invalid image receipt') + await rejected.fiber.dispose() + } + }) }) diff --git a/packages/client/locale/tests/config.host.spec.ts b/packages/client/locale/tests/config.host.spec.ts index 9c9d3e5462..84af262287 100644 --- a/packages/client/locale/tests/config.host.spec.ts +++ b/packages/client/locale/tests/config.host.spec.ts @@ -13,11 +13,11 @@ describe('locale host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({}) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({}) await configuration.update({ preference: 'en' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'en' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'en' }) await configuration.update({ preference: 'pt-BR' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'pt-BR' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'pt-BR' }) await expect(configuration.update({ preference: 'bad locale' })).rejects.toThrow() await expect(configuration.update({ preference: '123' })).rejects.toThrow() await fiber.dispose() diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts index 1ccd331183..0ad898bd7a 100644 --- a/packages/client/product-analytics/tests/collection.host.spec.ts +++ b/packages/client/product-analytics/tests/collection.host.spec.ts @@ -161,7 +161,7 @@ it('streams live policy changes and rejects intake disabled during identity look const next = iterator.next() // Loader commits the stable Config reference before publishing this notification. const { updateVolatile, createVolatile } = await import('../../../../vendor/cosmokit/src/volatile.ts') - const config = b.fiber.config as import('../src/index.ts').Config + const config = b.fiber.effectiveConfig as import('../src/index.ts').Config updateVolatile(config.enabled, createVolatile(false)) b.fiber.ctx.emit('loader/volatile-update', [['enabled']]) expect(await next).toEqual({ value: false, done: false }) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 123b686b4e..ae497ad6ce 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -18,8 +18,8 @@ en: 15cdd9d52dcc71b8 zh: 53933fbbf5d39100 /deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments: - en: a77c8ec48e219aec - zh: 756d4fe881403006 + en: 8246cb3c470e3fc5 + zh: 7028cf466a832b99 /deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox: en: 8b3d7729152d8060 zh: 815f932ccc9653f5 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 4fee29e123..0805928455 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) an ### Draft attachments -Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Clicking an image opens the original. +Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. An uploading image shows the same progress bar along its thumbnail's bottom edge, and a failed image upload turns the thumbnail into its retry control, except an image the Host refused, whose thumbnail explains the refusal in a tooltip that a tap or click also pins and leaves removal as the way out. Clicking an image opens the original. ### Message images and the lightbox diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 706a6d97c7..c17f914b4d 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 草稿附件 -图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 +图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。上传中的图片在缩略图底边显示同样的进度条,上传失败的图片缩略图变成重试控件;Host 拒绝的图片则用缩略图的提示说明原因,点按也能固定该提示,只能移除。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 ### 消息图片与灯箱 diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index adb40af60f..2d2dc878f6 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -30,6 +30,57 @@ object-fit: cover; } +.thumbnailFailed { + border-color: var(--dsw-alias-state-error-primary, #d54941); + cursor: pointer; +} + +.thumbnailFailed img { + opacity: 0.4; +} + +.thumbnailRefused { + cursor: default; +} + +.failureGlyph { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--dsw-alias-state-error-primary, #d54941); +} + +.progressTrack { + position: absolute; + right: 8px; + bottom: 6px; + left: 8px; + overflow: hidden; + height: 2px; + border-radius: 1px; + background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); + pointer-events: none; +} + +.progressBar { + display: block; + width: 35%; + height: 100%; + border-radius: inherit; + background: var(--dsw-alias-brand-primary, #4d6bfe); + animation: image-upload-progress 1.2s ease-in-out infinite alternate; +} + +.progressBar[style] { + animation: none; +} + +@keyframes image-upload-progress { + from { transform: translateX(-70%); } + to { transform: translateX(220%); } +} + .remove { position: absolute; top: 4px; @@ -70,4 +121,8 @@ .remove { transition: none; } + + .progressBar { + animation: none; + } } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 0065410b9d..8035470f69 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCloseFillRegular, IconRefreshOutlineRegular, IconWarningOutlineRegular, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' @@ -17,10 +19,15 @@ interface ComposerRailItem extends AttachmentRailItem { attachment: ComposerAttachment } +/** The owner share and locale seat the attachment rail reads from its slot props. */ +type ComposerAttachmentsInput = Pick + /** Draft image previews, pending-file cards, drop target, and original-image preview. */ export function ComposerAttachments({ - attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t, -}: ComposerAttachmentsProps) { + attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, uploadFailureText, dropLimits, t, +}: ComposerAttachmentsInput) { const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) const dragDepth = useRef(0) @@ -71,20 +78,68 @@ export function ComposerAttachments({ /> ) } + // An uploading image shows its progress; a failed upload turns the thumbnail into its retry + // control, except one the Host refused, which explains why and leaves removal as the way out. + // The refusal is the only explanation of its warning, so a tap or click also pins it. + const upload = uploads[attachment.id] + const name = attachment.file.name || t('image.pending') + const refusal = upload?.status === 'error' && upload.reason !== undefined + ? uploadFailureText(upload.reason) + : undefined return (
- + {refusal !== undefined + ? ( + + + + ) + : upload?.status === 'error' + ? ( + + + + ) + : ( + + )} + {upload?.status === 'uploading' && ( + + + + )}
)} - {showBubble &&
+ {showBubble && {renderFactorySlot('conversation.user.text', { text, facts, interactive, ellipsis })} {extraBlocks.map((block, index) => ( ))} -
} + } {referenceLabels.length > 0 && (
{t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })} diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts index a1b7328c92..e3996ec43d 100644 --- a/packages/client/ui-chat/tests/config.host.spec.ts +++ b/packages/client/ui-chat/tests/config.host.spec.ts @@ -14,26 +14,26 @@ describe('ui-chat Host settings', () => { const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion', foldedReplies: 'all', }) await configuration.update({ transcriptView: 'normal' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar', collapseTiming: 'completion', foldedReplies: 'all', }) for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) { await configuration.update({ transcriptView: mode }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ transcriptView: mode }) } for (const mode of ['dense', '', 42, false, {}, null, undefined]) { await configuration.update({ transcriptView: mode }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode === null ? null : undefined }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ transcriptView: mode === null ? null : undefined }) } await configuration.update({ performanceUsage: 'compact' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ performanceUsage: 'compact' }) await expect(configuration.update({ performanceUsage: 'hidden' })).rejects.toThrow() await configuration.update({ linkOpening: 'new-tab' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ linkOpening: 'new-tab' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ linkOpening: 'new-tab' }) await expect(configuration.update({ linkOpening: 'popup' })).rejects.toThrow() await fiber.dispose() @@ -42,15 +42,15 @@ describe('ui-chat Host settings', () => { it('validates collapse timing and restores its saved config independently of work details', async () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ collapseTiming: 'completion' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ collapseTiming: 'completion' }) await configuration.update({ transcriptView: 'compact', collapseTiming: 'next-input' }) - const saved = plainConfig(configuration.fiber.config) + const saved = plainConfig(configuration.fiber.effectiveConfig) if (typeof saved !== 'object' || saved === null) throw new Error('expected saved configuration object') expect(saved).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) await expect(configuration.update({ collapseTiming: 'never' })).rejects.toThrow() await configuration.fiber.dispose() const restored = await liveConfig(ctx, { Config, apply }, saved) - expect(plainConfig(restored.fiber.config)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) + expect(plainConfig(restored.fiber.effectiveConfig)).toMatchObject({ transcriptView: 'compact', collapseTiming: 'next-input' }) await restored.fiber.dispose() }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 844653cbff..268b7e59ae 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 7871f9fe6bcc4459 zh: 0d51565c600b3958 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 457e642073206f7d - zh: 95ea47c204850314 + en: 1d70d520d4a7537c + zh: 83febffaf90e0ac5 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fb3c5a44f8..a905984576 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -112,7 +112,7 @@ Clicking unused space inside an editable composer preserves its current caret or Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The snapshot also records whether the Session is still blank and the Agent preset id the Session records. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. On Desktop, the notification reports `send_button_click`, which carries `is_new_session` (true while the Session is blank) and `agent_preset`, the preset category: `creator` for `cordis`; `ptc`, `minimal`, and `standard` under their own ids; `other` for any other preset. `agent_preset` is omitted while the Session records no preset. Custom preset ids are not sent. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and cites the staged receipts of files and uploaded images; an image without an upload, as in a continuable subagent's draft, is encoded through the browser's native `FileReader` data-URL path. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files, and the images of an ordinary Session's draft, enter one FIFO background-upload queue as soon as they are added, and Send waits until each has a receipt; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits and send them inline, because the Host stages uploads only for ordinary Sessions; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8cf77157c3..feb91d7562 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -112,7 +112,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。快照还记录会话是否仍为空白,以及会话记录的 Agent preset id。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。在桌面端,该通知报告 `send_button_click`,携带 `is_new_session`(会话为空白时为 true)和表示 preset 类别的 `agent_preset`:`cordis` 记为 `creator`;`ptc`、`minimal` 与 `standard` 沿用各自的 id;其他 preset 记为 `other`。会话未记录 preset 时省略 `agent_preset`。不发送自定义 preset 的 id。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,文件和已上传的图片引用已暂存凭证;没有上传的图片(例如 continuable 子代理草稿里的图片)经浏览器原生 `FileReader` data-URL 路径编码。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件以及普通 Session 草稿里的图片在加入时就进入同一个先进先出的后台上传队列,发送会等到每一项都取得凭证;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,沿用相同的投影限制并以内联方式发送,因为 Host 只为普通 Session 暂存上传;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9ca1c14380..6a004c8740 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -368,10 +368,13 @@ export function apply(ctx: Context, config: Config = Config({})): void { const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) { - if (sessions.binding(nextId) === undefined) { + const binding = sessions.binding(nextId) + if (binding === undefined) { throw new Error(`ui-conversation: session "${nextId}" resolved no binding`) } - concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds) + // The same rule as intake: only an ordinary Session stages image uploads. + const ordinary = binding.session.getSnapshot().subagent === null + concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds, ordinary) if (draft.text !== '') { next.setDraft(draft) from.setDraft('') @@ -521,7 +524,9 @@ export function apply(ctx: Context, config: Config = Config({})): void { }) } try { - const drafts = conversation.createDrafts(sessionId, uploads) + // The Host stages uploads for ordinary Sessions only; a subagent continuation's images travel inline. + const ordinary = sessions.binding(sessionId)?.session.getSnapshot().subagent === null + const drafts = conversation.createDrafts(sessionId, uploads, ordinary) if (!shell.addFiles(references, drafts.map(draft => draft.id))) { conversation.releaseDraftAttachments(drafts) return t('attachment.dropBlocked') diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index d93a848198..6f638b2371 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -28,7 +28,11 @@ import type { UserTextDocumentInput, UserTextEvaluator, UserTextId, UserTextNode /** Browser-owned draft attachment that has not crossed the durable Host boundary. */ export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment -/** Browser-owned image, base64-encoded into the prompt at send time. */ +/** + * Browser-owned image. An ordinary Session's draft uploads it to the Host as + * soon as it is added, and the prompt cites the receipt; a subagent + * continuation's draft base64-encodes it into the prompt at send time. + */ export interface ComposerImageAttachment { kind: 'image' id: DraftAttachmentId @@ -47,11 +51,17 @@ export interface ComposerFileAttachment { file: File } -/** Upload lifecycle of one picked file draft (files upload on pick, not on send). */ +/** + * Upload lifecycle of one draft that uploads when it is added, not on send: + * every file draft, and every image draft of an ordinary Session. A failed + * upload the Host refused keeps the attachment refusal `reason`, because + * retrying the same bytes cannot succeed. + */ export type DraftFileUpload = | { readonly status: 'uploading'; readonly loaded: number; readonly total?: number } | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly file: FileAttachmentRef } - | { readonly status: 'error'; readonly message: string } + | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId } + | { readonly status: 'error'; readonly message: string; readonly reason?: string } /** Per-draft upload states keyed by draft attachment id. */ export type DraftFileUploads = Readonly> @@ -74,6 +84,13 @@ export interface ComposerAttachmentsOwnerProps { uploads: DraftFileUploads /** Restart one failed file upload. */ onRetryFile: (id: DraftAttachmentId) => void + /** + * Explain one upload the Host refused; such a draft cannot be retried and + * stays until it is removed. + * @param reason - the refusal `reason` the failed upload carries. + * @returns localized text naming the limit or the way out. + */ + uploadFailureText: (reason: string) => string /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } @@ -517,7 +534,7 @@ export interface ComposerBarInjected { * and the primary Send button use while the addressed agent is busy. */ busyEnter: ObservableSnapshot - /** Live per-draft upload states for file-kind drafts. */ + /** Live per-draft upload states for drafts that upload when added. */ fileUploads: ObservableSnapshot notices: ObservableSnapshot lexicon: ObservableSnapshot> diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index dacb1796b8..524c8a37ea 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -2,7 +2,6 @@ import type { MouseEvent, MutableRefObject, RefObject } from 'react' import type { LexicalEditor } from 'lexical' import type { ComposerKeyboard } from '../../contract/draft-editor.ts' -import type { ComposerBarProps } from '../../contract/slots.ts' import type { BusyEnterBehavior } from '../../contract/composer-submission.ts' import { resolveSubmitMode } from '../submission-policy.ts' import { registerComposerKeymap } from './keymap.ts' @@ -16,8 +15,9 @@ interface DraftViewGate { busyEnter: BusyEnterBehavior intakeFiles: (files: readonly File[], directories?: ReadonlySet) => void uploadsPending: boolean + /** Toast text explaining why pending uploads hold the send. */ + uploadsHeldText: string showToast: (text: string) => void - t: ComposerBarProps['t'] canAcceptDrop: boolean } @@ -126,7 +126,7 @@ export function installDraftKeymap( return } if (g.uploadsPending) { - g.showToast(g.t('file.stillUploading')) + g.showToast(g.uploadsHeldText) return } keyboard.submit(resolveSubmitMode( diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index fc76701be6..41eabe4132 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -18,7 +18,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type {} from '@deepseek-ai/dsh-client-file-upload/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { @@ -155,7 +155,7 @@ export class ConversationController extends Service implements IConversation { readonly input: SessionInputResolver /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks - /** Live upload state per file-kind draft; images never appear here. */ + /** Live upload state per draft that uploads when added: every file draft and every uploaded image draft. */ readonly fileUploads: SnapshotStore> = createSnapshotStore>({}) private readonly draftAttachments = new Map() private readonly fileUploadOperations = new Map => { + const uploadFor = (attachment: ComposerAttachment): Extract => { const upload = uploads[attachment.id] if (upload === undefined || upload.status !== 'ready') { throw new Error('conversation.sendSession: one or more files have not finished uploading') } return upload } + // A file draft's upload always stores a file; only image drafts store an image. + const storedFileOf = (attachment: ComposerFileAttachment): FileAttachmentRef => + (uploadFor(attachment) as Extract).file + // An image draft without an upload, such as a subagent continuation's, sends its bytes inline. + const imageReceipts = new Map(attachments.flatMap(attachment => attachment.kind === 'image' + && uploads[attachment.id] !== undefined ? [[attachment.id, uploadFor(attachment).receiptId] as const] : [])) const pendingAttachments = attachments.map(attachment => attachment.kind === 'image' ? { type: 'image' as const, @@ -255,11 +261,15 @@ export class ConversationController extends Service implements IConversation { ...(attachment.height === undefined ? {} : { height: attachment.height }), }, } - : { type: 'file' as const, value: uploadFor(attachment).file }) + : { type: 'file' as const, value: storedFileOf(attachment) }) const serializeAttachments = (): Promise[0]> => Promise.all( - attachments.map(async attachment => attachment.kind === 'image' - ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } - : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), + attachments.map(async (attachment) => { + if (attachment.kind === 'file') return { type: 'file' as const, receiptId: uploadFor(attachment).receiptId } + const receiptId = imageReceipts.get(attachment.id) + return receiptId === undefined + ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } + : { type: 'file' as const, receiptId } + }), ) let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined const retirement = attachments.length === 0 @@ -291,20 +301,24 @@ export class ConversationController extends Service implements IConversation { /** * Create runtime-only draft attachments. Files whose browser MIME is an - * accepted image type become image drafts (object URL preview, bytes sent - * with the prompt); every other file becomes a file draft whose background - * upload starts immediately and remains owned by this service across Session - * navigation until completion or explicit removal. + * accepted image type become image drafts with an object URL preview; every + * other file becomes a file draft. A file draft's background upload starts + * immediately, and so does an image draft's when `uploadImages` is set; the + * upload remains owned by this service across Session navigation until + * completion or explicit removal. An image draft without an upload sends its + * bytes with the prompt. * @param sessionId - target Agent-scope identity. * @param files - browser files to register. + * @param uploadImages - whether the Session accepts image uploads; a subagent continuation does not. * @returns ordered draft descriptors. */ - createDrafts(sessionId: SessionId, files: readonly File[]): readonly ComposerAttachment[] { + createDrafts(sessionId: SessionId, files: readonly File[], uploadImages: boolean): readonly ComposerAttachment[] { return files.map((file) => { if (isImageMediaType(file.type)) { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) probeDimensions(attachment) + if (uploadImages) this.beginFileUpload(sessionId, attachment) return attachment } const attachment: ComposerFileAttachment = { @@ -319,30 +333,33 @@ export class ConversationController extends Service implements IConversation { } /** - * Restart one failed file upload. + * Restart one failed file or image upload. * @param sessionId - target Agent-scope identity. * @param id - draft attachment id whose upload previously failed. */ retryFileUpload(sessionId: SessionId, id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) - if (attachment === undefined || attachment.kind !== 'file') return + if (attachment === undefined) return if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return this.beginFileUpload(sessionId, attachment) } /** - * Stage carried file drafts again for a new Session. + * Stage carried file and image drafts again for a new Session. * @param sessionId - target Agent-scope identity after a Workspace switch. * @param ids - carried draft attachment ids. + * @param uploadImages - whether the target Session stages image uploads; an image draft otherwise sends inline. */ - rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[]): void { + rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[], uploadImages: boolean): void { for (const id of ids) { const attachment = this.draftAttachments.get(id) - if (attachment?.kind === 'file') this.beginFileUpload(sessionId, attachment) + if (attachment === undefined) continue + if (attachment.kind === 'image' && !uploadImages) this.dropUpload(id) + else this.beginFileUpload(sessionId, attachment) } } - private beginFileUpload(sessionId: SessionId, attachment: ComposerFileAttachment): void { + private beginFileUpload(sessionId: SessionId, attachment: ComposerAttachment): void { this.fileUploadOperations.get(attachment.id)?.controller.abort() const controller = new AbortController() this.fileUploads.update((draft) => { @@ -353,33 +370,41 @@ export class ConversationController extends Service implements IConversation { this.fileUploadOperations.set(attachment.id, { controller, done }) this.pendingFileUploads.add(done) void done.then(() => { this.pendingFileUploads.delete(done) }) + const name = attachment.file.name === '' ? undefined : attachment.file.name + const onProgress = (progress: { readonly loaded: number; readonly total?: number }): void => { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'uploading', + loaded: progress.loaded, + ...(progress.total === undefined ? {} : { total: progress.total }), + } + }) + } const run = async (): Promise => { try { if (controller.signal.aborted || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return - const result = await this.ctx.fileUpload.upload( - sessionId, - attachment.file, - attachment.file.name === '' ? undefined : attachment.file.name, - controller.signal, - (progress) => { - if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return - this.fileUploads.update((draft) => { - if (!(attachment.id in draft)) return - draft[attachment.id] = { - status: 'uploading', - loaded: progress.loaded, - ...(progress.total === undefined ? {} : { total: progress.total }), - } - }) - }, - ) + const result = attachment.kind === 'image' + ? await this.ctx.fileUpload.uploadImage( + sessionId, attachment.file, imageMediaType(attachment.file.type), name, controller.signal, onProgress, + ) + : await this.ctx.fileUpload.upload(sessionId, attachment.file, name, controller.signal, onProgress) if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return this.fileUploads.update((draft) => { if (!(attachment.id in draft)) return - draft[attachment.id] = result.ok - ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } - : { status: 'error', message: result.error.message } + if (result.ok) { + draft[attachment.id] = 'file' in result.value + ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } + : { status: 'ready', receiptId: result.value.receiptId } + return + } + const { error } = result + // A Host refusal keeps its reason: retrying the same bytes cannot succeed. + draft[attachment.id] = error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-invalid' + ? { status: 'error', message: error.message, reason: error.details.reason } + : { status: 'error', message: error.message } }) } catch (error) { if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return @@ -430,8 +455,9 @@ export class ConversationController extends Service implements IConversation { /** * Serialize ordered draft attachments to command-submit wire payloads without - * sending or releasing them. Images are encoded; generic files cite receipts - * from their completed background uploads and never reread browser bytes. + * sending or releasing them. Images are encoded even when an upload already + * stored them, because commands accept only file receipts; generic files cite + * receipts from their completed background uploads and never reread browser bytes. * @param attachmentIds - ordered draft-local attachment ids. * @returns wire payloads in id order. */ @@ -462,18 +488,25 @@ export class ConversationController extends Service implements IConversation { releaseDraftAttachment(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return + this.draftAttachments.delete(id) + if (attachment.kind === 'image') revokePreview(attachment.previewUrl) + // The stored Host object stays durable; only the draft's upload state ends. + this.dropUpload(id) + } + + /** Abort one draft's active upload and drop its upload state. */ + private dropUpload(id: DraftAttachmentId): void { const operation = this.fileUploadOperations.get(id) this.fileUploadOperations.delete(id) operation?.controller.abort() - this.draftAttachments.delete(id) - if (attachment.kind === 'image') { - revokePreview(attachment.previewUrl) - return - } - // The stored Host object stays durable; only the draft's upload state ends. - this.fileUploads.set(Object.fromEntries( - Object.entries(this.fileUploads.getSnapshot()).filter(([key]) => key !== id), - )) + this.forgetUpload(id) + } + + /** Drop one draft's upload state. */ + private forgetUpload(id: DraftAttachmentId): void { + const uploads = this.fileUploads.getSnapshot() + if (!(id in uploads)) return + this.fileUploads.set(Object.fromEntries(Object.entries(uploads).filter(([key]) => key !== id))) } /** @@ -559,6 +592,7 @@ export class ConversationController extends Service implements IConversation { continue } this.draftAttachments.delete(attachment.id) + this.forgetUpload(attachment.id) if (ref !== undefined && 'mediaType' in ref && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) continue revokePreview(attachment.previewUrl) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index ef95796577..e53e666864 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -80,11 +80,12 @@ export const InputBar = memo(function InputBar({ ) const empty = draft.trim() === '' && attachments.length === 0 const uploads = useFileUploads(snapshot => snapshot) - // Send waits for every picked file: uploading and failed drafts both hold - // the gate (a failed upload is retried or removed, never silently dropped). - const uploadsPending = attachments.some( - attachment => attachment.kind === 'file' && uploads[attachment.id]?.status !== 'ready', - ) + // Send waits for every picked file and every uploading image: uploading and + // failed drafts both hold the gate (a failed upload is retried or removed, + // never silently dropped). An image draft without an upload sends inline. + const uploadsPending = attachments.some(attachment => attachment.kind === 'file' + ? uploads[attachment.id]?.status !== 'ready' + : uploads[attachment.id] !== undefined && uploads[attachment.id]?.status !== 'ready') // Transient error banner (machine notices, image-intake rejections, and // prompt failures): the seq keys the Toast so an identical repeated message // restarts the hold-then-fade cycle instead of reusing the faded one. @@ -98,6 +99,11 @@ export const InputBar = memo(function InputBar({ // The deployment's image-intake limits (absent while no attachment service // is composed — the pre-check below then defers entirely to the host). const imageLimits = useProjection('imageLimits') + // Enter while uploads hold the send explains the first failed draft; a Host refusal names its reason. + const failedUpload = attachments.map(attachment => uploads[attachment.id]).find(upload => upload?.status === 'error') + const uploadsHeldText = failedUpload?.status !== 'error' + ? t('file.stillUploading') + : failedUpload.reason === undefined ? t('file.uploadFailed') : attachmentErrorText(t, failedUpload.reason, imageLimits) // Prompt failures are ordinary failures (no create/attach transaction exists // anymore): the toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds @@ -252,11 +258,11 @@ export const InputBar = memo(function InputBar({ // registration survives re-renders without re-arming per keystroke. const gate = useRef({ locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, canAcceptDrop, + intakeFiles, uploadsPending, uploadsHeldText, showToast, canAcceptDrop, }) gate.current = { locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, canAcceptDrop, + intakeFiles, uploadsPending, uploadsHeldText, showToast, canAcceptDrop, } useEffect(() => { @@ -411,6 +417,7 @@ export const InputBar = memo(function InputBar({ onRemoveAttachment: (id) => { removeAttachment?.(id) }, uploads, onRetryFile: (id) => { retryFileUpload?.(id) }, + uploadFailureText: reason => attachmentErrorText(t, reason, imageLimits), dropLimits: imageLimits === undefined ? undefined : { count: imageLimits.maxImagesPerMessage, size: imageSizeText(imageLimits.maxImageBytes), diff --git a/packages/client/ui-conversation/tests/config.host.spec.ts b/packages/client/ui-conversation/tests/config.host.spec.ts index cb047da964..7b87d23d59 100644 --- a/packages/client/ui-conversation/tests/config.host.spec.ts +++ b/packages/client/ui-conversation/tests/config.host.spec.ts @@ -13,9 +13,9 @@ describe('ui-conversation host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: DEFAULT_BUSY_ENTER_BEHAVIOR }) await configuration.update({ busyEnter: 'steer' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ busyEnter: 'steer' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ busyEnter: 'steer' }) await expect(configuration.update({ busyEnter: 'invalid' })).rejects.toThrow() await fiber.dispose() }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index e5ad1d32c5..e308d326da 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -242,8 +242,9 @@ function bench(over?: BenchOptions) { const steeringAvailable = over?.subagent === undefined || over.subagent.address.mode === 'continuable' const composerLocked = over?.disabled === true || over?.inert === true || over?.blocked !== undefined || (over?.subagent?.address.mode === 'continuable' && over.subagent.parentAvailable !== true) - const uploadsPending = (over?.attachments ?? []).some(attachment => - attachment.kind === 'file' && over?.fileUploads?.[attachment.id]?.status !== 'ready') + const uploadsPending = (over?.attachments ?? []).some(attachment => attachment.kind === 'file' + ? over?.fileUploads?.[attachment.id]?.status !== 'ready' + : over?.fileUploads?.[attachment.id] !== undefined && over.fileUploads[attachment.id]?.status !== 'ready') const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false) const primaryLabel = primaryStops ? '停止生成' @@ -1100,6 +1101,58 @@ describe('running and lock semantics', () => { expect(ready.button.disabled).toBe(false) }) + it('running Send waits for an uploading image and treats an image without an upload as ready', () => { + const image = { + kind: 'image' as const, + id: 'image-1' as DraftAttachmentId, + file: new File(['x'], 'shot.png', { type: 'image/png' }), + previewUrl: 'blob:shot', + } + const uploading = bench({ + running: true, busyEnter: 'steer', draft: '带图', attachments: [image], + fileUploads: { [image.id]: { status: 'uploading', loaded: 0 } }, + }) + expect(uploading.button.getAttribute('aria-label')).toBe('发送消息') + expect(uploading.button.disabled).toBe(true) + + // A subagent continuation's image has no upload and travels inline. + const inline = bench({ running: true, busyEnter: 'steer', draft: '带图', attachments: [image] }) + expect(inline.button.getAttribute('aria-label')).toBe('插话发送') + expect(inline.button.disabled).toBe(false) + }) + + it('explains on Enter why uploads hold the send, naming a Host refusal', () => { + const limits = { + maxImageBytes: 1024 * 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'] as const, + } + const image = { + kind: 'image' as const, + id: 'image-1' as DraftAttachmentId, + file: new File(['x'], 'wide.png', { type: 'image/png' }), + previewUrl: 'blob:wide', + } + const enterWith = (upload: NonNullable[string]): string | null => { + const result = bench({ draft: '带图', attachments: [image], fileUploads: { [image.id]: upload }, imageLimits: limits }) + fireEvent.keyDown(result.textarea, { key: 'Enter' }) + expect(result.sink).not.toHaveBeenCalled() + const text = result.view.getByRole('alert').textContent + cleanup() + return text + } + expect(enterWith({ status: 'uploading', loaded: 0 })).toContain('文件还在上传,请等待上传完成后发送') + expect(enterWith({ status: 'error', message: 'offline' })).toContain('上传失败,点击重试') + const refusal = '图片宽高不能超过 2000px,请缩小后重试' + expect(enterWith({ status: 'error', message: 'too wide', reason: 'IMAGE_DIMENSION_TOO_LARGE' })).toContain(refusal) + // The attachments slot receives the same localized explanation for its thumbnail. + const owner = attachmentOwner(bench({ attachments: [image], imageLimits: limits }).slotCalls) + expect(owner.uploadFailureText('IMAGE_DIMENSION_TOO_LARGE')).toBe(refusal) + }) + it('idle Send keeps the plain label regardless of the busy-state preference', () => { const { button, sink } = bench({ busyEnter: 'steer', draft: '空闲发送' }) expect(button.getAttribute('aria-label')).toBe('发送消息') diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 66885e5a53..76b93bd95a 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -18,6 +18,19 @@ import { reportMessageSubmission } from '../src/client/input/submission-analytic import { ConversationController } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' +const IMAGE_REF = { + attachmentId: 'image-stored-1' as never, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, +} + +/** `createDrafts` mode of an ordinary Session draft, which uploads its images when added. */ +const UPLOAD_IMAGES = true +/** `createDrafts` mode of a draft whose images travel inline, as a subagent continuation's do. */ +const INLINE_IMAGES = false + async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { @@ -27,6 +40,13 @@ async function bench(maxConcurrentFileUploads = 2) { if (session.uploadFile === undefined) throw new Error('test file upload has no Session override') return session.uploadFile(...args) } + runtime.fileUpload.uploadImage = (sessionId: SessionId, ...args: unknown[]) => { + const session = runtime.sessions.behavior(sessionId) as { + uploadImage?: (...input: unknown[]) => Promise + } + if (session.uploadImage === undefined) throw new Error('test image upload has no Session override') + return session.uploadImage(...args) + } const prompt = vi.fn(( _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string, ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) @@ -125,7 +145,7 @@ describe('ConversationController', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) if (attachment === undefined) throw new Error('draft attachment missing') b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id]) b.reference.release() @@ -146,7 +166,7 @@ describe('ConversationController', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) if (attachment === undefined) throw new Error('draft attachment missing') b.shell.addAttachments([attachment.id]) b.shell.submit() @@ -161,7 +181,7 @@ describe('ConversationController', () => { await b.runtime.dispose() }) - it('classifies image MIME drafts as images and every other file as an uploading file draft', async () => { + it('classifies image MIME drafts as images and other files as file drafts, uploading images only where accepted', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') const session = b.runtime.sessions.binding('s1')!.session @@ -172,24 +192,95 @@ describe('ConversationController', () => { file: { attachmentId: 'sha256:1' as never, name: 'notes.pdf', bytes: 2 }, }, })) - ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const uploadImage = vi.fn((_data: Blob, _mediaType: string, _name?: string) => Promise.resolve({ + ok: true as const, + value: { receiptId: 'image-receipt-1' as never, image: IMAGE_REF }, + })) + Object.assign(session, { uploadFile, uploadImage }) const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) expect(created).toHaveBeenCalledTimes(1) - const fileDraft = drafts[1]! + const [imageDraft, fileDraft] = [drafts[0]!, drafts[1]!] expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('uploading') + expect(b.root.fileUploads.getSnapshot()[imageDraft.id]?.status).toBe('uploading') await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready') + expect(b.root.fileUploads.getSnapshot()[imageDraft.id]) + .toEqual({ status: 'ready', receiptId: 'image-receipt-1' }) }) expect(uploadFile).toHaveBeenCalledOnce() - expect(uploadFile.mock.calls[0]?.[0]).toBe((fileDraft as { file: File }).file) + expect(uploadFile.mock.calls[0]?.[0]).toBe(fileDraft.file) + expect(uploadImage.mock.calls[0]?.slice(0, 3)).toEqual([imageDraft.file, 'image/png', 'valid.png']) + const [inline] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(3)], 'child.png', { type: 'image/png' }), + ], INLINE_IMAGES) + expect(b.root.fileUploads.getSnapshot()[inline!.id]).toBeUndefined() + expect(uploadImage).toHaveBeenCalledOnce() created.mockRestore() await b.runtime.dispose() }) + it('retries a failed image upload and aborts it with its draft', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:retry') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const session = b.runtime.sessions.binding('s1')!.session + const signals: AbortSignal[] = [] + const uploadImage = vi.fn((_data: Blob, _mediaType: string, _name: string | undefined, signal: AbortSignal) => { + signals.push(signal) + return signals.length === 1 + ? Promise.resolve({ ok: false, error: new RemoteError('session/attachment-invalid', 'too big', { reason: 'IMAGE_TOO_LARGE' }) }) + : new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + }) + ;(session as { uploadImage?: unknown }).uploadImage = uploadImage + const [draft] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'big.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + // A Host refusal keeps its reason; retrying stays available to the service. + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[draft!.id]) + .toEqual({ status: 'error', message: 'too big', reason: 'IMAGE_TOO_LARGE' }) + }) + b.root.retryFileUpload(session.sessionId, draft!.id) + await vi.waitFor(() => { expect(uploadImage).toHaveBeenCalledTimes(2) }) + expect(b.root.fileUploads.getSnapshot()[draft!.id]?.status).toBe('uploading') + b.root.releaseDraftAttachment(draft!.id) + expect(signals[1]?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[draft!.id]).toBeUndefined() + expect(revoked).toHaveBeenCalledWith('blob:retry') + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + + it('keeps no refusal reason when an image upload fails in transport', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:flaky') + try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadImage?: unknown }).uploadImage = vi.fn(() => Promise.resolve({ + ok: false, error: new RemoteError('gateway/internal', 'connection reset', {}), + })) + const [draft] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'flaky.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[draft!.id]).toEqual({ status: 'error', message: 'connection reset' }) + }) + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('serializes command files as staged receipts without reading their bytes again', async () => { const b = await bench() const session = b.runtime.sessions.binding('s1')!.session @@ -204,7 +295,7 @@ describe('ConversationController', () => { const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }), new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }), - ]) + ], UPLOAD_IMAGES) await vi.waitFor(() => { expect(drafts.every(draft => b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) }) @@ -245,7 +336,7 @@ describe('ConversationController', () => { }) ;(session as { uploadFile?: unknown }).uploadFile = uploadFile const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name => - new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' }))) + new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' })), UPLOAD_IMAGES) await vi.waitFor(() => { expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) }) await expect(b.root.serializeDraftAttachments([drafts[2]!.id])) @@ -295,7 +386,7 @@ describe('ConversationController', () => { ;(session as { uploadFile?: unknown }).uploadFile = uploadFile const [attachment] = b.root.createDrafts(session.sessionId, [ new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') b.shell.addAttachments([attachment.id]) await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) @@ -328,6 +419,38 @@ describe('ConversationController', () => { await b.runtime.dispose() }) + it('drops a carried image upload when the target Session sends images inline', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:carry') + try { + const source = b.runtime.sessions.binding('s1')!.session + let sourceSignal: AbortSignal | undefined + ;(source as { uploadImage?: unknown }).uploadImage = vi.fn( + (_data: Blob, _mediaType: string, _name: string | undefined, signal: AbortSignal) => { + sourceSignal = signal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + }, + ) + const [image] = b.root.createDrafts(source.sessionId, [ + new File([Uint8Array.of(5)], 'carry.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await vi.waitFor(() => { expect(sourceSignal).toBeDefined() }) + const targetUpload = vi.fn() + await b.runtime.sessions.add({ id: 's2', session: { uploadImage: targetUpload } }) + using _target = b.runtime.sessions.retain('s2' as SessionId) + await _target.ready + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [image!.id], INLINE_IMAGES) + expect(sourceSignal?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[image!.id]).toBeUndefined() + expect(targetUpload).not.toHaveBeenCalled() + } finally { + created.mockRestore() + } + await b.runtime.dispose() + }) + it('cancels a superseded upload and stages the carried draft on the target Session', async () => { const b = await bench() const source = b.runtime.sessions.binding('s1')!.session @@ -343,7 +466,7 @@ describe('ConversationController', () => { ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload const [attachment] = b.root.createDrafts(source.sessionId, [ new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') await vi.waitFor(() => { expect(sourceUpload).toHaveBeenCalledOnce() }) @@ -359,7 +482,7 @@ describe('ConversationController', () => { await b.runtime.sessions.add({ id: 's2', session: target }) using _target = b.runtime.sessions.retain('s2' as SessionId) await _target.ready - b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id]) + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id], UPLOAD_IMAGES) expect(sourceSignal?.aborted).toBe(true) await vi.waitFor(() => { @@ -386,7 +509,7 @@ describe('ConversationController', () => { ;(session as { uploadFile?: unknown }).uploadFile = uploadFile const [attachment] = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) @@ -415,7 +538,7 @@ describe('ConversationController', () => { })) const [attachment] = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) if (attachment === undefined) throw new Error('file draft missing') await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') @@ -454,7 +577,7 @@ describe('ConversationController', () => { ) b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }), - ]) + ], UPLOAD_IMAGES) await vi.waitFor(() => { expect(uploadSignal).toBeDefined() }) let disposed = false @@ -516,7 +639,7 @@ describe('sendSession submission echo', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') const echo = await b.begun @@ -563,7 +686,7 @@ describe('sendSession submission echo', () => { new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }), new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready') }) @@ -612,6 +735,40 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) + it('sends an uploaded image draft by its receipt and holds a send while it uploads', async () => { + const b = await echoBench() + const readAsDataURL = vi.spyOn(FileReader.prototype, 'readAsDataURL') + try { + const session = b.runtime.sessions.binding('s1')!.session + const uploaded = Promise.withResolvers() + ;(session as { uploadImage?: unknown }).uploadImage = vi.fn(() => uploaded.promise) + const [draft] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'shot.png', { type: 'image/png' }), + ], UPLOAD_IMAGES) + await expect(b.root.sendSession(session, 'look', [draft!.id], 'queue')) + .rejects.toThrow('one or more files have not finished uploading') + expect(b.beginSubmission).not.toHaveBeenCalled() + uploaded.resolve({ ok: true, value: { receiptId: 'image-receipt-1', image: IMAGE_REF } }) + await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[draft!.id]?.status).toBe('ready') }) + const sending = b.root.sendSession(session, 'look', [draft!.id], 'queue') + const echo = await b.begun + expect(echo.attachments[0]).toMatchObject({ type: 'image', value: { previewUrl: 'blob:echo-1', name: 'shot.png' } }) + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + expect(b.prompt.mock.calls[0]?.[0]).toEqual([ + { type: 'file', receiptId: 'image-receipt-1' }, + { type: 'text', text: 'look' }, + ]) + expect(readAsDataURL).not.toHaveBeenCalled() + b.retire.onRetire?.({ reason: 'observed', attachments: [IMAGE_REF] }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.fileUploads.getSnapshot()[draft!.id]).toBeUndefined() + } finally { + readAsDataURL.mockRestore() + b.restore() + } + await b.runtime.dispose() + }) + it('passes each delivery mode before image serialization', async () => { const b = await echoBench() try { @@ -643,7 +800,7 @@ describe('sendSession submission echo', () => { b.runtime.ctx.set('uiConversation', { seedImageUrl }) const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) @@ -684,7 +841,7 @@ describe('sendSession submission echo', () => { const attachments = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }), - ]) + ], INLINE_IMAGES) await vi.waitFor(() => { expect(b.root.fileUploads.getSnapshot()[attachments[1]!.id]?.status).toBe('ready') }) @@ -713,7 +870,7 @@ describe('sendSession submission echo', () => { try { const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) const session = b.runtime.sessions.binding('s1')!.session await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue')) .rejects.toThrow('read failed') @@ -796,12 +953,12 @@ describe('draft image dimension probe', () => { try { const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) expect(probed).toMatchObject({ width: 640, height: 480 }) vi.stubGlobal('Image', undefined) const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), - ]) + ], INLINE_IMAGES) expect(unprobed?.kind).toBe('image') if (unprobed?.kind !== 'image') throw new Error('image draft missing') expect(unprobed.width).toBeUndefined() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9a13d1d0a6..ab2f5b4a66 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: d2f5701274be5110 zh: d60e2617136a6f26 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: cb874a0ca53a6747 - zh: 31179193dd1d6e12 + en: 4091bc18d6eabedd + zh: c59f84589a5f692a /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: fff68b60f4abdc7e zh: 5cf5952ec78f7f91 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ecd3fd4e29..202ba0b9e3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -55,6 +55,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | +| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. | +| `useRowDragPreview` | Native drag preview using an inert offscreen row copy. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. | | `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. | | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | @@ -67,10 +69,12 @@ Check this table before writing a control in a feature package. A plugin cannot | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. | +| `useClipboardCopy` | Copies the current text with one-second success feedback, prevents overlapping or success-window repeats, and cancels its reset timer on unmount; late write completion cannot update an unmounted control. | +| `UserMessageBubble` | User-message background, radius, content inset, typography, and plain-text wrapping. Accepts native div props; callers own row alignment, width, content, and actions. | | `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. | | `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | -| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. The thinking glyph keeps an inset orbit within its 16px view box. `IconAppsAddOutlineRegular`/`Medium`, the More launcher glyph, take `compact` to draw their tiles closer together at card size. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. `IconDragHandleOutlineRegular`/`Medium` draw a three-line grip for reordering. The thinking glyph keeps an inset orbit within its 16px view box. `IconAppsAddOutlineRegular`/`Medium`, the More launcher glyph, take `compact` to draw their tiles closer together at card size. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium`, `PermissionIconAutoReviewRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, full-access, and auto-review choices. | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork; `Terminal` supplies the light-blue prompt in plugin cards and sidebar guide entries. `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `GuideArtworkBrowser`/`Files` | Fixed-palette 36×36 browser and folder artwork for sidebar guide entries. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3de121ab2c..0241739e2c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -55,6 +55,8 @@ kind: "package-library" | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | +| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 | +| `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 | | `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 | | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | @@ -67,10 +69,12 @@ kind: "package-library" | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 | +| `useClipboardCopy` | 复制当前文本并显示一秒成功反馈,阻止写入期间及成功反馈期间的重复操作,卸载时清除重置计时器;迟到的写入结果不会更新已卸载控件。 | +| `UserMessageBubble` | 用户消息的背景、圆角、内容内边距、字体与纯文本换行规则。接收原生 div props;行对齐、宽度、内容和操作由调用方拥有。 | | `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 | | `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | -| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。思考图标的轨道在 16px 视口内保留内边距。“更多”入口使用的 `IconAppsAddOutlineRegular`/`Medium` 接受 `compact`,在卡片尺寸下让方块更靠近。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`IconDragHandleOutlineRegular`/`Medium` 以三条横线表示可拖动的排序手柄。思考图标的轨道在 16px 视口内保留内边距。“更多”入口使用的 `IconAppsAddOutlineRegular`/`Medium` 接受 `compact`,在卡片尺寸下让方块更靠近。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium`、`PermissionIconAutoReviewRegular`/`Medium` | 只读、工作区写入、完全访问与自动审查选项使用的权限模式图形。 | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 固定配色的 36×36 插件插画;`Terminal` 为插件卡片和侧边栏开始页入口提供浅蓝色提示符。`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `GuideArtworkBrowser`/`Files` | 固定配色的 36×36 浏览器与文件夹插画,用于侧栏引导入口。 | diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css b/packages/client/ui-primitives/src/AnimatedRows.module.css similarity index 100% rename from packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css rename to packages/client/ui-primitives/src/AnimatedRows.module.css diff --git a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx b/packages/client/ui-primitives/src/AnimatedRows.tsx similarity index 92% rename from packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx rename to packages/client/ui-primitives/src/AnimatedRows.tsx index 12030ab6f2..fb4a8fac68 100644 --- a/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx +++ b/packages/client/ui-primitives/src/AnimatedRows.tsx @@ -1,14 +1,16 @@ -/** React-commit-driven movement and entry/exit fades for the sidebar's keyed rows. */ -import { Component, createRef, type ReactNode } from 'react' +/** React-commit-driven movement and entry/exit fades for keyed rows. */ +import { Component, createRef, type AriaRole, type ReactNode } from 'react' import css from './AnimatedRows.module.css' const ROW_FADE_MS = 100 const ROW_GLIDE_MS = 200 -interface AnimatedRowsProps { +/** Keyed row content, list semantics, and animation readiness. */ +export interface AnimatedRowsProps { children: ReactNode className: string - label: string + role?: AriaRole + label?: string /** Unique DOM-order keys matching the rendered data-row-key attributes. */ rowKeys: readonly string[] ready: boolean @@ -38,7 +40,7 @@ function intersects(row: DOMRect, viewport: DOMRect): boolean { } /** - * Animates keyed sidebar rows only when their rendered membership or order changes. + * Animates keyed rows only when their rendered membership or order changes. * Motion starts after the first pointer or keyboard input inside the mounted list. * The parent supplies a positioned container for the inert exit overlay. */ @@ -65,9 +67,7 @@ export class AnimatedRows extends Component { const clone = row.element.cloneNode(true) as HTMLElement clone.removeAttribute('data-row-key') clone.inert = true - clone.style.setProperty( - '--dsh-workspace-indent', getComputedStyle(row.element).getPropertyValue('--dsh-workspace-indent'), - ) + clone.style.paddingInlineStart = getComputedStyle(row.element).paddingInlineStart removed.set(key, { ...row, element: clone }) } return { positions, removed } @@ -170,7 +170,7 @@ export class AnimatedRows extends Component {
{ this.armed = true }} onKeyDownCapture={() => { this.armed = true }} diff --git a/packages/client/ui-primitives/src/RowDragPreview.module.css b/packages/client/ui-primitives/src/RowDragPreview.module.css new file mode 100644 index 0000000000..f16dfc1d07 --- /dev/null +++ b/packages/client/ui-primitives/src/RowDragPreview.module.css @@ -0,0 +1,12 @@ +.preview { + position: fixed; + top: 0; + pointer-events: none; +} + +.preview > :first-child { + box-sizing: border-box; + margin: 0; + width: 100%; + height: 100%; +} diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index c2328d6a9d..a8a02a651f 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -1,7 +1,7 @@ import { useCallback, useState, type ReactNode } from 'react' import clsx from 'clsx' import { headTailCap } from './head-tail-cap.ts' -import { useCopyFeedback } from './use-copy-feedback.ts' +import { useClipboardCopy } from './use-clipboard-copy.ts' import css from './SearchBlock.module.css' /** @@ -184,7 +184,7 @@ export function SearchBlock(props: SearchBlockProps) { const rows = toRows(props, collapsed) const shown = shownCount(props) const empty = rows.length === 0 - const { copied, onCopy } = useCopyFeedback(copyText(props)) + const { copied, onCopy } = useClipboardCopy(copyText(props)) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 19c5c3296e..15f5ed1781 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' import { parseAnsiLines, type AnsiLine } from './ansi.ts' import { headTailCap } from './head-tail-cap.ts' -import { useCopyFeedback } from './use-copy-feedback.ts' +import { useClipboardCopy } from './use-clipboard-copy.ts' import { Pill } from './Pill.tsx' import { StateDot, type StateDotState } from './StateDot.tsx' import css from './TerminalBlock.module.css' @@ -204,7 +204,7 @@ export function TerminalBlock({ const [expanded, setExpanded] = useState(false) // The raw output (or the caller's override), never the rendered tree: the // prompt line and the status pill are chrome the user did not run. - const { copied, onCopy } = useCopyFeedback(copyText ?? text) + const { copied, onCopy } = useClipboardCopy(copyText ?? text) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) diff --git a/packages/client/ui-primitives/src/UserMessageBubble.module.css b/packages/client/ui-primitives/src/UserMessageBubble.module.css new file mode 100644 index 0000000000..b004e22374 --- /dev/null +++ b/packages/client/ui-primitives/src/UserMessageBubble.module.css @@ -0,0 +1,11 @@ +.bubble { + max-width: 100%; + background: var(--dsw-specific-bubble); + border-radius: var(--dsw-radius-xl); + padding: 10px 16px; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(22px + var(--dsh-content-font-delta, 0px)); + color: var(--dsw-alias-label-primary); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/packages/client/ui-primitives/src/UserMessageBubble.tsx b/packages/client/ui-primitives/src/UserMessageBubble.tsx new file mode 100644 index 0000000000..dc9f89bf88 --- /dev/null +++ b/packages/client/ui-primitives/src/UserMessageBubble.tsx @@ -0,0 +1,16 @@ +/** Shared user-message background, typography, and content inset. */ +import type { ComponentPropsWithoutRef } from 'react' +import clsx from 'clsx' +import css from './UserMessageBubble.module.css' + +/** Native div attributes for user-authored message content. */ +export type UserMessageBubbleProps = ComponentPropsWithoutRef<'div'> + +/** + * Render user-style message content without row placement or actions. + * @param props - Native div attributes and the caller-owned content. + * @returns A theme-aware message surface that wraps its content. + */ +export function UserMessageBubble({ className, ...props }: UserMessageBubbleProps) { + return
+} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index cdfb4faf5e..1674d03b5c 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1571,6 +1571,23 @@ export const IconChevronsUpDownOutlineMedium = (props: IconProps) => ( ) +const IconDragHandleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconDragHandleOutline artwork. */ +export const IconDragHandleOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconDragHandleOutline artwork with a 1.3px stroke. */ +export const IconDragHandleOutlineMedium = (props: IconProps) => ( + +) + const IconArchiveOffOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => { // The mask carves whitespace along the slash out of the box strokes, so the // id must stay unique per rendered instance. diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 8b09ee19cb..c81c33b33e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -3,10 +3,15 @@ */ export type { CodeToolbarLabels } from './CodeToolbar.tsx' +export { UserMessageBubble } from './UserMessageBubble.tsx' +export type { UserMessageBubbleProps } from './UserMessageBubble.tsx' export { StateDot } from './StateDot.tsx' export type { StateDotState } from './StateDot.tsx' export { DisclosureRow } from './DisclosureRow.tsx' export type { DisclosureRowProps } from './DisclosureRow.tsx' +export { AnimatedRows } from './AnimatedRows.tsx' +export type { AnimatedRowsProps } from './AnimatedRows.tsx' +export { useRowDragPreview } from './useRowDragPreview.ts' export { TextShimmer } from './TextShimmer.tsx' export type { TextShimmerProps } from './TextShimmer.tsx' export { Button } from './Button.tsx' @@ -67,6 +72,8 @@ export { pointerModality } from './input-modality.ts' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' export { writeClipboard } from './clipboard.ts' +export { useClipboardCopy } from './use-clipboard-copy.ts' +export type { ClipboardCopyState } from './use-clipboard-copy.ts' export { SettingsForm } from './settings-form/SettingsForm.tsx' export type { SettingsFormLabels, SettingsFormProps } from './settings-form/SettingsForm.tsx' export { SettingsSecretField, SettingsValueField } from './settings-form/fields.tsx' diff --git a/packages/client/ui-primitives/src/use-clipboard-copy.ts b/packages/client/ui-primitives/src/use-clipboard-copy.ts new file mode 100644 index 0000000000..8dc2f8331d --- /dev/null +++ b/packages/client/ui-primitives/src/use-clipboard-copy.ts @@ -0,0 +1,44 @@ +/** Clipboard writes with component-owned success feedback and cleanup. */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { writeClipboard } from './clipboard.ts' + +/** Clipboard action and its temporary success state. */ +export interface ClipboardCopyState { + /** True for one second after an accepted clipboard write. */ + readonly copied: boolean + /** Write the current text unless a write or success indication is already active. */ + readonly onCopy: () => void +} + +/** + * Keep clipboard feedback local to the mounted control and release its reset timer on unmount. + * @param text - Exact text to copy when the action is invoked. + * @returns Success feedback and a callback stable while text and feedback are unchanged. + */ +export function useClipboardCopy(text: string): ClipboardCopyState { + const [copied, setCopied] = useState(false) + const copyPending = useRef(false) + const copyTimer = useRef | null>(null) + const copyEpoch = useRef(0) + useEffect(() => () => { + copyEpoch.current += 1 + copyPending.current = false + if (copyTimer.current !== null) clearTimeout(copyTimer.current) + }, []) + const onCopy = useCallback(() => { + if (copied || copyPending.current) return + const epoch = copyEpoch.current + copyPending.current = true + void writeClipboard(text).then((ok) => { + if (epoch !== copyEpoch.current) return + copyPending.current = false + if (!ok) return + setCopied(true) + copyTimer.current = window.setTimeout(() => { + copyTimer.current = null + setCopied(false) + }, 1000) + }) + }, [copied, text]) + return { copied, onCopy } +} diff --git a/packages/client/ui-primitives/src/use-copy-feedback.ts b/packages/client/ui-primitives/src/use-copy-feedback.ts deleted file mode 100644 index 73c8581206..0000000000 --- a/packages/client/ui-primitives/src/use-copy-feedback.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useCallback, useState } from 'react' -import { writeClipboard } from './clipboard.ts' - -/** How long the `copied` flag stays true after a successful write, in ms. */ -const COPIED_FEEDBACK_MS = 1000 - -/** The copy-feedback hook's return: the transient flag and the copy handler. */ -export interface CopyFeedback { - /** True for {@link COPIED_FEEDBACK_MS} after a successful write; render the success label off it. */ - copied: boolean - /** Copy the hook's text; no-op while `copied` is still true, silent on a refused write. */ - onCopy: () => void -} - -/** - * Copy `text` to the clipboard with one-second success feedback. - * @param text - the text to write on copy. - * @returns the `copied` flag and the `onCopy` handler. - */ -export function useCopyFeedback(text: string): CopyFeedback { - const [copied, setCopied] = useState(false) - const onCopy = useCallback(() => { - if (copied) return - void writeClipboard(text).then((ok) => { - if (!ok) return - setCopied(true) - window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS) - }) - }, [copied, text]) - return { copied, onCopy } -} diff --git a/packages/client/ui-workspace/src/client/rows/useRowDragPreview.ts b/packages/client/ui-primitives/src/useRowDragPreview.ts similarity index 76% rename from packages/client/ui-workspace/src/client/rows/useRowDragPreview.ts rename to packages/client/ui-primitives/src/useRowDragPreview.ts index bf84a9e1f4..f60e4bdc93 100644 --- a/packages/client/ui-workspace/src/client/rows/useRowDragPreview.ts +++ b/packages/client/ui-primitives/src/useRowDragPreview.ts @@ -1,18 +1,22 @@ -/** Offscreen snapshots for native Workspace and Session row dragging. */ +/** Offscreen row snapshots for native browser dragging. */ import { useEffect, useMemo, useRef } from 'react' import type { DragEvent } from 'react' -import css from './Rows.module.css' +import clsx from 'clsx' +import css from './RowDragPreview.module.css' /** * Own a row's inert drag preview until drag end, replacement, or unmount. * The offscreen copy keeps WebKit snapshots separate from overlapping panels. * Its cloned slot DOM remains in document.body for that lifetime; query live rows * within their list to exclude duplicate text and element IDs. + * @param className - preview background matching the row's surface; WebKit fills + * transparent snapshot pixels white, including rounded corners. * @returns preview callbacks; cleanup reports whether this row started a drag, * even when its preview failed or the row became non-draggable before drag end. + * Start accepts the source row when a child handle owns the drag event. */ -export function useRowDragPreview(): { - start: (event: DragEvent) => void +export function useRowDragPreview(className: string): { + start: (event: DragEvent, row?: HTMLElement) => void clear: () => boolean } { const current = useRef(null) @@ -27,10 +31,9 @@ export function useRowDragPreview(): { } return { clear, - start: (event: DragEvent): void => { + start: (event: DragEvent, row = event.currentTarget): void => { clear() dragging.current = true - const row = event.currentTarget const rect = row.getBoundingClientRect() const style = getComputedStyle(row) const copy = row.cloneNode(true) as HTMLElement @@ -40,15 +43,15 @@ export function useRowDragPreview(): { 'button, a[href], input, select, textarea, [tabindex], [contenteditable]', )) control.tabIndex = -1 copy.style.color = style.color + copy.style.paddingInlineStart = style.paddingInlineStart const image = row.ownerDocument.createElement('div') - image.className = css.dragPreview as string + image.className = clsx(css.preview, className) image.inert = true image.setAttribute('aria-hidden', 'true') Object.assign(image.style, { left: `${String(-rect.width)}px`, width: `${String(rect.width)}px`, height: `${String(rect.height)}px`, font: style.font, }) - image.style.setProperty('--dsh-workspace-indent', style.getPropertyValue('--dsh-workspace-indent')) image.append(copy) row.ownerDocument.body.append(image) current.current = image @@ -61,7 +64,7 @@ export function useRowDragPreview(): { } }, } - }, []) + }, [className]) useEffect(() => () => { preview.clear() }, [preview]) return preview } diff --git a/packages/client/ui-primitives/tests/UserMessageBubble.client.spec.tsx b/packages/client/ui-primitives/tests/UserMessageBubble.client.spec.tsx new file mode 100644 index 0000000000..496dec28b3 --- /dev/null +++ b/packages/client/ui-primitives/tests/UserMessageBubble.client.spec.tsx @@ -0,0 +1,24 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { UserMessageBubble } from '../src/UserMessageBubble.tsx' + +afterEach(cleanup) + +describe('UserMessageBubble', () => { + it('preserves caller content, attributes, and layout class', () => { + render( +
Which color?
Blue
+
) + const surface = screen.getByRole('group', { name: 'answer' }) + expect(surface.classList.contains('owner-layout')).toBe(true) + expect(screen.getByText('Which color?').tagName).toBe('DT') + expect(screen.getByText('Blue').tagName).toBe('DD') + }) + + it('renders a plain surface without additional interaction', () => { + render(Plain user text) + expect(screen.getByText('Plain user text').tagName).toBe('DIV') + expect(screen.queryByRole('button')).toBeNull() + }) +}) diff --git a/packages/client/ui-workspace/tests/animated-rows.client.spec.tsx b/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx similarity index 91% rename from packages/client/ui-workspace/tests/animated-rows.client.spec.tsx rename to packages/client/ui-primitives/tests/animated-rows.client.spec.tsx index 27bd79454a..fe46cb5a2b 100644 --- a/packages/client/ui-workspace/tests/animated-rows.client.spec.tsx +++ b/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest' import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { AnimatedRows } from '../src/client/rows/AnimatedRows.tsx' +import { AnimatedRows } from '../src/AnimatedRows.tsx' interface RecordedAnimation { element: HTMLElement @@ -24,7 +24,8 @@ beforeEach(() => { configurable: true, value(this: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimationOptions): Animation { const cancel = vi.fn<() => void>() - const animation = { onfinish: null, cancel } as unknown as Animation + const methods: Pick = { onfinish: null, cancel } + const animation = methods as Animation animations.push({ element: this, keyframes, options, animation, cancel }) return animation }, @@ -49,6 +50,7 @@ function rows(keys: readonly string[], options: { ready?: boolean; resetKey?: st return
{ expect(leaving.element.isConnected).toBe(false) }) + it('fades the last row when the list becomes empty', () => { + const view = render(rows(['a'])) + arm() + view.rerender(rows([])) + expect(animations).toHaveLength(1) + expect(animations[0]?.options.duration).toBe(100) + expect(animations[0]?.element.inert).toBe(true) + finish(animations[0]!.animation) + expect(animations[0]?.element.isConnected).toBe(false) + }) + it('does not measure content-only updates or scrolling, or interrupt their ongoing movement', () => { const view = render(rows(['a', 'b'])) const tree = arm() diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index b79e3d4856..97bc3cc02c 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -17,8 +17,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('product icon set', () => { - it('exports regular and medium variants for all 95 public glyphs', () => { - expect(iconNames.length).toBe(190) + it('exports regular and medium variants for all 96 public glyphs', () => { + expect(iconNames.length).toBe(192) expect(iconNames.some(name => /\d+$/.test(name))).toBe(false) const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length)) const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length)) diff --git a/packages/client/ui-primitives/tests/row-drag-preview.client.spec.tsx b/packages/client/ui-primitives/tests/row-drag-preview.client.spec.tsx new file mode 100644 index 0000000000..4e2c19e0e6 --- /dev/null +++ b/packages/client/ui-primitives/tests/row-drag-preview.client.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +/** Native row previews keep cloned controls inert and release their DOM. */ +import { cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useRowDragPreview } from '../src/useRowDragPreview.ts' + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +function Row({ handle }: { handle: boolean }) { + const preview = useRowDragPreview('surface') + return
+ + +
+} + +function fixture(handle: boolean) { + const view = render() + const button = screen.getByRole('button') + const row = button.parentElement as HTMLElement + vi.spyOn(row, 'getBoundingClientRect').mockReturnValue(new DOMRect(5, 10, 200, 40)) + const source = handle ? button : row + const dataTransfer = { setDragImage: vi.fn<(element: HTMLElement, x: number, y: number) => void>() } + const start = () => { + const event = createEvent.dragStart(source, { dataTransfer }) + Object.defineProperties(event, { clientX: { value: 20 }, clientY: { value: 30 } }) + fireEvent(source, event) + return dataTransfer.setDragImage.mock.calls.at(-1)![0] + } + return { ...view, row, source, dataTransfer, start } +} + +describe('row drag preview', () => { + it.each([false, true])('copies the whole row with handle=%s and removes replaced, ended, or unmounted previews', (handle) => { + const { row, source, dataTransfer, start, unmount } = fixture(handle) + const originalStyle = row.getAttribute('style') + const first = start() + expect(dataTransfer.setDragImage).toHaveBeenLastCalledWith(first, 15, 20) + expect(first.inert).toBe(true) + expect(first.getAttribute('aria-hidden')).toBe('true') + expect(first.className).toContain('surface') + expect(first.style.width).toBe('200px') + expect(first.style.height).toBe('40px') + expect(first.style.left).toBe('-200px') + expect(first.querySelector('[data-row-key]')).toBeNull() + expect((first.firstElementChild as HTMLElement).style.paddingInlineStart).toBe('32px') + expect(first.querySelector('input')?.value).toBe('Draft') + expect([...first.querySelectorAll('button, input')].every(control => (control as HTMLElement).tabIndex === -1)).toBe(true) + expect(row.getAttribute('style')).toBe(originalStyle) + const second = start() + expect(first.isConnected).toBe(false) + fireEvent.dragEnd(source) + expect(second.isConnected).toBe(false) + const third = start() + unmount() + expect(third.isConnected).toBe(false) + }) + + it('removes a rejected custom preview while allowing native drag cleanup', () => { + const { source, dataTransfer, start } = fixture(true) + dataTransfer.setDragImage.mockImplementation(() => { throw new DOMException('Unavailable') }) + const image = start() + expect(image.isConnected).toBe(false) + fireEvent.dragEnd(source) + }) +}) diff --git a/packages/client/ui-primitives/tests/useClipboardCopy.client.spec.tsx b/packages/client/ui-primitives/tests/useClipboardCopy.client.spec.tsx new file mode 100644 index 0000000000..470ca1342e --- /dev/null +++ b/packages/client/ui-primitives/tests/useClipboardCopy.client.spec.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useClipboardCopy } from '../src/use-clipboard-copy.ts' + +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') +const writeText = vi.fn<(text: string) => Promise>() +beforeEach(() => { + vi.useFakeTimers() + writeText.mockReset().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + if (originalClipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', originalClipboard) +}) + +/** Complete the clipboard promise and its success feedback callback. */ +async function settleCopy(): Promise { + await act(async () => { await Promise.resolve(); await Promise.resolve() }) +} + +describe('useClipboardCopy', () => { + it('copies current text, blocks success-window repeats, and resets after one second', async () => { + const { result, rerender } = renderHook(({ text }) => useClipboardCopy(text), { initialProps: { text: 'first' } }) + const callback = result.current.onCopy + rerender({ text: 'first' }) + expect(result.current.onCopy).toBe(callback) + act(() => { result.current.onCopy() }) + expect(writeText).toHaveBeenCalledWith('first') + await settleCopy() + expect(result.current.copied).toBe(true) + act(() => { result.current.onCopy() }) + expect(writeText).toHaveBeenCalledTimes(1) + act(() => { vi.advanceTimersByTime(1000) }) + expect(result.current.copied).toBe(false) + expect(vi.getTimerCount()).toBe(0) + rerender({ text: 'second' }) + act(() => { result.current.onCopy() }) + expect(writeText).toHaveBeenLastCalledWith('second') + await settleCopy() + }) + + it('blocks overlapping writes and ignores completion after unmount', async () => { + let finish: (() => void) | undefined + writeText.mockImplementation(() => new Promise((resolve) => { finish = resolve })) + const { result, unmount } = renderHook(() => useClipboardCopy('pending')) + act(() => { result.current.onCopy(); result.current.onCopy() }) + expect(writeText).toHaveBeenCalledTimes(1) + expect(result.current.copied).toBe(false) + unmount() + finish!() + await settleCopy() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps failure unconfirmed and allows another attempt', async () => { + writeText.mockRejectedValueOnce(new Error('denied')) + const { result } = renderHook(() => useClipboardCopy('retry')) + act(() => { result.current.onCopy() }) + await settleCopy() + expect(result.current.copied).toBe(false) + expect(vi.getTimerCount()).toBe(0) + act(() => { result.current.onCopy() }) + await settleCopy() + expect(writeText).toHaveBeenCalledTimes(2) + expect(result.current.copied).toBe(true) + }) + + it('releases its success reset timer on unmount', async () => { + const { result, unmount } = renderHook(() => useClipboardCopy('done')) + act(() => { result.current.onCopy() }) + await settleCopy() + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/packages/client/ui-settings-general/tests/config.host.spec.ts b/packages/client/ui-settings-general/tests/config.host.spec.ts index 7bc4864b1b..cb999a2d94 100644 --- a/packages/client/ui-settings-general/tests/config.host.spec.ts +++ b/packages/client/ui-settings-general/tests/config.host.spec.ts @@ -11,7 +11,7 @@ it('validates and updates onboarding preferences without remounting', async () = onTestFinished(() => ctx.fiber.dispose()) const configuration = await liveConfig(ctx, General) await configuration.update({ welcomeNoticeVersion: 'v1' }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ welcomeNoticeVersion: 'v1' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ welcomeNoticeVersion: 'v1' }) expect(configuration.entry.fiber).toBe(configuration.fiber) }) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index fac034b195..53f1ab1e77 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 2ab40f99bd12dc17 - zh: 2b5fd79885cb85fb + en: a4ac56de00a7e59b + zh: 9b3f914dc43393c7 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 016dd7efd1..0a65e5a7d2 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -57,6 +57,8 @@ The collapsed 自定义设置 fold carries the curated extras: `baseURL` for bot The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. +Drag a model’s reorder handle to change its position within the provider, or focus the handle and use the up and down arrow keys. Apply saves the order; Cancel discards it. Each model keeps its settings, expanded options, and unfinished capacity text when moved. The full-row drag preview and reduced-motion-aware row animations share primitives with workspace reordering; before/after insertion markers follow the same style. + Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 3937cc80d4..a99154c6db 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -57,6 +57,8 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 +拖动模型的排序手柄可调整它在当前提供商内的位置,也可以聚焦手柄后使用上下方向键。保存会保留顺序,取消会放弃修改。移动时,每个模型的参数、已展开选项和未完成的容量输入都会随模型保留。整行拖拽预览及遵循减少动态效果设置的行动画,与工作区排序共用基础组件;前后插入标记沿用相同样式。 + 展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 diff --git a/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx index 2cbbdf42fc..d2b98d894e 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekModelsEditor.tsx @@ -5,11 +5,12 @@ * override; reset removes that override instead of copying defaults into it. */ -import { useState } from 'react' import type { ReactNode } from 'react' -import { IconPlusOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { AnimatedRows, IconPlusOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { en } from './locales.ts' import { ModelRow } from './ModelRow.tsx' +import { useModelRows } from './useModelRows.ts' +import type { CapacityField } from './useModelRows.ts' import styles from './ModelsSection.module.css' /** One catalog entry kept structurally open so hidden or future fields survive an edit. */ @@ -18,14 +19,6 @@ export type DeepSeekModelDraft = Record /** The catalog fields this editor writes. */ type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens' -/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */ -type CapacityField = 'contextWindow' | 'maxTokens' - -/** Row index encoded in an editing-buffer key. */ -function rowOf(key: string): number { - return Number(key.slice(0, key.indexOf(':'))) -} - /** Accepted capacity spellings: a decimal count with an optional K/M suffix. */ const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i @@ -148,19 +141,7 @@ export interface DeepSeekModelsEditorProps { * @returns the catalog editor. */ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode { - // Capacities are edited as text, so a field's keystrokes are held here - // rather than re-derived from the parsed count on every change, which would - // rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the - // save-time rejection names a row the user can still see — which is why - // this is one entry PER FIELD: a single active buffer would be displaced by - // editing any other field, and the abandoned one would fall back to - // rendering its stored NaN as the literal `NaN`. - // - // Keys carry the row index, so the two operations that move indexes maintain - // them: `remove` re-keys around the dropped row, and reset clears them all - // because the rows they annotated are gone. - const [editing, setEditing] = useState>(() => new Map()) - const [expanded, setExpanded] = useState>(() => new Set()) + const rowState = useModelRows(props.models, props.onChange, props.disabled) const update = (index: number, key: CatalogField, value: unknown): void => { const next = props.models.map((model, at) => { @@ -174,62 +155,31 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod } const remove = (index: number): void => { - setEditing((current) => { - const next = new Map() - for (const [key, text] of current) { - const at = rowOf(key) - if (at === index) continue - // Only the row number moves; the field half of the key is untouched. - next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, text) - } - return next - }) - setExpanded((current) => { - const next = new Set() - for (const at of current) { - if (at === index) continue - next.add(at > index ? at - 1 : at) - } - return next - }) + rowState.forget(new Set([index])) props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model }))) } const reset = (): void => { - setEditing(new Map()) - setExpanded(new Set()) + rowState.reset() props.onReset() } - const toggle = (index: number): void => { - setExpanded((current) => { - const next = new Set(current) - if (!next.delete(index)) next.add(index) - return next - }) - } - /** The field's text: its live keystrokes, else the stored count spelled short. */ const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => { - const typed = editing.get(`${String(index)}:${field}`) + const typed = rowState.rows[index]?.editing.get(field) if (typed !== undefined) return typed const value = model[field] return typeof value === 'number' ? formatCapacity(value) : '' } const settleCapacity = (index: number, field: CapacityField): void => { - const key = `${String(index)}:${field}` - const typed = editing.get(key) + const typed = rowState.rows[index]?.editing.get(field) if (typed === undefined) return // Unreadable text stays on screen: the save-time rejection names a row the // user can still see and correct. const parsed = parseCapacity(typed) if (parsed !== undefined && Number.isNaN(parsed)) return - setEditing((current) => { - const next = new Map(current) - next.delete(key) - return next - }) + rowState.edit(index, field, undefined) } const capacityInput = (model: DeepSeekModelDraft, index: number, field: CapacityField, fallback: number | undefined) => ({ @@ -238,7 +188,7 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod ? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder') : formatCapacity(fallback), onChange: (text: string) => { - setEditing(current => new Map(current).set(`${String(index)}:${field}`, text)) + rowState.edit(index, field, text) update(index, field, parseCapacity(text)) }, onBlur: () => { settleCapacity(index, field) }, @@ -266,33 +216,32 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod ) : null}
- {props.models.length === 0 - ?

{props.t('modelsEmpty')}

- : ( -
- {props.models.map((model, index) => ( - { update(index, field, value) }} - onIdBlur={(value) => { - const trimmed = value.trim() - if (trimmed !== value) update(index, 'id', trimmed) - }} - onChange={(next) => { props.onChange(props.models.map((row, at) => at === index ? next : row)) }} - onToggle={() => { toggle(index) }} - onRemove={() => { remove(index) }} - /> - ))} -
- )} + String(row.key))} ready resetKey=""> + {props.models.length === 0 ?

{props.t('modelsEmpty')}

: null} + {rowState.rows.map(({ model, key, expanded, order }, index) => ( + { update(index, field, value) }} + onIdBlur={(value) => { + const trimmed = value.trim() + if (trimmed !== value) update(index, 'id', trimmed) + }} + onChange={(next) => { props.onChange(props.models.map((row, at) => at === index ? next : row)) }} + onToggle={() => { rowState.toggle(index) }} + onRemove={() => { remove(index) }} + /> + ))} +
@@ -392,17 +344,19 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
{models.length === 0 ?

{t('modelsEmpty')}

: null} -
- {models.map((model, index) => ( + String(row.key))} ready resetKey=""> + {rowState.rows.map(({ model, key, expanded, order }, index) => ( { patch(index, { [field]: value }) }} onChange={(next) => { onChange(models.map((row, at) => at === index ? next : row)) }} - onToggle={() => { toggleExpanded(index) }} + onToggle={() => { rowState.toggle(index) }} onRemove={() => { onChange(models.filter((_model, at) => at !== index)) - forgetRows(new Set([index])) + rowState.forget(new Set([index])) }} /> ))} -
+ + {(['id', 'name'] as const).map(field => ( +} + +/** Drag state and actions for one model row within its editor. */ +export interface ModelRowOrder { + dragging: boolean + target: 'before' | 'after' | undefined + canDrop: boolean + onStart: () => void + onEnd: () => void + onOver: (half: 'before' | 'after') => void + onLeave: () => void + onDrop: (half: 'before' | 'after') => void + onMove: (offset: -1 | 1) => void +} + +interface ModelRows { + rows: (RowState & { model: DeepSeekModelDraft; order: ModelRowOrder })[] + toggle: (index: number) => void + edit: (index: number, field: CapacityField, text: string | undefined) => void + forget: (removed: ReadonlySet) => void + reset: () => void +} + +/** One editor-local identity; editing a model ID never replaces its controls. */ +function createRow(key: number): RowState { + return { key, expanded: false, editing: new Map() } +} + +/** + * Keep row state with its model through draft moves and removals. + * @param models - current draft array, owned by the provider card. + * @param onChange - replaces only that card's draft array. + * @param disabled - prevents reordering while the card cannot be edited. + * @returns row state and local editing and ordering actions. + */ +export function useModelRows( + models: readonly DeepSeekModelDraft[], onChange: (models: DeepSeekModelDraft[]) => void, disabled: boolean, +): ModelRows { + const nextKey = useRef(0) + const [stored, setRows] = useState(() => models.map(() => createRow(nextKey.current++))) + const [dragged, setDragged] = useState() + const [target, setTarget] = useState<{ key: number; half: 'before' | 'after' }>() + const entries = models.map((model, index) => ({ model, state: stored[index] ?? createRow(nextKey.current++) })) + const rows = entries.map(entry => entry.state) + if (stored.length !== models.length) setRows(rows) + + const endDrag = (): void => { setDragged(undefined); setTarget(undefined) } + const move = (from: number, to: number): void => { + if (disabled || from === to || from < 0 || to < 0 || to >= models.length) return + const order = [...entries] + const moved = order.splice(from, 1) + order.splice(to, 0, ...moved) + setRows(order.map(entry => entry.state)) + onChange(order.map(entry => entry.model)) + } + + return { + rows: entries.map(({ model, state }, index) => ({ + ...state, + model, + order: { + dragging: dragged === state.key, + target: target?.key === state.key ? target.half : undefined, + canDrop: !disabled && dragged !== undefined, + onStart: (): void => { setDragged(state.key) }, + onEnd: endDrag, + onOver: (half): void => { + setTarget(current => current?.key === state.key && current.half === half ? current : { key: state.key, half }) + }, + onLeave: (): void => { setTarget(current => current?.key === state.key ? undefined : current) }, + onDrop: (half): void => { + const from = rows.findIndex(row => row.key === dragged) + const insertion = index + (half === 'after' ? 1 : 0) + move(from, insertion > from ? insertion - 1 : insertion) + endDrag() + }, + onMove: (offset: -1 | 1): void => { move(index, index + offset) }, + }, + })), + toggle: (index: number): void => { + setRows(rows.map((row, at) => at === index ? { ...row, expanded: !row.expanded } : row)) + }, + edit: (index: number, field: CapacityField, text: string | undefined): void => { + setRows(rows.map((row, at) => { + if (at !== index) return row + const editing = new Map(row.editing) + if (text === undefined) editing.delete(field) + else editing.set(field, text) + return { ...row, editing } + })) + }, + forget: (removed: ReadonlySet): void => { + setRows(rows.filter((_row, index) => !removed.has(index))) + endDrag() + }, + reset: (): void => { setRows(models.map(() => createRow(nextKey.current++))); endDrag() }, + } +} diff --git a/packages/client/ui-settings-models/tests/model-order.client.spec.tsx b/packages/client/ui-settings-models/tests/model-order.client.spec.tsx new file mode 100644 index 0000000000..3f2bceb6fe --- /dev/null +++ b/packages/client/ui-settings-models/tests/model-order.client.spec.tsx @@ -0,0 +1,267 @@ +// @vitest-environment jsdom +/** Provider-local ordering preserves row instances and unfinished model edits. */ +import { useState } from 'react' +import { cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DeepSeekModelsEditor } from '../src/client/DeepSeekModelsEditor.tsx' +import type { DeepSeekModelDraft } from '../src/client/DeepSeekModelsEditor.tsx' +import { ModelListEditor } from '../src/client/ModelListEditor.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +function Editor(props: { + family: 'deepseek' | 'pi-ai' + initial: DeepSeekModelDraft[] + changed: (models: DeepSeekModelDraft[]) => void + disabled?: boolean +}) { + const [models, setModels] = useState(props.initial) + const shared = { + models, disabled: props.disabled === true, overridden: true, + t: (key: keyof typeof en) => en[key], + onChange: (next: DeepSeekModelDraft[]): void => { props.changed(next); setModels(next) }, + onReset: (): void => { setModels(props.initial) }, + } + return props.family === 'deepseek' + ? + : {}} + operations={{ discoverModels: vi.fn(), storeCredential: vi.fn(), removeCredential: vi.fn(), writeSettings: vi.fn() }} + /> +} + +function handle(position: number): HTMLButtonElement { + return screen.getByRole('button', { name: `${en.reorderModel} ${String(position)}` }) +} + +function row(button: HTMLButtonElement): HTMLElement { + const entry = button.closest('[data-row-key]') + if (!(entry instanceof HTMLElement)) throw new Error('Model row is missing') + return entry +} + +function transfer() { + return { + setData: vi.fn(), effectAllowed: '', dropEffect: '', + setDragImage: vi.fn<(image: HTMLElement, x: number, y: number) => void>(), + } +} + +/** jsdom's DragEvent fallback drops pointer coordinates from event options. */ +function fireDrag(target: HTMLElement, kind: 'dragOver' | 'drop', clientY: number, dataTransfer: ReturnType) { + const event = kind === 'dragOver' ? createEvent.dragOver(target) : createEvent.drop(target) + Object.defineProperties(event, { clientY: { value: clientY }, dataTransfer: { value: dataTransfer } }) + fireEvent(target, event) +} + +function leave(target: HTMLElement, relatedTarget: Node | null = null) { + const event = createEvent.dragLeave(target) + Object.defineProperty(event, 'relatedTarget', { value: relatedTarget }) + fireEvent(target, event) +} + +describe.each(['deepseek', 'pi-ai'] as const)('%s model ordering', (family) => { + it('moves a complete model and its expanded capacity draft to the drop position', () => { + const first = { id: 'first', name: 'First', contextWindow: 4096, input: ['image'], hidden: { preserved: true } } + const second = { id: 'second', maxTokens: 1024 } + const third = { id: 'third' } + const changed = vi.fn() + render() + const identity = screen.getByLabelText(`${en.modelId} 1`) + const source = handle(1) + const destination = row(handle(3)) + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: 'unfinished' } }) + fireEvent.change(screen.getByLabelText(`${en.maxTokens} 1`), { target: { value: '2000' } }) + changed.mockClear() + + const dataTransfer = transfer() + fireEvent.dragStart(source, { dataTransfer }) + expect(dataTransfer.setData).toHaveBeenCalledExactlyOnceWith('application/x-dsh-model-order', '1') + fireEvent.dragOver(destination, { dataTransfer }) + expect(changed).not.toHaveBeenCalled() + fireEvent.drop(destination, { dataTransfer }) + fireEvent.dragEnd(source) + + expect(changed).toHaveBeenCalledExactlyOnceWith([ + second, third, { ...first, contextWindow: Number.NaN, maxTokens: 2000 }, + ]) + expect(screen.getByLabelText(`${en.modelId} 3`)).toBe(identity) + expect(screen.getByLabelText(`${en.contextWindow} 3`).value).toBe('unfinished') + expect(screen.getByLabelText(`${en.maxTokens} 3`).value).toBe('2000') + expect(screen.queryByLabelText(`${en.contextWindow} 1`)).toBeNull() + expect(first.contextWindow).toBe(4096) + }) + + it('moves with arrow keys, retains focus, and ignores moves past either end', () => { + const changed = vi.fn() + render() + const source = handle(1) + expect(source.getAttribute('aria-description')).toBe(en.reorderModelHelp) + source.focus() + fireEvent.keyDown(source, { key: 'ArrowUp' }) + fireEvent.keyDown(source, { key: 'Enter' }) + expect(changed).not.toHaveBeenCalled() + fireEvent.keyDown(source, { key: 'ArrowDown' }) + expect(handle(2)).toBe(source) + expect(document.activeElement).toBe(source) + fireEvent.keyDown(source, { key: 'ArrowDown' }) + expect(changed).toHaveBeenCalledExactlyOnceWith([{ id: 'second' }, { id: 'first' }]) + fireEvent.keyDown(source, { key: 'ArrowUp' }) + expect(changed).toHaveBeenLastCalledWith([{ id: 'first' }, { id: 'second' }]) + }) + + it('marks and inserts before or after a row, ignoring adjacent insertions that keep the order', () => { + const changed = vi.fn() + render() + const source = handle(3) + const destination = row(handle(1)) + vi.spyOn(destination, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 40)) + const dataTransfer = transfer() + fireEvent.dragStart(source, { dataTransfer }) + fireDrag(destination, 'dragOver', 110, dataTransfer) + expect(destination.dataset.dropTarget).toBe('before') + fireDrag(destination, 'dragOver', 130, dataTransfer) + expect(destination.dataset.dropTarget).toBe('after') + fireDrag(destination, 'dragOver', 130, dataTransfer) + fireDrag(destination, 'drop', 110, dataTransfer) + fireEvent.dragEnd(source) + expect(changed).toHaveBeenCalledExactlyOnceWith([{ id: 'third' }, { id: 'first' }, { id: 'second' }]) + expect(destination.dataset.dropTarget).toBeUndefined() + changed.mockClear() + + const adjacent = row(handle(2)) + vi.spyOn(adjacent, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 100, 200, 40)) + fireEvent.dragStart(source, { dataTransfer }) + fireDrag(adjacent, 'drop', 110, dataTransfer) + fireEvent.dragEnd(source) + fireEvent.dragStart(handle(2), { dataTransfer }) + fireDrag(row(source), 'drop', 0, dataTransfer) + fireEvent.dragEnd(handle(2)) + expect(changed).not.toHaveBeenCalled() + }) + + it('previews the whole expanded row from its handle and cleans it up at drag end or unmount', () => { + const view = render() + const source = handle(1) + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + const dataTransfer = transfer() + fireEvent.dragStart(source, { dataTransfer }) + const preview = dataTransfer.setDragImage.mock.calls[0]![0] + expect(preview.inert).toBe(true) + expect(preview.getAttribute('aria-hidden')).toBe('true') + expect(preview.querySelector('[data-row-key]')).toBeNull() + expect(within(preview).getByLabelText(`${en.modelId} 1`).value).toBe('first') + expect(within(preview).getByLabelText(`${en.contextWindow} 1`).tabIndex).toBe(-1) + expect(row(source).draggable).toBe(false) + fireEvent.dragEnd(source) + expect(preview.isConnected).toBe(false) + fireEvent.dragEnd(source) + fireEvent.dragStart(source, { dataTransfer }) + const nextPreview = dataTransfer.setDragImage.mock.calls[1]![0] + view.unmount() + expect(nextPreview.isConnected).toBe(false) + }) + + it('keeps canceled, same-row, foreign, and disabled drops from changing the draft', () => { + const changed = vi.fn() + const initial = [{ id: 'first' }, { id: 'second' }] + const view = render() + const source = handle(1) + const destination = row(handle(2)) + const dataTransfer = transfer() + fireEvent.drop(destination, { dataTransfer }) + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.drop(row(source), { dataTransfer }) + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.dragOver(destination, { dataTransfer }) + fireEvent.dragEnd(source) + fireEvent.drop(destination, { dataTransfer }) + expect(changed).not.toHaveBeenCalled() + + view.rerender() + expect(source.disabled).toBe(true) + expect(source.draggable).toBe(false) + fireEvent.keyDown(source, { key: 'ArrowDown' }) + fireEvent.drop(destination, { dataTransfer }) + expect(changed).not.toHaveBeenCalled() + }) + + it('clears an outside insertion marker without clearing another row or an internal transition', () => { + const changed = vi.fn() + render() + const source = handle(1) + const second = row(handle(2)) + const third = row(handle(3)) + const dataTransfer = transfer() + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.dragOver(second, { dataTransfer }) + leave(second, second.querySelector('input')) + expect(second.dataset.dropTarget).toBe('after') + fireEvent.dragOver(third, { dataTransfer }) + leave(second, third) + expect(third.dataset.dropTarget).toBe('after') + leave(third, document.body) + expect(third.dataset.dropTarget).toBeUndefined() + fireEvent.dragOver(second, { dataTransfer }) + leave(second) + expect(second.dataset.dropTarget).toBeUndefined() + fireEvent.dragEnd(source) + expect(changed).not.toHaveBeenCalled() + }) + + it('keeps the animated list mounted when its last row is removed', () => { + render() + const list = row(handle(1)).parentElement! + fireEvent.click(screen.getByRole('button', { name: `${en.removeModel} 1` })) + expect(list.isConnected).toBe(true) + expect(screen.getByText(en.modelsEmpty)).toBeDefined() + }) + + it('keeps blank rows distinct through editing, moves, removal, and addition', () => { + render() + const first = screen.getByLabelText(`${en.modelId} 1`) + const second = screen.getByLabelText(`${en.modelId} 2`) + first.focus() + fireEvent.change(first, { target: { value: 'renamed' } }) + expect(document.activeElement).toBe(first) + fireEvent.keyDown(handle(1), { key: 'ArrowDown' }) + expect(screen.getByLabelText(`${en.modelId} 2`)).toBe(first) + expect(screen.getByLabelText(`${en.modelId} 1`)).toBe(second) + fireEvent.click(screen.getByRole('button', { name: `${en.removeModel} 1` })) + expect(screen.getByLabelText(`${en.modelId} 1`)).toBe(first) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + expect(screen.getByLabelText(`${en.modelId} 2`)).not.toBe(second) + fireEvent.keyDown(handle(2), { key: 'ArrowUp' }) + expect(screen.getByLabelText(`${en.modelId} 2`)).toBe(first) + }) + + it('restores inherited order and clears row drafts when defaults are restored', () => { + render() + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: 'bad' } }) + fireEvent.keyDown(handle(1), { key: 'ArrowDown' }) + fireEvent.click(screen.getByRole('button', { name: en.resetModels })) + expect(screen.getByLabelText(`${en.modelId} 1`).value).toBe('first') + expect(screen.queryByLabelText(`${en.contextWindow} 1`)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: `${en.modelAdvanced} 1` })) + expect(screen.getByLabelText(`${en.contextWindow} 1`).value).toBe('') + }) +}) + +it('does not accept another provider editor\'s dragged model', () => { + const firstChanged = vi.fn() + const secondChanged = vi.fn() + const first = render() + const second = render() + const source = within(first.container).getByRole('button', { name: `${en.reorderModel} 1` }) + const destination = within(second.container).getByRole('button', { name: `${en.reorderModel} 2` }) + const dataTransfer = transfer() + fireEvent.dragStart(source, { dataTransfer }) + fireEvent.dragOver(row(destination), { dataTransfer }) + fireEvent.drop(row(destination), { dataTransfer }) + fireEvent.dragEnd(source) + expect(firstChanged).not.toHaveBeenCalled() + expect(secondChanged).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-settings/tests/config.host.spec.ts b/packages/client/ui-settings/tests/config.host.spec.ts index 558e6350c1..551f313633 100644 --- a/packages/client/ui-settings/tests/config.host.spec.ts +++ b/packages/client/ui-settings/tests/config.host.spec.ts @@ -9,9 +9,9 @@ it('defaults on, persists valid choices and removes its schema on disposal', asy const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) onTestFinished(() => ctx.fiber.dispose()) - expect(plainConfig(configuration.fiber.config)).toEqual({ enabled: true }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ enabled: true }) await configuration.update({ enabled: false }) - expect(plainConfig(configuration.fiber.config)).toEqual({ enabled: false }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ enabled: false }) await expect(configuration.update({ enabled: 'yes' })).rejects.toThrow() await configuration.fiber.dispose() }) diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index 0de2aac907..0dfd061883 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -30,11 +30,11 @@ describe('ui-theme host', () => { const ctx = new Context() const configuration = await liveConfig(ctx, { Config, apply }) const { fiber } = configuration - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14, codeFontSize: 11, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '', terminalFontFamily: '' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14, codeFontSize: 11, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '', terminalFontFamily: '' }) await configuration.update({ preference: 'dark', fontSize: 10, codeFontSize: 16, codeFontFamily: '"Iosevka"' }) - expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 10, codeFontSize: 16, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '"Iosevka"', terminalFontFamily: '' }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toEqual({ preference: 'dark', fontSize: 10, codeFontSize: 16, terminalFontSize: 13, textFontFamily: '', codeFontFamily: '"Iosevka"', terminalFontFamily: '' }) await configuration.update({ fontSize: 22 }) - expect(plainConfig(configuration.fiber.config)).toMatchObject({ preference: 'dark', fontSize: 22 }) + expect(plainConfig(configuration.fiber.effectiveConfig)).toMatchObject({ preference: 'dark', fontSize: 22 }) await expect(configuration.update({ preference: 'sepia' })).rejects.toThrow() await expect(configuration.update({ fontSize: 9 })).rejects.toThrow() await expect(configuration.update({ fontSize: 23 })).rejects.toThrow() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index a307a1361c..ae89cb24b4 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -21,14 +21,14 @@ en: 8900fbf9cbd72083 zh: 67a951d58a029a58 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: - en: 10975f68ff9c6fcc - zh: 92246aadc363f0eb + en: d51f61aec8981c28 + zh: 9973db6ce3fe6b72 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/the-plan-review-card: en: 6b12f1362c614a39 zh: 27d9cf54d4cbd5d2 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery: - en: 3ad0e0fd224aabfc - zh: db15f400e6f2bfd7 + en: d4f1dee7e7ff1d15 + zh: 6fb56ad50ca6851c /deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation: en: 77e1b2585acdf67b zh: d433ec185c6b8f57 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index a637ac1251..205b8732b4 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -35,7 +35,7 @@ Fixed approval questions use the same pending-question and answer channel. A fea ### Reading a settled question back -Each late reply is shown once, including when it opens a new Turn. Replies follow normal Chat process grouping and whole-Turn folding. Expand the containing Turn and work group to read a reply that has been folded away. +Each answered batch appears once in a right-aligned user-style bubble after its answer is recorded, for native and PTC blocking questions and late replies. The bubble shows every question above its selected choices and custom answer, uses lighter question text, and stays visible when process groups or the containing Turn are folded. A blocking-question answer bubble does not prevent completed work steps from collapsing. Late replies admitted after the opening step are interleaved Turn inputs and keep that Turn open. Each bubble separates the process runs before and after its recorded answer. Unanswered questions remain in the question card and tool row. A question that already settled opens as a read-only card: the same pager over the recorded answers, marked `Answered`. Every option and text field is disabled, Skip and the submit action are gone, a question the user skipped says so in place of its empty answer, and a free-text field appears only where the recorded answer used one. Closing it removes the card, and the tool call row builds another from the same record. @@ -57,7 +57,7 @@ Each Session and pending request has an independent draft in browser storage. A A browser that reconnects while the tool call is open receives the pending request again and can still complete it with the remaining time. A Session reopened after the agent or process ended shows the card as continued; submitting resumes the Session's root agent and enters the answer as a new user turn. -Settled question replies remain compact in chat history. A caret marks the bubble as a disclosure; clicking it reopens the read-only question and answer details, and never reopens the settled tool call for a second submission. Its copy action writes each question with the answer the user gave, the same text whether the bubble is open or closed. +The settled bubble is fully visible and has no disclosure control. Question and answer text renders as plain text; question detail and unselected options are omitted. Its copy action writes each question with the answer the user gave, without reopening the tool call or submitting another answer. ----- diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 85a4cc8902..122b6e9c26 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -35,7 +35,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 回看已结束的问题 -每条迟到回复只显示一次,包括由它开启新轮次的情况。回复遵循 Chat 原有的过程分组与整轮折叠规则。回复被收起时,展开所属轮次和工作分组即可查看。 +原生工具调用、PTC 子调用的阻塞提问和迟到回复都会在回答记录后,将每批问答显示为一个右对齐的用户样式气泡,且只显示一次。气泡逐题显示问题及其已选选项、自定义回答,问题文字使用较浅颜色;过程分组或所属轮次收起后,气泡仍保持可见。普通阻塞提问的回答气泡不阻止已完成的工作步骤收起。首个步骤之后获准进入的迟到回复属于轮次中的交错输入,会使该轮次保持展开。每个气泡会分隔回答记录前后的过程分组。未回答的问题仍由提问卡片和工具调用行承载。 已经结束的问题以只读卡片打开:同一个翻页器走过已记录的回答,并标注「已回答」。所有选项与文本框都被禁用,跳过与提交动作消失,用户当时跳过的问题会在空回答处说明这一点,自由文本框只出现在当时确实用过它的回答上。关闭它会移除该卡片,工具调用行会用同一份记录再建一个。 @@ -57,7 +57,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 工具调用开放期间重新连接的浏览器会再次收到挂起请求,仍可用剩余时间完成它。在 agent 或进程结束后重新打开的会话把卡片显示为已继续;提交时先恢复会话的根 agent,再把回答作为新的用户轮次送入。 -已结算的问题回复在聊天历史中保持紧凑。气泡以三角标记指示可以展开;点击它会展开只读的问题与回答详情,且不会重新打开已经结算的工具调用,也不会产生第二次提交。它的复制动作写出每个问题与用户当时给出的回答,无论气泡展开还是收起都是同一份文本。 +已结算的气泡完整显示内容,不提供展开控件。问题和回答以纯文本呈现,不显示问题说明及未选选项。复制操作写出每个问题与用户当时给出的回答,不会重新打开工具调用,也不会再次提交回答。 ----- diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 6f9c2bf77b..977b1544e2 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -72,7 +72,8 @@ "clsx": "^2.0.0", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", - "@deepseek-ai/dsh-client-ui-tool": "workspace:*" + "@deepseek-ai/dsh-client-ui-tool": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-user-questions/src/client/QuestionReplyView.module.css b/packages/client/ui-user-questions/src/client/QuestionReplyView.module.css index f29c727c62..5abe124469 100644 --- a/packages/client/ui-user-questions/src/client/QuestionReplyView.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionReplyView.module.css @@ -1,128 +1,41 @@ .row { display: flex; flex-direction: column; - justify-content: flex-end; align-items: flex-end; gap: 6px; - padding: 4px 0; +} + +.bubbleWidth { + max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%); + min-width: 0; } .bubble { - max-width: 72%; - padding: 10px 16px; - border-radius: var(--dsw-radius-xl); - background: var(--dsw-specific-bubble); - color: var(--dsw-alias-label-primary); - cursor: pointer; - font-size: var(--dsh-content-font-size, 14px); - line-height: calc(22px + var(--dsh-content-font-delta, 0px)); text-align: left; overflow-wrap: anywhere; - white-space: pre-wrap; -} - -.toggle { - width: 100%; - padding: 0; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - text-align: left; -} - -.toggle:focus-visible { - outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); - outline-offset: 2px; -} - -.bubble:has(.toggle:focus-visible) { - box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); -} - -.labelRow { - display: flex; - align-items: center; - gap: 3px; -} - -.caret { - display: inline-flex; - color: var(--dsw-alias-label-secondary); -} - -.caret svg { - width: 12px; - height: 12px; -} - -.label { - color: var(--dsw-alias-label-secondary); - display: block; - font-size: var(--dsh-content-font-size-secondary, 13px); - line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); -} - -.summary { - display: block; - margin-top: 3px; - color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size, 14px); -} - -.text { - margin: 8px 0 0; } +.text, .details { - margin: 8px 0 0; - padding-top: 8px; - border-top: 1px solid color-mix(in srgb, var(--dsw-alias-label-secondary) 24%, transparent); + margin: 0; } .details > div + div { margin-top: 12px; } -.details dt { - color: var(--dsw-alias-label-secondary); - font-size: 12px; -} - .question { display: grid; gap: 3px; -} - -.header, -.detail, -.options { color: var(--dsw-alias-label-secondary); - font-size: 11px; } -.questionText { - color: var(--dsw-alias-label-primary); -} - -.options { - margin: 0; - padding-left: 16px; -} - -.details dd { - margin: 4px 0 0; +.header { + font-size: var(--dsh-content-font-size-secondary, 13px); } .answer { - font-size: 14px; -} - -.answerLabel { - margin-right: 4px; - color: var(--dsw-alias-label-secondary); - font-size: 12px; + margin: 4px 0 0; } .actions { diff --git a/packages/client/ui-user-questions/src/client/QuestionReplyView.tsx b/packages/client/ui-user-questions/src/client/QuestionReplyView.tsx index 8cd59f5b4d..9c1680cb83 100644 --- a/packages/client/ui-user-questions/src/client/QuestionReplyView.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionReplyView.tsx @@ -1,9 +1,9 @@ -import { memo, useCallback, useState } from 'react' +/** Settled question batches rendered with the shared user-message surface. */ +import { memo } from 'react' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, - IconCopyOutlineRegular, Tooltip, writeClipboard, + IconCheckOutlineRegular, IconCopyOutlineRegular, Tooltip, UserMessageBubble, useClipboardCopy, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QuestionReplyData } from './question-reply.ts' import { replyAnswerValues, replyClipboardText } from './question-reply.ts' @@ -13,82 +13,39 @@ type QuestionReplyViewProps = PropsRuntime<'conversation.chat.node', 'question-reply'> & PropsLocale<'question'> -/** - * Right-aligned late-reply bubble: a label naming the earlier pending - * questions, then one question and answer pair per question. A payload the - * Client cannot read falls back to the model-facing text. - */ +/** Render recorded questions and answers without an expandable process row. */ export const QuestionReplyView = memo(function QuestionReplyView({ node, t }: QuestionReplyViewProps) { return }) /** - * Render one settled question reply as a compact transcript bubble. + * Render one answered batch with questions above their corresponding answers. * @param props - Reply data and the question locale translator. - * @returns The expandable reply bubble. + * @returns The right-aligned, fully visible question-and-answer bubble. */ export function QuestionReplyBubble({ data, t }: { data: QuestionReplyData; t: PropsLocale<'question'>['t'] }) { - const label = t('reply.label') - const [open, setOpen] = useState(false) - const toggleLabel = t(open ? 'reply.close' : 'reply.open') - const summary = data.questions.map(question => replyAnswerValues(data, question.id)).flat().join(', ') const copyText = replyClipboardText(data, t) - const [copied, setCopied] = useState(false) - const onCopy = useCallback(() => { - if (copied) return - void writeClipboard(copyText).then((ok) => { - if (!ok) return - setCopied(true) - window.setTimeout(() => { setCopied(false) }, 1000) - }) - }, [copied, copyText]) + const { copied, onCopy } = useClipboardCopy(copyText) return ( -
-
- - {open && (data.questions.length === 0 - ?

{data.text}

- : ( -
+
+
+ + {data.questions.length === 0 + ?

{data.text}

+ :
{data.questions.map((question) => { const values = replyAnswerValues(data, question.id) - return ( -
-
- {question.header && question.header !== question.question && {question.header}} - {question.question} - {question.detail && {question.detail}} - {question.options && question.options.length > 0 && ( -
    - {question.options.map(option =>
  • {option.label}{option.description && ` — ${option.description}`}
  • )} -
- )} -
-
- {t('reply.answerLabel')} - {values.length === 0 ? t('reply.skipped') : values.join(', ')} -
-
- ) + return
+
+ {question.header && question.header !== question.question && {question.header}} + {question.question} +
+
{values.length === 0 ? t('reply.skipped') : values.join(', ')}
+
})} -
- ))} +
} +
diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 2d04df1b4b..618cb599af 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -28,7 +28,7 @@ import { brandString } from '@deepseek-ai/dsh-brand' import { createWaterfallRequest, PendingQuestion, type QuestionRpcChannel } from './contract/slots.ts' import { createQuestionDraftStore } from './draft-store.ts' import { QuestionComposer } from './QuestionComposer.tsx' -import { questionReplyDefinition } from './question-reply.ts' +import { answeredQuestionDefinition, questionReplyDefinition } from './question-reply.ts' import { QuestionReplyView } from './QuestionReplyView.tsx' import { en, ja, zh, type QuestionKey } from './locales.ts' @@ -392,6 +392,7 @@ export function apply(ctx: ClientContext): void { QuestionComposer, )) ctx.uiConversation.events.register(questionReplyDefinition) + ctx.uiConversation.events.register(answeredQuestionDefinition) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'question-reply', diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 83ec122186..719e4ecd0b 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -14,9 +14,7 @@ export const zh = { 'wait.continued': '已继续工作,仍可回答', 'review.status': '已回答', 'review.skipped': '这道问题当时被跳过。', - 'reply.label': '回答先前等待中的问题', - 'reply.open': '展开问题详情', - 'reply.close': '收起问题详情', + 'reply.label': '问题与回答', 'reply.answerLabel': '回答:', 'reply.skipped': '已跳过', 'nav.prev': '上一题', @@ -54,9 +52,7 @@ export const en = { 'wait.continued': 'Work continued — you can still answer', 'review.status': 'Answered', 'review.skipped': 'This question was skipped.', - 'reply.label': 'Reply to earlier pending questions', - 'reply.open': 'Open question details', - 'reply.close': 'Close question details', + 'reply.label': 'Questions and answers', 'reply.answerLabel': 'Answer: ', 'reply.skipped': 'Skipped', 'nav.prev': 'Previous question', @@ -91,9 +87,7 @@ export const ja = { 'wait.continued': '作業は続行されました — まだ回答できます', 'review.status': '回答済み', 'review.skipped': 'この質問はスキップされました。', - 'reply.label': '以前の保留中の質問に返信', - 'reply.open': '質問の詳細を開く', - 'reply.close': '質問の詳細を閉じる', + 'reply.label': '質問と回答', 'reply.answerLabel': '回答: ', 'reply.skipped': 'スキップ済み', 'nav.prev': '前の質問', diff --git a/packages/client/ui-user-questions/src/client/question-reply.ts b/packages/client/ui-user-questions/src/client/question-reply.ts index 68973b32df..8af46e8036 100644 --- a/packages/client/ui-user-questions/src/client/question-reply.ts +++ b/packages/client/ui-user-questions/src/client/question-reply.ts @@ -1,19 +1,17 @@ -/** Late-reply conversation node: the steered `user-question-reply` message projected as question and answer pairs. */ +/** Answered-question Chat data from in-time tool results and late human replies. */ import type {} from '@deepseek-ai/dsh-client-ui-chat/client' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ConversationLocation, ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-user-questions/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' /** One asked question as echoed in the reply payload. */ export interface QuestionReplyQuestion { readonly id: string readonly question: string - readonly detail?: string readonly header?: string - readonly options?: readonly { readonly label: string; readonly description?: string }[] - readonly multiSelect?: boolean } /** One structured answer as echoed in the reply payload. */ @@ -23,10 +21,10 @@ export interface QuestionReplyAnswer { readonly custom?: string } -/** Presentation data of one late reply. */ +/** Presentation data of one submitted answer batch. */ export interface QuestionReplyData { readonly callId: string - /** A late answer to the pending questions. */ + /** A submitted answer batch, including individually skipped questions. */ readonly outcome: 'answered' readonly questions: readonly QuestionReplyQuestion[] readonly answers: readonly QuestionReplyAnswer[] @@ -37,7 +35,7 @@ export interface QuestionReplyData { declare module '@deepseek-ai/dsh-client-ui-chat/client' { interface ChatNodeDataMap { - /** Late answer to a continued question. */ + /** One answered question batch, regardless of foreground wait timing. */ 'question-reply': QuestionReplyData } } @@ -73,15 +71,7 @@ export function replyPairsOf(text: string): Pick { - if (!isRecord(option) || typeof option.label !== 'string') return [] - return [{ label: option.label, ...(typeof option.description === 'string' ? { description: option.description } : {}) }] - }) } - : {}), - ...(typeof question.multiSelect === 'boolean' ? { multiSelect: question.multiSelect } : {}), }) } const answers: QuestionReplyAnswer[] = [] @@ -110,9 +100,8 @@ export function replyAnswerValues(data: QuestionReplyData, id: string): string[] } /** - * Clipboard text of one late reply: every question with the answer the user - * gave, in the layout the open bubble shows, without the options nobody chose. - * The text does not depend on whether the bubble is open. + * Clipboard text of one answer batch: every question with the answer the user + * gave, without question details or unselected options. * @param data - Projected reply to copy. * @param t - Bound `question` namespace translator owning the answer labels. * @returns One block per question, or the model-facing text when the payload was unreadable. @@ -142,6 +131,115 @@ function replySource( : null } +/** Reply data retained until a matching tool result records a complete answer batch. */ +interface AnsweredQuestionState { + readonly questions: readonly QuestionReplyQuestion[] | null + readonly reply: QuestionReplyState | undefined +} + +function jsonRecord(text: string): Record | null { + try { + const value: unknown = JSON.parse(text) + return isRecord(value) ? value : null + } catch (error) { + // Malformed tool JSON remains visible through the generic Tool node. + void error + return null + } +} + +function callQuestions(value: unknown): readonly QuestionReplyQuestion[] | null { + if (!isRecord(value) || !Array.isArray(value.questions) || value.questions.length === 0) return null + const ids = new Set() + const questions: QuestionReplyQuestion[] = [] + for (const question of value.questions) { + if (!isRecord(question) || typeof question.id !== 'string' || typeof question.question !== 'string' + || ids.has(question.id) || (isRecord(question.intent) && question.intent.kind === 'plan-review')) return null + ids.add(question.id) + questions.push({ + id: question.id, + question: question.question, + ...(typeof question.header === 'string' ? { header: question.header } : {}), + }) + } + return questions +} + +function resultReply( + questions: readonly QuestionReplyQuestion[], + event: SessionEvent<'tool/result' | 'tool/ptc-dispatch'>, +): QuestionReplyState | undefined { + const result = event.type === 'tool/result' ? event.data.message : event.data + if (result.isError === true) return undefined + const content = result.content + if (content.length !== 1 || content[0]?.type !== 'text') return undefined + const text = content[0].text + const value = jsonRecord(text) + if (value === null || value.pending === true || !Array.isArray(value.answers) + || value.answers.length !== questions.length) return undefined + const ids = new Set(questions.map(question => question.id)) + const answers: QuestionReplyAnswer[] = [] + for (const answer of value.answers) { + if (!isRecord(answer) || typeof answer.id !== 'string' || !ids.delete(answer.id) + || !Array.isArray(answer.selected) || !answer.selected.every(item => typeof item === 'string') + || (answer.custom !== undefined && typeof answer.custom !== 'string')) return undefined + answers.push({ id: answer.id, selected: answer.selected, + ...(answer.custom === undefined ? {} : { custom: answer.custom }) }) + } + return { seq: event.seq, time: event.time, callId: String(event.type === 'tool/result' ? event.data.message.source.callId : event.data.subCallId), + outcome: 'answered', questions, answers, text } +} + +/** + * In-time ask-user answers paired by native callId or PTC subCallId. Pending, failed, and + * malformed results retain their generic Tool presentation; late answers are + * owned by questionReplyDefinition. Work is bounded by this call's answer batch. + */ +export const answeredQuestionDefinition: ConversationNodeDefinition = { + kind: 'answered-user-question', + target: 'chat', + match: (event) => { + if (event.type === 'tool/call' && event.data.name === 'ask_user_question') { + return { id: String(event.data.callId), role: 'start' } + } + if (event.type === 'tool/ptc-dispatch-start' && event.data.name === 'ask_user_question') { + return { id: String(event.data.subCallId), role: 'start' } + } + if (event.type === 'tool/ptc-dispatch' && event.data.name === 'ask_user_question') { + return { id: String(event.data.subCallId), role: 'update' } + } + // Native results omit the tool name; retain update-only matches for history prepends. + if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { + return { id: String(event.data.message.source.callId), role: 'update' } + } + return null + }, + start: (_context, match) => { + const event = match.event + if (event.type !== 'tool/call' && event.type !== 'tool/ptc-dispatch-start') { + throw new Error('answered-user-question requires tool/call or tool/ptc-dispatch-start') + } + return { questions: callQuestions(event.type === 'tool/call' ? jsonRecord(event.data.arguments) : event.data.arguments), reply: undefined } + }, + update: (context, match) => { + if ((match.event.type !== 'tool/result' && match.event.type !== 'tool/ptc-dispatch') || context.state.questions === null) return context.state + return { ...context.state, reply: resultReply(context.state.questions, match.event) } + }, + buildViewNode: (context) => { + const reply = context.state?.reply + if (reply === undefined) { + const current = context.current.get('chat') + return current == null ? null : { ...current, visibility: 'hidden' } + } + const { seq, ...data } = reply + return { + key: context.key, id: context.id, kind: 'question-reply', target: 'chat', + anchorSeq: seq, location: context.start?.location ?? { kind: 'unresolved' }, + groupTree: 'turn', visibility: 'visible', data, + } + }, +} + /** * Late-reply projection owned by the questions UI. It claims the reply message * on the Chat target, so the generic message Definition never presents it. @@ -173,13 +271,15 @@ export const questionReplyDefinition: ConversationNodeDefinition { if (context.state === undefined) return null const { seq, ...data } = context.state + const location: ConversationLocation = context.start?.location ?? { kind: 'unresolved' } return { key: context.key, kind: 'question-reply', id: context.id, target: 'chat', anchorSeq: seq, - location: context.start?.location ?? { kind: 'unresolved' }, + location, + groupTree: location.kind === 'step' && location.step.step === 1 ? 'exchange' : 'input', visibility: 'visible', data, } diff --git a/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx b/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx index dddc3c5bc9..ea2625a912 100644 --- a/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/QuestionReplyView.client.spec.tsx @@ -4,11 +4,16 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' -import { zh } from '../src/client/locales.ts' +import { ja, zh } from '../src/client/locales.ts' import { QuestionReplyBubble, QuestionReplyView } from '../src/client/QuestionReplyView.tsx' import type { QuestionReplyData } from '../src/client/question-reply.ts' -afterEach(cleanup) +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') +afterEach(() => { + cleanup() + if (originalClipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', originalClipboard) +}) /** Render the keyed Chat renderer itself; the framework props it never reads are stubbed. */ function renderView(data: QuestionReplyData) { @@ -34,32 +39,28 @@ const data = { callId: 'call-reply', outcome: 'answered', questions: [{ - id: 'goal', question: 'What should we build?', header: 'What should we build?', detail: 'Choose the first milestone.', - options: [{ label: 'A dashboard', description: 'A focused web surface.' }, { label: 'A CLI' }], + id: 'goal', question: 'What should we build?', header: 'What should we build?', }], answers: [{ id: 'goal', selected: ['A dashboard'] }], text: '{"questions":[],"answers":[]}', time: 1, } satisfies QuestionReplyData describe('QuestionReplyView', () => { - it('reopens the settled question details from the history bubble', () => { - render() + it('names answered batches in Japanese', () => { + render() + expect(screen.getByRole('group', { name: '質問と回答' })).toBeTruthy() + }) - const bubble = screen.getByRole('button', { name: /展开问题详情/ }) - expect(bubble.getAttribute('aria-expanded')).toBe('false') + it('shows paired questions and answers immediately without auxiliary option content', () => { + render() + expect(screen.getAllByText('What should we build?')).toHaveLength(1) expect(screen.getAllByText('A dashboard')).toHaveLength(1) + expect(screen.queryByText('Choose the first milestone.')).toBeNull() + expect(screen.queryByText('A focused web surface.')).toBeNull() + expect(screen.queryByText('A CLI')).toBeNull() + expect(screen.queryByRole('button', { name: /展开问题详情/ })).toBeNull() expect(screen.getByRole('button', { name: 'copy' })).toBeTruthy() expect(screen.getByText(/\d{2}:\d{2}/)).toBeTruthy() - expect(screen.queryByText('What should we build?')).toBeNull() - - fireEvent.click(bubble) - - expect(screen.getByRole('button', { name: /收起问题详情/ }).getAttribute('aria-expanded')).toBe('true') - expect(screen.getByText('What should we build?')).toBeTruthy() - expect(screen.getAllByText('What should we build?')).toHaveLength(1) - expect(screen.getByText('A dashboard — A focused web surface.')).toBeTruthy() - expect(screen.getByText('回答:')).toBeTruthy() - expect(screen.getAllByText('A dashboard')).toHaveLength(1) }) it('copies the question with its answer, then confirms the write', async () => { @@ -81,7 +82,7 @@ describe('QuestionReplyView', () => { it('renders through the conversation.chat.node slot with the same bubble', () => { renderView(data) - expect(screen.getByRole('group', { name: '回答先前等待中的问题' }).getAttribute('data-question-reply')).toBe('call-reply') + expect(screen.getByRole('group', { name: '问题与回答' }).getAttribute('data-question-reply')).toBe('call-reply') }) it('shows a distinct header, marks an unanswered question skipped, and falls back to the raw text', () => { @@ -90,20 +91,18 @@ describe('QuestionReplyView', () => { ...data, questions: [ { id: 'goal', question: 'What should we build?', header: 'Goal' }, - { id: 'notes', question: 'Any notes?', options: [{ label: 'None' }] }, + { id: 'notes', question: 'Any notes?' }, ], answers: [{ id: 'goal', selected: [], custom: ' ' }], }} t={t} />) - fireEvent.click(screen.getByRole('button', { name: /展开问题详情/ })) expect(screen.getByText('Goal')).toBeTruthy() - expect(screen.getByText('None')).toBeTruthy() + expect(screen.queryByText('None')).toBeNull() expect(screen.getAllByText('已跳过')).toHaveLength(2) cleanup() render() - fireEvent.click(screen.getByRole('button', { name: /展开问题详情/ })) expect(screen.getByText('raw reply text')).toBeTruthy() }) @@ -136,12 +135,46 @@ describe('QuestionReplyView', () => { } }) - it('copies the same text once the bubble is open', () => { + it('ignores repeated pending copies and does not schedule feedback after unmount', async () => { + vi.useFakeTimers() + try { + let finish!: () => void + const writeText = vi.fn(() => new Promise((resolve) => { finish = resolve })) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'copy' })) + fireEvent.click(screen.getByRole('button', { name: 'copy' })) + expect(writeText).toHaveBeenCalledTimes(1) + view.unmount() + await act(async () => { finish(); await Promise.resolve() }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('clears the confirmation timer when the bubble unmounts', async () => { + vi.useFakeTimers() + try { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }) + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'copy' })) + await act(async () => { await Promise.resolve(); await Promise.resolve() }) + expect(screen.getByRole('button', { name: 'copied' })).toBeTruthy() + view.unmount() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('copies the complete visible question and answer', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) render() - fireEvent.click(screen.getByRole('button', { name: /展开问题详情/ })) fireEvent.click(screen.getByRole('button', { name: 'copy' })) expect(writeText).toHaveBeenCalledWith('What should we build?\n回答:A dashboard') }) diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 3fa48a81b6..07a4479c9c 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -336,8 +336,9 @@ describe('apply', () => { it('registers the late-reply conversation node with the conversation runtime', async () => { const b = await bench() - expect(b.registerNode).toHaveBeenCalledOnce() + expect(b.registerNode).toHaveBeenCalledTimes(2) expect(b.registerNode.mock.calls[0]![0]).toMatchObject({ kind: 'user-question-reply', target: 'chat' }) + expect(b.registerNode.mock.calls[1]![0]).toMatchObject({ kind: 'answered-user-question', target: 'chat' }) expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).toEqual(['question-reply']) }) diff --git a/packages/client/ui-user-questions/tests/question-reply.client.spec.ts b/packages/client/ui-user-questions/tests/question-reply.client.spec.ts index 789371b623..d9001258bf 100644 --- a/packages/client/ui-user-questions/tests/question-reply.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-reply.client.spec.ts @@ -2,12 +2,19 @@ import { describe, expect, it } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { SessionSeq } from '@deepseek-ai/dsh-session' +import type { ChatConversationViewNode } from '../../ui-chat/src/client/contract/chat-nodes.ts' +import type { ChatSnapshot } from '../../ui-chat/src/client/contract/snapshot.ts' +import { chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts' +import { assistantDefinition } from '../../ui-chat/src/client/conversation-nodes/assistant.ts' +import { toolDefinition } from '../../ui-chat/src/client/conversation-nodes/tool.ts' +import { turnProcessDefinition } from '../../ui-chat/src/client/conversation-nodes/turn-process.ts' +import { isAtOrOutside } from '../../ui-chat/src/client/contract/group-tree.ts' import type { ConversationViewNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { messageDefinition } from '../../ui-chat/src/client/conversation-nodes/message.ts' import { ConversationNodeAssembler } from '../../ui-conversation/src/client/conversation/assembler.ts' import { en } from '../src/client/locales.ts' import type { QuestionReplyData } from '../src/client/question-reply.ts' -import { questionReplyDefinition, replyAnswerValues, replyClipboardText, replyPairsOf } from '../src/client/question-reply.ts' +import { answeredQuestionDefinition, questionReplyDefinition, replyAnswerValues, replyClipboardText, replyPairsOf } from '../src/client/question-reply.ts' const answered = JSON.stringify({ kind: 'answer_to_pending_question', @@ -21,10 +28,10 @@ const answered = JSON.stringify({ }) const noAnswers = JSON.stringify({ questions: [{ id: 'scope', question: 'Which scope?' }] }) -function message(source: Record, text: string, surfaceOp: string | null = 'append') { +function message(source: Record, text: string, surfaceOp: string | null = 'append', seq = 7) { return { type: 'user/message', - seq: SessionSeq(7), + seq: SessionSeq(seq), time: 7_000, ...(surfaceOp === null ? {} : { surfaceOp }), data: { id: 'msg-1', role: 'user', source, content: [{ type: 'text', text }] }, @@ -37,20 +44,19 @@ describe('replyPairsOf', () => { it('reads question and answer pairs, tolerating a payload without answers', () => { expect(replyPairsOf(answered)).toEqual({ questions: [{ - id: 'scope', question: 'Which scope?', header: 'Scope', detail: 'Choose the smallest useful surface.', - options: [{ label: 'Tool only', description: 'Keep the change narrow.' }], multiSelect: false, + id: 'scope', question: 'Which scope?', header: 'Scope', }, { id: 'notes', question: 'Any notes?' }], answers: [{ id: 'scope', selected: ['Tool only'], custom: 'note' }, { id: 'notes', selected: [] }], }) expect(replyPairsOf(noAnswers)).toEqual({ questions: [{ id: 'scope', question: 'Which scope?' }], answers: [] }) }) - it('keeps only the well-formed options of a question and drops the rest', () => { + it('omits options from the projected question', () => { const text = JSON.stringify({ questions: [{ id: 'scope', question: 'Which scope?', options: [{ label: 'Tool only', description: 1 }, 'nope', { description: 'no label' }] }], answers: [], }) - expect(replyPairsOf(text).questions).toEqual([{ id: 'scope', question: 'Which scope?', options: [{ label: 'Tool only' }] }]) + expect(replyPairsOf(text).questions).toEqual([{ id: 'scope', question: 'Which scope?' }]) }) it('renders nothing structured for text it cannot read', () => { @@ -60,13 +66,233 @@ describe('replyPairsOf', () => { }) }) +function toolCall(argumentsText = JSON.stringify({ questions: [{ + id: 'scope', question: 'Which scope?', header: 'Scope', detail: 'Hidden detail', options: [{ label: 'Tool only' }], +}, { id: 'notes', question: 'Any notes?' }] }), name = 'ask_user_question') { + return { type: 'tool/call', seq: SessionSeq(5), time: 5_000, + data: { turn: 1, step: 1, callId: 'call-1', name, arguments: argumentsText } } as never +} + +function toolResult(text = JSON.stringify({ answers: [ + { id: 'notes', selected: [] }, { id: 'scope', selected: ['Tool only'], custom: 'note' }, +] }), isError = false, surfaceOp = 'append') { + return { type: 'tool/result', seq: SessionSeq(6), time: 6_000, surfaceOp, + data: { turn: 1, step: 1, message: { role: 'tool', isError, + source: { kind: 'tool', callId: 'call-1' }, content: [{ type: 'text', text }] } } } as never +} + +function ptcCall(argumentsValue: unknown = { questions: [{ id: 'scope', question: 'Which scope?', header: 'Scope' }, { id: 'notes', question: 'Any notes?' }] }, name = 'ask_user_question') { + return { type: 'tool/ptc-dispatch-start', seq: SessionSeq(5), time: 5_000, + data: { rootCallId: 'root', parentCallId: 'root', subCallId: 'call-1', name, arguments: argumentsValue } } as never +} + +function ptcResult(text = JSON.stringify({ answers: [{ id: 'notes', selected: [] }, { id: 'scope', selected: ['Tool only'], custom: 'note' }] }), isError = false, name = 'ask_user_question') { + return { type: 'tool/ptc-dispatch', seq: SessionSeq(6), time: 6_000, + data: { rootCallId: 'root', parentCallId: 'root', subCallId: 'call-1', name, arguments: {}, isError, + content: [{ type: 'text', text }] } } as never +} + +function answerAssembler() { + let nodes: readonly ConversationViewNode[] = [] + const assembler = new ConversationNodeAssembler( + { entries: () => [answeredQuestionDefinition, questionReplyDefinition, messageDefinition], fallbackEntry: () => undefined }, + { entries: () => [{ target: 'chat', create: () => ({ + empty: nodes, + replace: input => (nodes = input.nodes), + apply: (input) => { + const changed = new Map(nodes.map(node => [node.key, node])) + for (const node of input.upserts) changed.set(node.key, node) + return (nodes = [...changed.values()]) + }, + }) }] }, + ) + assembler.activateTarget('chat') + return { assembler, nodes: () => nodes as readonly ChatConversationViewNode[] } +} + +describe('answeredQuestionDefinition', () => { + it.each(['native', 'ptc'].flatMap(transport => ['replay', 'live', 'prepend'].flatMap(mode => [false, true].map(late => ({ mode, late, transport })))))('projects ordinary and late answer folding independently ($transport, $mode, late=$late)', ({ mode, late, transport }) => { + const call = transport === 'native' ? toolCall() : ptcCall() + const result = transport === 'native' ? toolResult : ptcResult + const entries = [ + { type: 'event' as const, event: { type: 'turn/start', seq: SessionSeq(1), time: 1000, data: { turn: 1 } } as never }, + { type: 'event' as const, event: { type: 'step/start', seq: SessionSeq(2), time: 2000, data: { turn: 1, step: 1 } } as never }, + { type: 'event' as const, event: message({ kind: 'user' }, 'Ask me about scope', 'append', 3) }, + { type: 'event' as const, event: call }, + { type: 'event' as const, event: late ? result('{"pending":true,"callId":"call-1"}') : result() }, + { type: 'event' as const, event: { type: 'step/end', seq: SessionSeq(7), time: 7000, data: { turn: 1, step: 1 } } as never }, + { type: 'event' as const, event: { type: 'step/start', seq: SessionSeq(8), time: 8000, data: { turn: 1, step: 2 } } as never }, + { type: 'event' as const, event: { type: 'assistant/message', seq: SessionSeq(10), time: 10000, surfaceOp: 'append', + data: { turn: 1, step: 2, stream: [], message: { id: 'final', role: 'assistant', source: { kind: 'model', provider: 'fake', model: 'fake' }, content: [{ type: 'text', text: 'Done' }] } } } as never }, + { type: 'event' as const, event: { type: 'step/end', seq: SessionSeq(11), time: 11000, data: { turn: 1, step: 2 } } as never }, + { type: 'event' as const, event: { type: 'turn/end', seq: SessionSeq(12), time: 12000, data: { turn: 1, reason: { kind: 'completed' } } } as never }, + ] + if (late) entries.splice(7, 0, { type: 'event' as const, event: message(replySource('answered'), answered, 'append', 9) }) + if (transport === 'ptc') entries.splice(3, 0, { type: 'event' as const, event: { + type: 'tool/call', seq: SessionSeq(4), time: 4000, + data: { turn: 1, step: 1, callId: 'root', name: 'run_code', arguments: '{}' }, + } as never }) + const run = new ConversationNodeAssembler( + { + entries: () => [answeredQuestionDefinition, questionReplyDefinition, messageDefinition, + assistantDefinition, toolDefinition, turnProcessDefinition], + fallbackEntry: () => undefined, + }, + { entries: () => [chatViewDefinition] }, + ) + if (mode === 'live') { + run.replaceWindow([], false) + run.activateTarget('chat') + for (const entry of entries) { run.append(entry); run.flush() } + } else if (mode === 'prepend') { + run.replaceWindow(entries.slice(4), true) + run.activateTarget('chat') + run.prepend(entries.slice(0, 4), false) + run.flush() + } else { + run.replaceWindow(entries, false) + run.activateTarget('chat') + } + const view = run.snapshot('chat') as ChatSnapshot + const batch = view.nodes.values().find(node => node.kind === 'question-reply')! + const presentation = view.nodes.processSource(batch.key).getSnapshot() + expect(presentation).toMatchObject({ turnClosed: true, hasExternalProcess: true, hasInterleavedInput: late }) + expect(batch).toMatchObject({ groupTree: late ? 'input' : 'turn', visibility: 'visible' }) + expect(isAtOrOutside(batch, 'turn')).toBe(true) + expect(isAtOrOutside(batch, 'input')).toBe(late) + }) + + it('does not present a question until its answer arrives, then pairs one batch at the result position', () => { + const run = answerAssembler() + run.assembler.replaceWindow([{ type: 'event', event: toolCall() }], false) + run.assembler.flush() + expect(run.nodes()).toEqual([]) + run.assembler.append({ type: 'event', event: toolResult() }) + run.assembler.flush() + expect(run.nodes()).toHaveLength(1) + expect(run.nodes()[0]).toMatchObject({ kind: 'question-reply', anchorSeq: 6, groupTree: 'turn', visibility: 'visible', + data: { callId: 'call-1', time: 6_000, outcome: 'answered', + questions: [{ id: 'scope', question: 'Which scope?', header: 'Scope' }, { id: 'notes', question: 'Any notes?' }], + answers: [{ id: 'notes', selected: [] }, { id: 'scope', selected: ['Tool only'], custom: 'note' }] } }) + expect(run.nodes()[0]?.data).not.toHaveProperty('seq') + const replay = answerAssembler() + replay.assembler.replaceWindow([{ type: 'event', event: toolCall() }, { type: 'event', event: toolResult() }], false) + replay.assembler.flush() + expect(replay.nodes().map(({ location: _location, ...node }) => node)) + .toEqual(run.nodes().map(({ location: _location, ...node }) => node)) + expect(replay.nodes()[0]?.location.kind).toBe(run.nodes()[0]?.location.kind) + }) + + it('keeps an update-only tail pending and derives the batch when its call is prepended', () => { + const run = answerAssembler() + run.assembler.replaceWindow([{ type: 'event', event: toolResult() }], false) + run.assembler.flush() + expect(run.nodes()).toEqual([]) + run.assembler.prepend([{ type: 'event', event: toolCall() }], false) + run.assembler.flush() + expect(run.nodes()).toHaveLength(1) + expect(run.nodes()[0]).toMatchObject({ kind: 'question-reply', anchorSeq: 6 }) + }) + + it('leaves pending results without a bubble and presents the eventual late answer exactly once', () => { + const run = answerAssembler() + run.assembler.replaceWindow([{ type: 'event', event: toolCall() }, + { type: 'event', event: toolResult('{"pending":true,"callId":"call-1"}') }, + { type: 'event', event: message(replySource('answered'), answered) }], false) + run.assembler.flush() + expect(run.nodes()).toHaveLength(1) + expect(run.nodes()[0]).toMatchObject({ kind: 'question-reply', anchorSeq: 7, groupTree: 'exchange' }) + }) + + it.each([ + 'null', '[]', 'not json', '{"answers":"wrong"}', '{"answers":[]}', + '{"answers":[{"id":"scope","selected":[]},{"id":"scope","selected":[]}]}', + '{"answers":[{"id":"scope","selected":[1]},{"id":"notes","selected":[]}]}', + '{"answers":[{"id":"scope","selected":[],"custom":1},{"id":"notes","selected":[]}]}', + '{"answers":[{"id":"wrong","selected":[]},{"id":"notes","selected":[]}]}', + ])('retains the generic fallback instead of fabricating answers from %s', (text) => { + const run = answerAssembler() + run.assembler.replaceWindow([{ type: 'event', event: toolCall() }, { type: 'event', event: toolResult(text) }], false) + run.assembler.flush() + expect(run.nodes()).toEqual([]) + }) + + it.each(['not json', '{"questions":[]}', '{"questions":[{"id":1}]}', + '{"questions":[{"id":"scope","question":"A"},{"id":"scope","question":"B"}]}', + '{"questions":[{"id":"scope","question":"Approve?","intent":{"kind":"plan-review"}}]}', + ])('does not pair unsafe or plan-review call arguments %s', (args) => { + const run = answerAssembler() + run.assembler.replaceWindow([{ type: 'event', event: toolCall(args) }, { type: 'event', event: toolResult() }], false) + run.assembler.flush() + expect(run.nodes()).toEqual([]) + }) + + it.each([{ content: [] }, { content: [{ type: 'image', source: { kind: 'inline', data: '', mediaType: 'image/png' } }] }, + { content: [{ type: 'text', text: '{}' }, { type: 'text', text: '{}' }] }, + ])('leaves unsupported result content in the generic Tool presentation', ({ content }) => { + const run = answerAssembler() + const result = { type: 'tool/result', seq: SessionSeq(6), time: 6_000, surfaceOp: 'append', + data: { turn: 1, step: 1, message: { role: 'tool', source: { kind: 'tool', callId: 'call-1' }, content } } } as never + run.assembler.replaceWindow([{ type: 'event', event: toolCall() }, { type: 'event', event: result }], false) + run.assembler.flush() + expect(run.nodes()).toEqual([]) + }) + + it('retains a published node key while replay withdraws its answer evidence', () => { + const event = toolCall() + const state = answeredQuestionDefinition.start({} as never, { event, id: 'call-1', role: 'start' } as never, {} as never) + const answeredState = answeredQuestionDefinition.update({ state } as never, + { event: toolResult(), id: 'call-1', role: 'update' } as never) + const current = new Map() + const node = answeredQuestionDefinition.buildViewNode!({ state: answeredState, current, + key: 'answered:1', id: 'call-1', start: undefined } as never) as ChatConversationViewNode + expect(node.location).toEqual({ kind: 'unresolved' }) + expect(node.visibility).toBe('visible') + current.set('chat', node) + const hidden = answeredQuestionDefinition.buildViewNode!({ state, current, + key: 'answered:1', id: 'call-1', start: undefined } as never) + expect(hidden).toMatchObject({ key: 'answered:1', visibility: 'hidden' }) + expect(answeredQuestionDefinition.buildViewNode!({ state: undefined, current: new Map() } as never)).toBeNull() + }) + + it('ignores unrelated PTC calls and retains malformed or failed dispatches in the generic tool view', () => { + expect(answeredQuestionDefinition.match(ptcCall(undefined, 'bash'))).toBeNull() + expect(answeredQuestionDefinition.match(ptcResult(undefined, false, 'bash'))).toBeNull() + for (const [call, result] of [ + [ptcCall(), ptcResult(undefined, true)], + [ptcCall(), ptcResult('not json')], + [ptcCall({ questions: [] }), ptcResult()], + [ptcCall({ questions: [{ id: 'plan', question: 'Approve?', intent: { kind: 'plan-review' } }] }), ptcResult()], + ]) { + const run = answerAssembler() + run.assembler.replaceWindow([{ type: 'event', event: call! }, { type: 'event', event: result! }], false) + run.assembler.flush() + expect(run.nodes()).toEqual([]) + } + }) + + it('requires tool/call as its starting evidence', () => { + expect(() => answeredQuestionDefinition.start({} as never, + { event: toolResult(), id: 'call-1', role: 'start' } as never, {} as never)).toThrow(/requires tool\/call/) + }) + + it('excludes other tools, plan approval, failed results, and non-appended results', () => { + expect(answeredQuestionDefinition.match(toolCall(undefined, 'exit_plan_mode'))).toBeNull() + expect(answeredQuestionDefinition.match(toolCall(undefined, 'bash'))).toBeNull() + expect(answeredQuestionDefinition.match(toolResult(undefined, false, 'replace'))).toBeNull() + const run = answerAssembler() + run.assembler.replaceWindow([{ type: 'event', event: toolCall() }, { type: 'event', event: toolResult(undefined, true) }], false) + run.assembler.flush() + expect(run.nodes()).toEqual([]) + }) +}) + const t = makeTranslate(en) const replyData = (overrides: Partial = {}): QuestionReplyData => ({ callId: 'call-1', outcome: 'answered', questions: [{ id: 'scope', question: 'Which scope?', header: 'Scope', - options: [{ label: 'Tool only', description: 'Keep the change narrow.' }, { label: 'Whole package' }], }, { id: 'notes', question: 'Any notes?' }], answers: [{ id: 'scope', selected: ['Tool only'], custom: 'note' }, { id: 'notes', selected: [] }], text: answered, @@ -156,6 +382,17 @@ describe('questionReplyDefinition', () => { expect(questionReplyDefinition.buildViewNode!({ state: undefined } as never)).toBeNull() }) + it('opens an exchange only for a reply admitted into Step 1', () => { + const event = message(replySource('answered'), answered) + const state = questionReplyDefinition.start({} as never, { event, id: 'msg-1', role: 'start' } as never, {} as never) + for (const step of [1, 2]) { + const node = questionReplyDefinition.buildViewNode!({ + state, key: 'reply:1', id: 'msg-1', start: { location: { kind: 'step', step: { step } } }, + } as never) + expect((node as ChatConversationViewNode | null)?.groupTree).toBe(step === 1 ? 'exchange' : 'input') + } + }) + it('starts an empty record for a reply without text', () => { const untyped = { type: 'user/message', seq: SessionSeq(8), time: 8_000, surfaceOp: 'append', diff --git a/packages/client/ui-user-questions/tsconfig.json b/packages/client/ui-user-questions/tsconfig.json index 8368699f96..0be6a8fb64 100644 --- a/packages/client/ui-user-questions/tsconfig.json +++ b/packages/client/ui-user-questions/tsconfig.json @@ -58,6 +58,9 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 626e8c4bf9..3d826e05b2 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -51,8 +51,8 @@ en: ad4a333892f2fe92 zh: 3a670fd401f4d7a7 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/view-state: - en: faedf74d08113970 - zh: c5f383727a65a1b1 + en: b62b5a344b962c45 + zh: 6c773bece95c3621 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards: en: 8bcb7bd90d7c5773 zh: 17663be926a099d2 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index eaa9add9d4..754428e4e8 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -190,9 +190,9 @@ Once the Workspace baseline is ready, browser-persisted expansion and Session-or The sidebar hides durable summaries with `origin: 'subagent'`. A visible ordinary row shows the shared ongoing loader from running direct children in its loaded parent catalog, never from summary lineage. Child activity uses the latest UI status, falling back to the Session summary until that status is known. -Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measures keyed rows before and after React commits that change their membership or order, and uses native movement and opacity animations. Removed rows fade as inert copies outside the scrolling list; they do not delay React unmounting or extend its scroll range. Initial loading, drag commits, overflow expansion, and view-option changes settle immediately. The animator has no layout observers or polling and does not measure content-only updates or scrolling. +Row motion belongs to [AnimatedRows](../ui-primitives/src/AnimatedRows.tsx). It measures keyed rows before and after React commits that change their membership or order, and uses native movement and opacity animations. Removed rows fade as inert copies outside the scrolling list; they do not delay React unmounting or extend its scroll range. Initial loading, drag commits, overflow expansion, and view-option changes settle immediately. The animator has no layout observers or polling and does not measure content-only updates or scrolling. -Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. [useRowDragPreview](src/client/rows/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. The preview does not change the source row's background or drag-and-drop handlers. If the browser rejects a custom drag image, the copy is removed and native dragging continues. +Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. [useRowDragPreview](../ui-primitives/src/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. The preview does not change the source row's background or drag-and-drop handlers. If the browser rejects a custom drag image, the copy is removed and native dragging continues. ### Hover cards diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index cc7e13b7c7..34da2eb9b8 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -190,9 +190,9 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序 侧边栏隐藏持久化摘要中带有 `origin: 'subagent'` 的行。可见普通行的共享 ongoing loading 来自其已加载 parent 目录中正在运行的直接 child,绝不来自摘要谱系。Child 活动状态使用最新 UI status,尚无该状态时使用 Session 摘要。 -行动画由 [AnimatedRows](src/client/rows/AnimatedRows.tsx) 负责。它仅在 React 提交改变行成员或顺序时读取更新前后的位置,并使用浏览器原生位移与透明度动画。被移除的行以不可交互的副本在滚动列表外淡出,不会延迟 React 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。 +行动画由 [AnimatedRows](../ui-primitives/src/AnimatedRows.tsx) 负责。它仅在 React 提交改变行成员或顺序时读取更新前后的位置,并使用浏览器原生位移与透明度动画。被移除的行以不可交互的副本在滚动列表外淡出,不会延迟 React 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。 -Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。[useRowDragPreview](src/client/rows/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。预览不改变原行的背景或拖放事件处理。如果浏览器拒绝自定义拖拽图片,则移除副本并继续原生拖动。 +Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。[useRowDragPreview](../ui-primitives/src/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。预览不改变原行的背景或拖放事件处理。如果浏览器拒绝自定义拖拽图片,则移除副本并继续原生拖动。 ### 悬浮卡片 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 18967adc92..6436998842 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -23,18 +23,7 @@ } .dragPreview { - position: fixed; - top: 0; - /* WebKit fills transparent snapshot pixels white, including rounded corners. */ background: var(--dsw-specific-sidebar-fill); - pointer-events: none; -} - -.dragPreview > :first-child { - box-sizing: border-box; - margin: 0; - width: 100%; - height: 100%; } .searchResultRow { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 24fa1e1675..291c3f7ff4 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -18,7 +18,7 @@ import { HoverCard, IconArchiveOutlineRegular, IconEditOutlineRegular, IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, IconNewChatOutlineRegular, IconPinFillRegular, IconTrashOutlineRegular, - IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, Tooltip, + IconTriangleRightFillRegular, IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, Tooltip, useRowDragPreview, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' @@ -26,7 +26,6 @@ import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import type { MenuOpenState, WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import css from './Rows.module.css' -import { useRowDragPreview } from './useRowDragPreview.ts' /** The standard locale seat, prop-passed from the browser root. */ type RowTranslate = WorkspaceBrowserProps['t'] @@ -237,7 +236,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = containsCurrentDescendant || (group.expanded && group.containsCurrent) const [menuOpen, setMenuOpen] = useState(false) - const dragPreview = useRowDragPreview() + const dragPreview = useRowDragPreview(css.dragPreview as string) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, { id: 'delete', label: t('delete.workspace'), icon: , danger: true }, @@ -587,7 +586,7 @@ export function SessionNodeItem({ // their drop targets to fellow pinned rows. const draggable = drag !== undefined && !row.blank && !row.archived const [menuOpen, setMenuOpen] = useState(false) - const dragPreview = useRowDragPreview() + const dragPreview = useRowDragPreview(css.dragPreview as string) // The menu's open state, bound into the row entries' `useMenuOpenState` hook. const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen]) const rowRef = useRef(null) diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 7a0309630b..fd708fbebe 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -17,7 +17,7 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, HoverCardGroup, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular, IconArchiveOutlineRegular, + AnimatedRows, Button, HoverCardGroup, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular, IconQueueOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, @@ -37,7 +37,6 @@ import { pinCurrentBlank, reconcileManualOrder, sessionMemberIds, UNGROUPED_KEY, } from '../tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx' -import { AnimatedRows } from './AnimatedRows.tsx' import { FLAT_SESSION_ORDER_KEY, type SessionGroupBy } from '../stores.ts' import { WorkspacePickFlow } from '../WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' @@ -672,6 +671,7 @@ function SessionTree({ {workspaceDropAtListStart &&