diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 00652d3b09..2cd0ed343b 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362 -2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e +2026-08-18-session-history-and-event-transport.md: 5f4aba19d147eae3f49fcefc9a8006d0f557dc6c +2026-08-18-session-history-and-event-transport.zh.md: dcf7e7ffc5ad325756b1fb37dd5ad60d2f0b3147 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 808565ff7d..5f4aba19d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -62,6 +62,8 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream. +The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries. + After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`. In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. @@ -76,7 +78,7 @@ Unexpected normal completion of `$events`, a Host error, a malformed opening fra Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. -Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. +Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. ### General Remote stream model @@ -320,13 +322,15 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re **Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection. +**Send application-level JSON heartbeat frames.** This would expand the strict Remote stream message union and require browser handling for traffic with no business meaning. WebSocket Ping/Pong provides carrier activity without changing logical-stream semantics. + **Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently. **Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly. ## Verification -Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. +Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. @@ -364,6 +368,8 @@ Durable logs repair a missing suffix by sequence number and page; Session contro Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation. +Each resident browser connection adds one empty Ping/Pong exchange per configured interval. Deployments can shorten the interval for stricter idle timeouts without changing the Remote stream protocol or browser code. + Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary. The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 8bd00def45..dcf7e7ffc5 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -62,6 +62,8 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 +Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。 + 首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 @@ -76,7 +78,7 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 -插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。 +Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 ### 通用 Remote stream 模型 @@ -320,13 +322,15 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace **给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。 +**发送应用层 JSON 心跳帧。** 这会扩展严格的 Remote stream message union,并要求浏览器处理没有业务含义的流量。WebSocket Ping/Pong 无需改变 logical stream 语义即可保持 carrier 活跃。 + **继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。 **从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。 ## 验证 -Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 @@ -364,6 +368,8 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。 +每条常驻浏览器连接会按配置间隔增加一次空载荷 Ping/Pong 交换。面对更严格的空闲超时,部署方可缩短间隔,而无需改变 Remote stream 协议或浏览器代码。 + Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。 通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。 diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml new file mode 100644 index 0000000000..10d18004d1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md +2026-08-26-local-submission-echoes.md: 258df8f3f4bdd0441b714c7602273897d2d79fc7 +2026-08-26-local-submission-echoes.zh.md: 57a9523a1eaa399e596f0c8eb6f8913545976f87 diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md new file mode 100644 index 0000000000..258df8f3f4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md @@ -0,0 +1,37 @@ +# Agent Note: Local submission echoes over the prompt rpcId + +Status: implemented + +English | [中文](2026-08-26-local-submission-echoes.zh.md) + +## Problem + +A multi-image prompt spent seconds in client serialization plus host admission before its durable `user/message` existed, and the conversation showed nothing until then: the composer froze read-only, the message appeared only after the full pipeline, and the user could not tell whether the submission had started (#3003). The durable event cannot move earlier — Model-visible ⟺ logged requires the `user/message` to land only after every attachment persists — so the visible submission had to decouple from the durable one. + +## Decision + +**The Session object owns a client-local submission echo, correlated by the prompt's existing `requestId`/`rpcId`.** `session.beginSubmission` synchronously inserts `{requestId, text, images: previews}` into `SessionSnapshot.pendingSubmissions` and flips `promptAttempted`, before the caller serializes anything; the same `requestId` rides the prompt RPC. No new correlation id, no wire-type change, and no session-log change: the host already stamps the prompt's `requestId` into the durable user source as `rpcId`, and the queue projection now carries it as `SessionQueuedItem.rpcId` for prompts that land in the inbox instead of the log (running-turn submissions). + +**Retirement is observation-driven with a one-frame delay; display dedupe is render-time and declarative.** The Session marks an echo observed when a durable `user/message` or queue occurrence with its rpcId arrives (append, window install, or control frame) and removes it one animation frame later — after the conversation assembly's frame, which was scheduled first. ChatView independently hides any echo whose rpcId appears among rendered user/steering nodes or queue rows, so within every render exactly one of echo/durable is visible regardless of store update order. An identified prompt failure, `abandon()`, or disposal retires the echo immediately as failed; the first settlement wins. + +**The composer commits optimistically.** Enter clears the draft, occurrence table, and undo history in one machine transaction and keeps phase `plain`; the send runs as a detached attempt (concurrent sends allowed; the single frozen in-flight slot remains command-only). Failed detached sends are restored together in submission order while the composer is empty or still contains the preceding automatic restoration; a user edit ends that restoration sequence. Draft images remain owned by the detached attempt through echo retirement, so Session scope disposal can release them after they have left the rail. An observed echo gives each preview URL to `HistoricalImageCache.seed` under the admitted reference. The cache exposes that preview synchronously, fetches the durable attachment, replaces the preview with the canonical URL, and revokes both URLs with their respective lifetimes. Direct subagent continuations do not register echoes because their transport assigns a different RPC identity and image input is unsupported. + +Client image encoding switched from the synchronous chunked-`btoa` loop to `FileReader.readAsDataURL` (native encode). The browser→host transport still ships one base64 JSON envelope; that remaining #2885 transport work is out of scope here. + +## Consequences + +The submit click paints its message and docks the composer on the same frame, for ordinary text and image prompts, while admission timing is unchanged. The composer never freezes for default sends, so drafts can be typed and sent during a flight; the machine's `submitting` phase now occurs only for command submissions. A prompt whose RPC response is lost but whose admission succeeded converges through observation instead of double-posting. An admitted image keeps its local preview until the durable bytes resolve, then displays the host-authoritative rendition without a loading placeholder. + +## Verification + +Session client specs pin synchronous insertion, requestId threading, event/queue/window observation, one retirement when queue and durable observations coincide, frame-delayed removal, abandon, and disposal. Machine and shell specs pin optimistic commit, concurrent detached settlement, ordered multi-failure restoration, image-only cancellation, and image ownership through scope disposal. ChatView specs pin flow-tail rendering and node- and queue-keyed dedupe with the echo still in the snapshot. Host control specs pin the queue rpcId projection; cache and attachment specs pin synchronous seeded display, canonical replacement, and URL revocation. The connection fixture echoes `requestId`, and the `fresh-round-trip` recorded-session snapshot captures the local echo before durable admission. + +## Alternatives considered + +**A new `clientSubmissionId` threaded through the wire and the user source.** Rejected: `requestId` already exists end-to-end (`user-rpc` source member), so a second id would duplicate the correlation and touch wire validation for nothing. + +**Retire the echo synchronously on event ingestion.** Rejected: the chat assembly publishes on an animation frame, so synchronous removal blanks the message for a frame. The steering queue mirror historically accepted that race; the echo path removes it via render-time dedupe plus the delayed retirement. + +**Render echoes through the conversation assembler as synthetic nodes.** Rejected: the assembler is driven by durable session events only, and a client-only node kind would widen the closed `ConversationNode` union into every target's `assertNever`; the `PartialAssistant`-style side-channel state matches the existing precedent. + +**Keep the composer frozen and only add the echo.** Rejected: the issue's acceptance requires consecutive and concurrent submissions, and a frozen composer reintroduces the perceived hang the echo exists to remove. diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md new file mode 100644 index 0000000000..57a9523a1e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md @@ -0,0 +1,37 @@ +# Agent Note:基于 prompt rpcId 的本地提交回显 + +状态:implemented + +[English](2026-08-26-local-submission-echoes.md) | 中文 + +## 问题 + +多图 prompt 在客户端序列化加 host admission 上要花数秒,durable `user/message` 在此之前不存在,会话在此期间什么都不显示:composer 冻结为只读,消息在整条流水线结束后才出现,用户无法判断提交是否已经开始(#3003)。durable event 无法提前,Model-visible ⟺ logged 要求 `user/message` 只能在全部附件持久化后落盘,因此可见的提交必须与 durable 的提交解耦。 + +## 决定 + +**Session 对象持有客户端本地的提交回显,用 prompt 现有的 `requestId`/`rpcId` 关联。**`session.beginSubmission` 在调用方序列化任何内容之前,同步把 `{requestId, text, images: previews}` 写入 `SessionSnapshot.pendingSubmissions` 并翻转 `promptAttempted`;同一个 `requestId` 随 prompt RPC 发出。没有新关联 id,没有 wire 类型改动,也没有 session log 改动:host 本就把 prompt 的 `requestId` 写进 durable user source 的 `rpcId`,queue 投影现在把它作为 `SessionQueuedItem.rpcId` 携带,覆盖落进 inbox 而非 log 的 prompt(运行中 turn 的提交)。 + +**退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。 + +**Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行,允许并发发送,唯一的冻结 in-flight 槽只留给命令。多个 detached 发送失败时,只要 composer 为空或仍是上一次自动还原的内容,就按提交顺序合并还原;用户编辑后停止这一轮自动还原。草稿图片由 detached attempt 持有到回显退休,因此图片离开 rail 后销毁 Session scope 仍能释放它们。回显以 observed 退休时,`HistoricalImageCache.seed` 把每个预览 URL 挂到 admitted 引用名下。缓存同步公开预览 URL,同时读取 durable 附件;读取完成后用规范化 URL 替换预览,并按各自生命周期撤销两个 URL。直接 subagent continuation 不注册回显,因为它的 transport 会分配另一个 RPC id,而且不支持图片输入。 + +客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。 + +## 后果 + +普通文本与图片 prompt 点击提交后会在当帧显示消息并让 composer 落底,admission 时机不变。默认发送不再冻结 composer,发送期间可以继续输入和提交;machine 的 `submitting` 阶段只用于命令提交。RPC 响应丢失但 admission 已成功的 prompt 通过观察确认结果,不会重复发送。图片在 durable 字节返回前显示本地预览,随后显示 host 保存的版本,中间没有加载占位。 + +## 验证 + +Session client spec 覆盖同步插入、requestId 透传、event、queue 与窗口观察、queue 和 durable 同时观察时只退休一次、延帧移除、abandon 与销毁。Machine 与 shell spec 覆盖乐观提交、并发 detached settlement、多个失败按提交顺序还原、图片纯发送的取消,以及图片随 scope 销毁而释放。ChatView spec 覆盖流尾渲染,以及回显仍在 snapshot 时按节点和队列去重。Host control spec 覆盖 queue rpcId 投影;缓存与附件 spec 覆盖 seed 首帧显示、规范化替换和 URL 撤销。connection fixture 回显 `requestId`,`fresh-round-trip` 的 recorded-session snapshot 在 durable admission 前记录本地回显。 + +## 考虑过的替代方案 + +**新增 `clientSubmissionId` 贯穿 wire 与 user source。**否决:`requestId` 已端到端存在(`user-rpc` source 成员),第二个 id 会重复关联并平白触碰 wire 校验。 + +**事件入库时同步移除回显。**否决:会话组装按动画帧发布,同步移除会让消息空一帧。steering 队列镜像历史上接受了这个竞态;回显路径用渲染期去重加延帧退休消除它。 + +**把回显作为合成节点走会话 assembler。**否决:assembler 只由 durable session event 驱动,客户端专属的节点 kind 会把闭合的 `ConversationNode` 联合扩进每个 target 的 `assertNever`;`PartialAssistant` 式的旁路状态符合现有先例。 + +**保持 composer 冻结,只加回显。**否决:issue 验收要求连续与并发提交,冻结的 composer 会重新引入回显本要消除的卡顿感。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml new file mode 100644 index 0000000000..138ca27a49 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md +2026-08-27-process-table-snapshots.md: 27c364607ca1e03a926c309f26007477a8785636 +2026-08-27-process-table-snapshots.zh.md: 9c5fe65bb83a0d04fe5639b3ffefcf377c3588ef diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md new file mode 100644 index 0000000000..27c364607c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md @@ -0,0 +1,71 @@ +# Agent Note: Process-table snapshots replace per-question inspector reads + +Status: implemented + +English | [中文](2026-08-27-process-table-snapshots.zh.md) + +## Problem + +A terminal readiness poll asks the platform three questions: the shell's descendant tree, its POSIX session membership, and whether each tracked descendant is still running. When each question reads the process table independently, the poll's cost scales with the number of descendants the running command spawned. + +On macOS each read is a `/bin/ps -axo` fork that parses the entire table — 14.33 ms for 795 processes on the measured host. `LocalTerminalHandle.inspectForeground()` reads the tree once and then asks liveness once per tracked descendant, so one poll costs N+1 table reads for N descendants. `dsh-terminal-bash` polls every 50 ms for up to 30 s, and `ProcessInspectorInternals.exec` is `execFileSync`, so each poll blocks the event loop for its full duration. + +Measured by driving the production `MacProcessInspector` against a real process tree: + +| tracked descendants | one poll | share of the 50 ms interval | +|---|---|---| +| 0 | 18.0 ms | 36% | +| 1 | 33.8 ms | 68% | +| 2 | 49.1 ms | 98% | +| 5 | 87.1 ms | 174% | +| 10 | 178.4 ms | 357% | + +Any command spawning two or more children — a pipeline, `make`, `pnpm`, `git` — saturates the host event loop until it exits. + +Teardown has the same structure. `signalProcess` fences each signal against PID reuse by asking liveness itself, so signalling N members costs N table reads. + +## Decision + +`ProcessInspector.snapshot()` returns a `ProcessSnapshot`, one observation of the process table that answers `tree(rootPid)`, `session(sessionId)`, and `alive(identity)`. It replaces the three per-question methods; the inspector's remaining surface is `foregroundPgid`, `isStdinWaiting`, `signalGroup`, and `signalProcess`. + +Each caller captures one snapshot and answers every question of a single pass from it. `LocalTerminalHandle.descendants()` takes a snapshot, reads the tree and session from it, and filters survivors through the same `alive`, so a readiness poll costs one table read regardless of descendant count. `waitForMembers` captures a fresh snapshot per polling iteration, because its whole purpose is observing change. + +Signalling does not share that observation. `ProcessInspector.isAlive(identity)` answers current state from the narrowest per-identity source a platform offers — one `/proc//stat` read on Linux, one `ps` table on macOS, one process-handle check on Windows — and `signalProcess` takes that fence immediately before delivering the signal. An observation cannot stand in for it: the observation preserves the original PID-to-start-time pairing, so a recycled PID would still match it and take a signal meant for the process that exited. Reading the fence per target also keeps a failed read costing one target instead of the rest of a teardown round, which is what the [synchronous exit-cleanup contract](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md) requires. + +`signalMembers` and `waitForMembers` return before capturing anything when a round has no members, so a command that spawned no descendants pays no table read for its teardown sweeps. + +Platform differences live in how a snapshot is built, not in what it promises: + +- **macOS** builds it from one `ps` table. That table exposes neither a session id nor a state column, so `session` is empty and `alive` reports presence with a matching start identity. +- **Linux** walks `/proc` once, carrying each entry's parent, start identity, session, and state. `alive` treats the `Z`, `X`, and `x` states as quiescent, as a per-pid `stat` read did. +- **Windows** enumerates Toolhelp32 lazily, on the first `tree` question. It has no POSIX sessions, and answers `alive` from the live process handle, because wait state is not a table column there — so a snapshot asked only for liveness never enumerates. The terminal's Windows teardown polls liveness every 25 ms and would otherwise walk and discard the whole table each time. + +`PosixProcessSnapshot` holds both POSIX shapes: a row's `session` and `state` are `undefined` where the platform's table omits them, which is what makes the macOS answers fall out of the shared implementation instead of a second class. + +## Testing + +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` drives a real `MacProcessInspector` over an injected `exec` and asserts one foreground inspection performs exactly one `-axo` table read at 0, 2, and 10 descendants. That count, not wall time, is the durable invariant: it holds on any host and fails the moment a caller re-reads the table per member. The same file pins that a signalling round with no members captures nothing, and that a capture failure during synchronous host exit still lets the PTY root be killed. + +`process-inspector.spec.ts` pins the fence directly: an identity observed alive and then absent from the table takes no signal. `windows-inspector.spec.ts` pins that a snapshot answering only liveness performs no Toolhelp32 enumeration. + +## Alternatives considered + +**A batched `aliveMembers(members)` call, leaving the other methods alone.** This collapses the per-member reads and is a much smaller edit, but the tree read stays separate, so a macOS poll still forks `ps` twice plus the `tpgid` read — about 32 ms at 10 descendants, still 64% of the 50 ms interval. The event loop remains mostly blocked, so the measured problem survives the fix. + +**Caching the macOS table inside `MacProcessInspector` behind a short TTL.** This needs no interface change, but it makes staleness invisible: a caller cannot tell whether a liveness answer came from this instant or from the end of the previous poll, and a signal decided on a stale row is exactly what the PID-reuse fence exists to prevent. Hidden caching also conflicts with the repository's preference for explicit defaulting and explicit boundaries. + +**Fencing signals with the round's shared observation.** This removes the last per-member read and was the shape first implemented here. Review rejected it: the fence exists to defeat PID reuse, and an observation defeats the fence instead, because it carries the original PID-to-start-time pairing forward. The window is narrow — the kills in one round are microseconds apart, against a PID space of 99999 on macOS and 4194304 on Linux — but the `README` states the guarantee without qualification, and buying microseconds of teardown time by weakening it is the wrong trade. Keeping both `snapshot().alive` and `isAlive` is therefore not two ways to ask one question: one asks what the table showed, the other asks what is true now, and only the second may decide a signal. + +**Making `exec` asynchronous instead of reducing the read count.** An async `execFile` stops the poll from blocking the loop but still forks N+1 processes per poll; on a busy machine that trades a stall for sustained fork pressure. It remains a worthwhile follow-up on top of the reduced count, not a substitute for it. + +## Consequences + +A readiness poll's process-table cost is now constant in descendant count. On macOS one poll performs one full table read plus the small `tpgid` read, which is the 0-descendant cost in the table above for every descendant count. + +Teardown keeps its previous per-signal cost: one narrow liveness read per target, which on macOS is one `ps` fork per member. That cost was never the measured problem — a terminal tears down once, while its readiness path polls up to 600 times — so the fix deliberately spends it to keep the fence reading current state. + +A snapshot is a point-in-time view, and the type's documentation says so. `waitForMembers` re-captures per iteration because observing change is its purpose, and no signal is ever decided from a captured view. + +Every `ProcessInspector` implementation and test fake carries the new shape, including the Windows inspector and the `dsh-terminal-bash` session fake. Test fakes that previously replaced `processTree`, `processSession`, or `isAlive` to stage a scan now replace the corresponding per-question read hook, which keeps their staging behavior and call-counting identical. + +The synchronous `execFileSync` boundary and the fixed 50 ms poll interval are unchanged; both remain open follow-ups for the same readiness path. diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md new file mode 100644 index 0000000000..9c5fe65bb8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md @@ -0,0 +1,71 @@ +# Agent Note: Process-table snapshots replace per-question inspector reads + +Status: implemented + +[English](2026-08-27-process-table-snapshots.md) | 中文 + +## Problem + +一次终端就绪轮询要向平台问三个问题:shell 的子进程树、它的 POSIX 会话成员、以及每个被跟踪的子进程是否仍在运行。当每个问题各自去读一次进程表时,这次轮询的代价就随着当前命令派生出的子进程数量增长。 + +在 macOS 上每一次读取都是一次 `/bin/ps -axo` fork,并解析整张表——在实测主机上 795 个进程需要 14.33 ms。`LocalTerminalHandle.inspectForeground()` 读一次树,然后按每个被跟踪的子进程各问一次存活,所以 N 个子进程的一次轮询要读 N+1 次表。`dsh-terminal-bash` 每 50 ms 轮询一次、最长 30 s,而 `ProcessInspectorInternals.exec` 是 `execFileSync`,因此每次轮询在其整个时长内阻塞事件循环。 + +用生产环境的 `MacProcessInspector` 驱动真实进程树实测: + +| 被跟踪的子进程数 | 一次轮询 | 占 50 ms 间隔的比例 | +|---|---|---| +| 0 | 18.0 ms | 36% | +| 1 | 33.8 ms | 68% | +| 2 | 49.1 ms | 98% | +| 5 | 87.1 ms | 174% | +| 10 | 178.4 ms | 357% | + +任何派生两个及以上子进程的命令——一条管道、`make`、`pnpm`、`git`——都会把宿主事件循环打满,直到它退出。 + +拆卸路径的结构相同。`signalProcess` 自己去问存活来给每个信号加 PID 复用围栏,因此向 N 个成员发信号要读 N 次表。 + +## Decision + +`ProcessInspector.snapshot()` 返回一个 `ProcessSnapshot`,即对进程表的一次观察,由它回答 `tree(rootPid)`、`session(sessionId)` 和 `alive(identity)`。它取代了那三个按问题划分的方法;检查器剩下的接口是 `foregroundPgid`、`isStdinWaiting`、`signalGroup` 和 `signalProcess`。 + +每个调用方捕获一次快照,并从中回答本次流程的全部问题。`LocalTerminalHandle.descendants()` 取一次快照,从中读取树与会话,并用同一个 `alive` 过滤幸存者,因此一次就绪轮询无论有多少子进程都只读一次表。`waitForMembers` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。 + +发信号不共用这份观察。`ProcessInspector.isAlive(identity)` 用各平台最窄的按标识来源回答当前状态——Linux 读一个 `/proc//stat`、macOS 读一次 `ps` 表、Windows 查一次进程句柄——`signalProcess` 在投递信号前就地取这道围栏。观察无法代替它:观察把原始的「PID 与起始时间」配对保留了下来,因此被复用的 PID 仍会与之匹配,并领走本该发给已退出进程的信号。逐目标读取围栏还让一次失败的读取只损失一个目标,而不是整轮拆卸的其余部分,这正是[同步退出清理约定](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)的要求。 + +`signalMembers` 与 `waitForMembers` 在一轮没有成员时直接返回、不做任何捕获,因此没有派生子进程的命令,其拆卸扫描不付表读取代价。 + +平台差异体现在快照如何构建,而不在它承诺什么: + +- **macOS** 由一张 `ps` 表构建。该表既不暴露会话 id 也不暴露状态列,所以 `session` 为空,`alive` 报告的是「存在且起始标识匹配」。 +- **Linux** 遍历一次 `/proc`,携带每个条目的父进程、起始标识、会话与状态。`alive` 把 `Z`、`X`、`x` 状态视为静止,与按 pid 读 `stat` 的判定一致。 +- **Windows** 把 Toolhelp32 枚举惰性化到第一次 `tree` 提问。它没有 POSIX 会话,并且从活的进程句柄回答 `alive`,因为等待状态在那里不是表的一列——所以只问存活的快照永不枚举。终端在 Windows 上的拆卸每 25 ms 轮询一次存活,否则每次都会遍历整张表再丢弃。 + +`PosixProcessSnapshot` 同时承载两种 POSIX 形态:当平台的表省略某字段时,该行的 `session` 与 `state` 为 `undefined`,这使得 macOS 的答案从共享实现中自然得出,而不必新增一个类。 + +## Testing + +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` 通过注入的 `exec` 驱动真实的 `MacProcessInspector`,断言一次前台检查在 0、2、10 个子进程下都恰好执行一次 `-axo` 表读取。这个次数——而非墙钟时间——才是持久不变量:它在任何主机上都成立,并且在任何调用方按成员重复读表的那一刻失败。同一文件还钉住:没有成员的一轮信号不做任何捕获;同步主机退出期间捕获失败时,PTY root 仍会被杀掉。 + +`process-inspector.spec.ts` 直接钉住围栏:一个先被观察为存活、随后从表中消失的标识不会收到信号。`windows-inspector.spec.ts` 钉住只回答存活的快照不执行 Toolhelp32 枚举。 + +## Alternatives considered + +**只加一个批量的 `aliveMembers(members)`,其余方法不动。** 这能合并按成员的读取,改动也小得多,但树的读取仍然独立,因此 macOS 上一次轮询仍要 fork 两次 `ps` 外加 `tpgid` 读取——10 个子进程时约 32 ms,仍占 50 ms 间隔的 64%。事件循环依旧大部分时间被阻塞,实测到的问题在修复之后依然存在。 + +**在 `MacProcessInspector` 内部用短 TTL 缓存 macOS 的表。** 这不需要改接口,但它让陈旧性不可见:调用方无法分辨一个存活答案来自此刻还是来自上一次轮询结束时,而基于陈旧行发出的信号正是 PID 复用围栏要防止的事情。隐式缓存也与仓库偏好显式默认与显式边界的立场冲突。 + +**用整轮共享的观察来给信号加围栏。** 这能去掉最后一处按成员的读取,也是本次最初实现的形态。评审否决了它:围栏的存在就是为了击败 PID 复用,而观察反过来击败了围栏——因为它把原始的「PID 与起始时间」配对一路带了下来。窗口确实很窄(一轮里各次 kill 相隔微秒级,而 macOS 的 PID 空间是 99999、Linux 是 4194304),但 `README` 是无条件地声明这条保证的,用削弱它来换取微秒级的拆卸时间是错误的取舍。因此同时保留 `snapshot().alive` 与 `isAlive` 并不是同一个问题的两种问法:前者问表当时显示了什么,后者问此刻什么为真,而只有后者可以决定一次信号。 + +**把 `exec` 改成异步,而不是减少读取次数。** 异步的 `execFile` 能让轮询不再阻塞事件循环,但每次轮询仍然 fork N+1 个进程;在繁忙的机器上这是把一次停顿换成了持续的 fork 压力。它在减少读取次数之上仍是值得做的后续项,而不是它的替代。 + +## Consequences + +一次就绪轮询的进程表代价现在与子进程数量无关。在 macOS 上,一次轮询执行一次完整表读取加一次小的 `tpgid` 读取,也就是上表中 0 子进程那一行的代价,对任意子进程数量都成立。 + +拆卸保持原有的按次代价:每个目标一次窄的存活读取,在 macOS 上即每个成员一次 `ps` fork。这项代价从来不是实测到的问题——一个终端只拆卸一次,而它的就绪路径最多轮询 600 次——所以本次修复刻意付出它,以保证围栏读的是当前状态。 + +快照是一个时间点视图,该类型的文档也这样声明。`waitForMembers` 每轮重新捕获是因为观察变化正是它的用途;任何信号都不会从一份已捕获的视图上做决定。 + +每个 `ProcessInspector` 实现与测试替身都采用新形态,包括 Windows 检查器和 `dsh-terminal-bash` 的会话替身。此前通过替换 `processTree`、`processSession` 或 `isAlive` 来编排扫描的测试替身,现在替换对应的按问题读取钩子,其编排行为与调用计数保持不变。 + +同步的 `execFileSync` 边界与固定的 50 ms 轮询间隔未做改动;两者都仍是同一条就绪路径上待办的后续项。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index d87d9b8af7..01d1644420 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 40a34460171777a1cf2a8d2fb940d74012113c1c -2026-07-29-pnpm-setup-runner-isolation.zh.md: 4c307bc00e980975d35a1a0c3e776d5fe371cec9 +2026-07-29-pnpm-setup-runner-isolation.md: d5cd02cceba920368f0dfe6535e4bd03ee075417 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 5266112224b940c06ea2567247532eb15ce7fce8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 40a3446017..d5cd02cceb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -10,7 +10,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Decision -Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). +Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory, and the run/attempt suffix also protects sequential jobs on the same runner from a stale locked `pnpm.exe`. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). [The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml`, `ci-master.yml`, and `build-exe-for-python-sdk.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 4c307bc00e..5266112224 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 +[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录;run/attempt 后缀还能防止同一 runner 上顺序作业因残留的锁定 `pnpm.exe` 而失败。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 [工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml`、`ci-master.yml` 与 `build-exe-for-python-sdk.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml index 8cf0c30e2b..b8b98a6ecb 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md -2026-07-29-ask-question-web-presentation.md: fd6c326ccadc83cb9d2edc0151dd94984f2bca8b -2026-07-29-ask-question-web-presentation.zh.md: c26fe91c91280c3f5596a30a3e3483ed1fa784b4 +2026-07-29-ask-question-web-presentation.md: 280671570ca015457ab3c17241cea3e973b88fd2 +2026-07-29-ask-question-web-presentation.zh.md: 9ad687d655de19ea913ddc5255fb6426ef0fbb77 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md index fd6c326cca..280671570c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -14,6 +14,10 @@ Separately, the composer visuals had drifted from the current design: an expand- A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `tool.call.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrowed to `PendingWait<'approval'>` and `ChatView` filtered the pending list to approval waits, leaving the placeholder card to approvals alone; the approval composer takeover ([web permission and approval](2026-07-23-web-permission-and-approval.md)) has since removed it entirely. +A successful row keeps the collapsed transcript to its one-line verdict and replaces generic JSON in the expanded body with a read-only question transcript. The presenter validates questions from call args and answers from result content, pairs them by the echoed stable `id`, preserves call order, and renders each model-authored question in a muted label followed by its selected and custom answer lines in primary text. A skipped question shows the localized `Not answered` verdict. The card caps its height and scrolls internally. Invalid JSON, duplicate ids, missing ids, count mismatches, unknown answer ids, and invalid visible fields all retain the generic input/output card instead of presenting a partial or incorrectly paired transcript. + +A cancelled or interrupted row has no answer payload to pair. Its expanded card shows a localized set-level verdict that no answers were submitted followed by the original model-authored questions; it does not label cancellation as a per-question skip or fabricate answer records. Invalid call args retain the generic diagnostic card. Cancellation uses the neutral settled state because the user chose it deliberately; interruption keeps the amber stopped state. + The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim. Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim. @@ -24,11 +28,13 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover **Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface. -**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list. +**Show the questions or answers in the collapsed transcript row.** Rejected: the composer takeover owns answer collection, and the row convention (`todo_write`) keeps the collapsed line scannable. The row therefore reports only the outcome until expanded; its expanded body owns the read-only question transcript. + +**Keep raw input and output JSON in the expanded body.** Rejected: the payload preserves all information but makes the user's own answers or the cancelled questions difficult to scan. The structured view presents the same authored text while retaining raw JSON as the fail-closed fallback when question parsing or answer pairing is not trustworthy. **Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere. -**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale. +**Keep the row verdicts in English.** Initially deferred, then superseded when Client UI copy became locale-owned: the current conversation dictionaries localize the row verdicts and the expanded card's skipped-answer label, while model-authored questions and answers remain verbatim. **Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel. @@ -36,10 +42,10 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover `ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone. -The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. +The expanded transcript adds a typed, plain-data question-card model to the shared `ToolRow`; other tool views retain their existing generic or specialized cards. The question row reads only persisted call and result fields and does not add a Host presentation field. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. `ui-user-questions` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`. ## Verification -`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. +`ui-tool` tests pin the row's waiting/answered/skipped/cancelled/interrupted matrix, readable id-based pairing, selected and custom answer lines, no-answer verdicts, and fail-closed fallback. Keyless assembled-Web snapshots expand successful and cancelled question rows and record their readable transcripts. `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md index c26fe91c91..9ad687d655 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -14,6 +14,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, 一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `tool.call.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 曾收窄为 `PendingWait<'approval'>`,`ChatView` 曾将待处理列表过滤为仅审批等待,使占位卡片只服务于审批;其后审批输入区接管([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md))已将它彻底移除。 +成功的问题行仍让折叠后的会话记录只显示单行裁决,并在展开内容中用只读问答记录取代通用 JSON。presenter 校验调用参数中的问题与结果内容中的回答,按回显的稳定 `id` 配对、保持调用顺序,并将每段模型撰写的问题显示为弱化标签,下方用主要文字显示已选项和自定义回答。被跳过的问题显示本地化的 `未回答` 裁决。卡片限制最大高度并在内部滚动。无效 JSON、重复 id、缺少 id、数量不符、未知回答 id 与无效可见字段都保留通用输入/输出卡片,不呈现不完整或错误配对的记录。 + +已取消或已中断的问题行没有可供配对的回答载荷。其展开卡片先显示本地化的整组裁决,说明未提交回答,再列出模型撰写的原始问题;它不会把取消标成逐题跳过,也不会虚构回答记录。无效调用参数保留通用诊断卡片。取消是用户主动选择,使用中性 settled 状态;中断保留琥珀色 stopped 状态。 + 输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。 输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过 slot inject face 向条目提供绑定命名空间的翻译器和作为 hooks compartment 来源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。 @@ -24,11 +28,13 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, **继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。 -**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。 +**在折叠的会话记录行显示问题或回答。** 否决:输入区接管拥有回答收集,而行的约定(`todo_write`)让折叠单行保持易扫描。因此该行在展开前只报告结果,展开内容拥有只读问答记录。 + +**在展开内容中保留原始输入与输出 JSON。** 否决:载荷保留了全部信息,却让用户自己的回答或已取消的问题难以浏览。结构化视图展示相同的作者文本;问题解析或回答配对不可信时仍以原始 JSON 作为 fail-closed 回退。 **用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。 -**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。 +**让行内裁决保持英文。** 最初延期,随后在 Client UI 文案改由 locale 管理时被取代:当前 conversation 词典本地化行内裁决与展开卡片的未回答标签,而模型撰写的问题和回答保持原样。 **保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。 @@ -36,10 +42,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, `ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的 slot 注册。专用的 `todo-row.module.css` 已删除。 -行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 +展开问答记录为共享 `ToolRow` 增加类型化的纯数据问题卡片模型;其他工具视图保留原有的通用或专用卡片。问题行只读取已持久化的调用与结果字段,不增加 Host 呈现字段。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 `ui-user-questions` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其约定(`QuestionComposerInjected`)与消费方一起放在 `contract/slots.ts`。 ## 验证 -`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和 slot 注册;`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 +`ui-tool` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted 矩阵、可读的 id 配对、已选项与自定义回答行、无回答裁决和 fail-closed 回退。无密钥的组装 Web 快照分别展开成功与取消的问题行并记录其可读内容。`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index 39403b4062..5e0f82335f 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: bf4788b141370933197d9ec1a1ad3c8e76a6740c -2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7 +2026-08-18-model-selected-subagent-routes.md: 9cdbdbec3b79a93043fa6ae95a6a6dedf6072086 +2026-08-18-model-selected-subagent-routes.zh.md: 3b81f5417339325dd60884049a853d1094a9f1eb diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index bf4788b141..9cdbdbec3b 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -12,15 +12,15 @@ The model also needs a bounded way to discover live providers and model-owned ef ## Decision -`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its Agent-scoped `modelSelectionSettings` instance resolves a Session policy and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. The policy uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md); there is no unrestricted static mode. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. A settings-enabled instance against a provider without the capability fails the plugin mount. Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists authorized registered providers; with `provider` it calls that adapter's advisory model catalog only after authorization; with `provider` and `model` it authorizes the exact route before resolving its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-off Host-owned `subagent-model-selection` setting with an explicit `enabled` switch and `allowedModels` list. The Plugins settings page stores both fields atomically. A new top-level Session snapshots the route policy as `subagent/model-selection-policy` when the setting is enabled, before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. Discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. -Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. +Shipped `subagent_fork` instances do not read model-selection settings even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix. @@ -28,7 +28,7 @@ The delegation definition is static across adapter registration and catalog chan ## Alternatives considered -**Keep a deployment-configured route allowlist.** Rejected because it duplicates the live LLM registry, requires configuration before the model can use an already registered route, and creates a second policy surface for clients to edit. Deployments that must restrict LLM access should control which provider routes they register. +**Require a deployment-configured route allowlist for static enablement.** Rejected because it duplicates the live LLM registry and requires configuration before a custom composition can use an already registered route. The shipped user-owned preference is a distinct authorization decision and is documented by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). **Render the live adapter catalog in every delegation description.** Rejected because one provider can advertise hundreds of models, inflating every request, and catalog changes would rewrite an early cache-prefix definition. The on-demand directory keeps mutable data out of the fixed schema. @@ -48,8 +48,8 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences -- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. -- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation. +- A settings-enabled Session can select only its recorded exact child LLM routes; disabled Sessions omit and reject model-facing route fields. +- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. - Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 1d83e2e91f..3b81f54173 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -12,15 +12,15 @@ Status: implemented ## 决策 -只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 +只有 Agent 作用域的 `modelSelectionSettings` 实例解析出 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。该策略使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权;不存在无限制静态模式。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果 settings 已启用的实例缺少该提供方能力,插件挂载会失败。 提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已授权且已注册的提供方;提供 `provider` 时先授权,再调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时会先授权精确路由,再解析其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册 Host 自有的 `subagent-model-selection` 设置,其中包含默认关闭的显式 `enabled` 开关与 `allowedModels` 列表。Plugins 设置页会原子保存两个字段。设置启用时,新的顶层 Session 会在任何模型请求之前,把路由策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 -随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 +随附的 `subagent_fork` 实例不会读取模型选择设置,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。 @@ -28,7 +28,7 @@ Status: implemented ## 考虑过的替代方案 -**保留部署配置的路由允许列表。** 不采用,因为它重复实时 LLM 注册表,要求先配置才能让模型使用已经注册的路由,并为客户端增加第二套策略编辑界面。需要限制 LLM 访问的部署应控制所注册的提供方路由。 +**要求静态启用配置部署路由允许列表。** 不采用,因为它会重复实时 LLM 注册表,并要求自定义组合先配置才能使用已经注册的路由。随附的用户自有偏好属于另一项授权决定,由[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)记录。 **在每一份委派描述中渲染实时 adapter 目录。** 不采用,因为一个提供方可能公布数百个模型,从而扩大每次请求,而且目录变化会改写缓存前缀中的早期定义。按需目录让可变数据留在固定 schema 之外。 @@ -48,8 +48,8 @@ Status: implemented ## 结果 -- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 -- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。 +- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 - adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml new file mode 100644 index 0000000000..4ba77e05b1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +2026-08-24-user-authorized-subagent-model-routes.md: 0f9816ae89562545c267d87713c13faecd9efc66 +2026-08-24-user-authorized-subagent-model-routes.zh.md: ca63215a5ec7ae56ae2f077a7fb19fc1e204527c diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md new file mode 100644 index 0000000000..0f9816ae89 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -0,0 +1,43 @@ +# Agent Note: User-authorized subagent model routes + +Status: implemented + +English | [中文](2026-08-24-user-authorized-subagent-model-routes.zh.md) + +## Problem + +Registering an LLM adapter makes its routes reachable, but does not authorize an Agent to choose every reachable model for a child. A single enabled preference over the live adapter registry expands silently when another provider or model appears. The product needs an explicit, stable authorization decision without rendering a potentially large model directory into every parent request. + +## Decision + +The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. + +A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. + +The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. + +Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. + +## Alternatives considered + +**Render the allowed routes in the delegation description.** Rejected because a large or changing list would enlarge every request and invalidate an early prompt prefix. On-demand discovery keeps the fixed schema prefix-stable and logs directory content only when requested. + +**Filter only the settings UI or discovery result.** Rejected because a model can guess a route or retain one from an earlier transcript. Authorization is enforced in the executor that starts the child. + +**Infer enablement from a non-empty `allowedModels` array.** Rejected because disabling would have to discard a useful selection or preserve a non-empty array whose meaning depends on write history. The explicit switch is authoritative, and the settings scope submits both fields in one Host-validated mutation so no intermediate state is persisted. + +**Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized. + +**Read current settings on every discovery or delegation call.** Rejected because a settings edit would silently change a running Session's model-visible capabilities and execution authority. The durable Session snapshot keeps resume and child inheritance deterministic. + +## Consequences + +- New adapter registrations and newly advertised models do not expand user authorization. +- Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. +- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. +- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it. +- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. + +## Related decisions + +The route arguments, adapter preflight, discovery tool, and fork cache restriction remain owned by [model-selected subagent routes](2026-08-18-model-selected-subagent-routes.md). diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md new file mode 100644 index 0000000000..ca63215a5e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 用户授权的 subagent 模型路由 + +Status: implemented + +[English](2026-08-24-user-authorized-subagent-model-routes.md) | 中文 + +## Problem + +注册 LLM 适配器会使其路由可达,但不代表授权 Agent 为子级选择每一个可达模型。针对实时适配器注册表的单一启用偏好,会在另一个提供方或模型出现时静默扩大范围。产品需要一项显式且稳定的授权决定,同时避免把可能很大的模型目录渲染进父 Agent 的每次请求。 + +## Decision + +Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 + +设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 + +固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 + +模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 + +## Alternatives considered + +**在委派描述中渲染允许路由。** 不采用,因为很大或变化的列表会扩大每次请求,并使较早的提示词前缀失效。按需发现会保持固定 schema 的前缀稳定,且只在请求目录时记录其内容。 + +**只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。 + +**从非空 `allowedModels` 数组推断是否启用。** 不采用,因为关闭功能时要么必须丢弃仍有用的选择,要么要保留一个含义取决于写入历史的非空数组。显式开关是权威依据,设置 scope 会在一次由 Host 校验的 mutation 中提交两个字段,因此不会持久化中间状态。 + +**保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。 + +**每次发现或委派调用都读取当前设置。** 不采用,因为设置编辑会静默改变运行中 Session 的模型可见能力和执行权限。持久 Session 快照会让恢复与子级继承保持确定。 + +## Consequences + +- 新适配器注册和新公布模型不会扩大用户授权。 +- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 +- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 +- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。 +- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 + +## Related decisions + +路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)负责。 diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index f324900c9e..b86720a5d3 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -87,7 +87,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -131,7 +131,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -176,7 +176,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -264,7 +264,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} # The benchmark's Windows lanes deliberately skip the store cache like # the independent native Windows job; an empty input disables caching. @@ -355,7 +355,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} # Unlike the larger-runner suite, both platforms cache the store here: # the consolidated topology measures cache mechanics as workload. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45d345b9a3..e7f4ea86e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -109,7 +109,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -173,7 +173,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -261,7 +261,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: @@ -329,7 +329,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} - uses: actions/setup-node@v6 with: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 08c7f13f0f..616dcd1909 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -165,7 +165,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | -| [`debug`](https://github.com/visionmedia/debug) | MIT | +| [`debug`](https://github.com/debug-js/debug) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | diff --git a/apps/cli/tests/profiles/headless/cordis.yml b/apps/cli/tests/profiles/headless/cordis.yml index 759a252b4e..ea31466a70 100644 --- a/apps/cli/tests/profiles/headless/cordis.yml +++ b/apps/cli/tests/profiles/headless/cordis.yml @@ -124,7 +124,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 7829b67adc..7ef63f67a5 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -153,7 +153,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.reasoning_effort).toBe('max') expect(modelRequests[0]?.max_tokens).toBe(1234) - expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models') + expect(tools.map(tool => tool.function?.name)).not.toContain('list_subagent_models') child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4747543ec0..a351cee4ee 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -241,13 +241,19 @@ describe('the shipped Web composition', () => { } }) - it('applies the default-off subagent model-selection preference only to new sessions', async () => { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + it('applies the default-off subagent model allowlist only to new sessions', async () => { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: false, + allowedModels: [], + }) const disabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-disabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }], + }) const enabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-enabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 6ac6d76796..3c3be0922c 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - status: 已保存 minimax-cn。 - list: - listitem: 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 2f7d4a3a30..1d538bcfe4 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index bb129bf2ea..df48328fd3 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index dfeb637b5f..54bf1db3c3 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list - text: 提供方 - combobox "提供方": 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 020f70d095..a302932e65 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 73c66388f3..496443b057 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 54ac42b4a7..08e89d18f8 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -32,6 +32,10 @@ - 'button "展开设置: Agent 循环"': - text: Agent 循环 Agent 如何派发工具调用。 - img + - listitem: + - 'button "展开设置: Subagent"': + - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 + - img - listitem: - 'button "展开设置: 网页搜索"': - text: 网页搜索 DeepSeek 搜索提供方。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 0235f687de..9d7982d7d8 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -76,7 +76,9 @@ describe('web e2e: plugin configuration section', () => { const dialog = await openPlugins() // Every card the shipped web composition exposes: the shell executor, the - // agent loop, and the DeepSeek search provider. + // agent loop, subagent selection, and the DeepSeek search provider. + await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -88,6 +90,45 @@ describe('web e2e: plugin configuration section', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('persists selected adapter routes as the subagent model allowlist', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) + const dialog = await openPlugins() + await dialog.getByText('Subagent', { exact: true }).click() + const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' }) + + await toggle.click() + const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' }) + await models.waitFor({ timeout: 10_000 }) + const firstModel = models.getByRole('checkbox').first() + await firstModel.check() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + + const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' }) + await expandSubagent.waitFor({ timeout: 5_000 }) + await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('enabled: true') + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') + await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true) + expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0) + + await toggle.click() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expandSubagent.waitFor({ timeout: 5_000 }) + await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('stages an edit and writes it only when saved', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write')) const dialog = await openPlugins() @@ -109,6 +150,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 }) .toBe(true) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() // Presence in the user layer is what the badge reports, and the reset is // offered only for a field that has one. await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) @@ -167,6 +211,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 }) .toBe(false) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() expect(await timeout.inputValue()).toBe('60000') expect(await dialog.getByText('已覆盖').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 4dff77b911..d0956666b0 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -17,7 +17,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -29,7 +29,9 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') // Final golden: the answered transcript — the question resolved into its tool // round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') +const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md') const MODE = webSnapshotMode() +const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e' // The composer's own growth cap, in text lines (QuestionComposer.module.css // .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants @@ -61,6 +63,57 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: // collapsed row painting its copy outside its own box. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Replace the successful Tool settlement and omit the answer-dependent model step. */ +function cancelledFixture(fixture: string): string { + let replaced = false + const lines: string[] = [] + for (const line of fixture.trimEnd().split('\n')) { + const event: unknown = JSON.parse(line) + if (!isRecord(event)) throw new Error('question fixture event is invalid') + if (event.type === 'session') { + // Keep the derived session's relative-time header stable as the source + // fixture ages. + event.createdAt = Date.now() + lines.push(JSON.stringify(event)) + continue + } + if (replaced) { + const data = event.data + if ((event.type === 'step/end' && isRecord(data) && data.step === 1) + || event.type === 'turn/end') lines.push(line) + continue + } + if (event.type !== 'tool/result') { + lines.push(line) + continue + } + const data = event.data + if (!isRecord(data)) throw new Error('question fixture tool/result data is invalid') + const message = data.message + if (!isRecord(message) || !Array.isArray(message.content) || !isRecord(message.content[0])) { + throw new Error('question fixture tool/result message is invalid') + } + message.content[0].content = [{ + type: 'text', + text: 'Error: the user cancelled ask_user_question', + }] + message.content[0].isError = true + data.error = { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + } + replaced = true + lines.push(JSON.stringify(event)) + } + if (!replaced) throw new Error('question fixture has no tool/result event') + return `${lines.join('\n')}\n` +} + describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold let browser: Browser @@ -237,7 +290,13 @@ describe('web e2e: resident question composer round trip', () => { 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) // Golden of the answered transcript: the ask_user_question round trip - // rendered as history (question tool row + DONE), composer takeover gone. + // rendered as history (expanded readable answers + DONE), composer takeover gone. + 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 }) + 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.getByText(/"answers"/).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) @@ -285,13 +344,72 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) - it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { +}) + +describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { + let cancelledScaffold: WebScaffold + let cancelledBrowser: Browser + let cancelledPage: Page + let cancelledTripwire: ReturnType + + beforeAll(async () => { + cancelledScaffold = await launchWebScaffold({}) + await seedSession( + cancelledScaffold, + cancelledFixture(await readFile(FIXTURE, 'utf8')), + CANCELLED_SEED_ID, + ) + cancelledBrowser = await chromium.launch() + cancelledPage = await newEnglishPage(cancelledBrowser) + cancelledTripwire = watchConsole(cancelledPage) + await cancelledPage.goto(cancelledScaffold.authenticatedUrl, { waitUntil: 'load' }) + await cancelledPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = cancelledPage.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = cancelledPage.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + }, 120_000) + + afterAll(async () => { + await cancelledBrowser?.close() + await cancelledScaffold?.close() + }) + + it('expands to the cancellation verdict and original questions', async () => { + onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row')) + const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true }) + await row.waitFor({ timeout: 15_000 }) + await row.click() + + await cancelledPage + .getByText('This question set was cancelled before answers were submitted.', { exact: true }) + .waitFor({ timeout: 10_000 }) + await cancelledPage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await cancelledPage.getByText(/"questions"/).count()).toBe(0) + expect(await cancelledPage + .getByText('Error: the user cancelled ask_user_question', { exact: true }).count()).toBe(0) + + const snapshot = (await captureStableAria( + cancelledPage, + '[class*="centerCol"]', + cancelledScaffold.workspaceCwd, + )).split(CANCELLED_SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(CANCELLED_EXPECTED, snapshot, MODE) + expect(cancelledTripwire.pageErrors).toEqual([]) + expect(cancelledTripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'ui.expected.md', 'sidebar.expected.md', 'composed.expected.md', 'answered.expected.md', + 'cancelled.expected.md', ]) }) }) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 7413576885..44a81930d6 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } fro const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) +const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) const WEB_CONTEXT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/web-context.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -72,7 +73,21 @@ describe('web e2e: fresh round trip through the real assembly', () => { // Arm the host-side settled barrier BEFORE the send click. const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) - await input.press('Enter') + const echoSnapshot = await input.evaluate(async (element, prompt) => { + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, + })) + // sendSession registered its paint yield first. This frame observes the + // committed echo while admission remains queued on the following task. + await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) + const echo = document.querySelector('[data-submission-echo]') + return [ + `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, + `composer: ${JSON.stringify(element.textContent ?? '')}`, + `contenteditable: ${element.getAttribute('contenteditable')}`, + ].join('\n') + }, PROMPT) + await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) const sessionId = await settled settledSessionId = sessionId if (MODE === 'record') { @@ -188,6 +203,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', + 'submission-echo.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', 'web-context.expected.md', diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts new file mode 100644 index 0000000000..3e53dc5df8 --- /dev/null +++ b/apps/web/tests/submission-echo.e2e.ts @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +// Local submission echo over the BUILT client graph (keyless FixtureApiClient +// transport): a text-plus-image send paints its echo bubble synchronously on +// the submit keystroke — before serialization, transport, or the fixture's +// durable admission — with the composer already cleared and editable, and the +// durable user/message replaces the echo without a duplicate. The fixture host +// echoes the prompt requestId as the durable source's rpcId, so the retirement +// path here is the production correlation, not a test hook. +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + + const composer = await waitFor(() => { + const surface = document.querySelector('[data-composer-input]') + if (surface === null) throw new Error('composer surface missing') + return surface + }, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' }) + fireEvent.paste(composer, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + await waitFor(() => { + if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) { + throw new Error('attachment rail missing') + } + }, { timeout: 5_000 }) + fireEvent.paste(composer, { + clipboardData: { items: [], getData: () => '回显这条消息' }, + }) + await waitFor(() => { expect(composer.textContent).toBe('回显这条消息') }) + fireEvent.keyDown(composer, { key: 'Enter' }) + + // Synchronously after the keystroke: the echo bubble is in the flow with + // the draft text and the object-URL preview, while the prompt has not even + // been serialized yet (it starts after a paint yield). The composer is + // already cleared, editable, and free of the rail. + const echo = document.querySelector('[data-submission-echo]') + if (echo === null) throw new Error('submission echo missing on the send keystroke') + expect(echo.textContent).toContain('回显这条消息') + expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob') + expect(composer.textContent).toBe('') + expect(composer.getAttribute('contenteditable')).toBe('true') + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + + // The fixture's durable user/message (source.rpcId echoes the prompt + // requestId) replaces the echo: one bubble, no marker left, and the image + // now renders from the durable gallery. + await waitFor(() => { + if (document.querySelector('[data-submission-echo]') !== null) { + throw new Error('submission echo still present after the durable node arrived') + } + }, { timeout: 10_000 }) + expect(screen.getAllByText('回显这条消息')).toHaveLength(1) + await waitFor(() => { + if (document.querySelector('[data-align="end"] img') === null) { + throw new Error('durable user gallery missing') + } + }, { timeout: 10_000 }) +}) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4cdad8558b..750dab17ed 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 7f85b870bd9604ed983b0d8a251a3ee4511a52b7 -config-catalog.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd +config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289 +config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7f85b870bd..ab16221ff6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -274,6 +274,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +Requires: `typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +Source: [`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2863,12 +2879,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** @@ -2918,7 +2931,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) @@ -3317,7 +3330,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1ab6838e4c..8c9d956ad3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -276,6 +276,22 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +需要:`typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +来源:[`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2865,12 +2881,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** @@ -3319,7 +3332,6 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index dc55a4ee59..5e63621f1b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 91bfb814a4370fdafb6d62bc79259495b0069dda -event-producer-consumer.zh.md: e7dcd089f28888bdb235ee0a6df97dc4189f0723 +event-producer-consumer.md: 6869ef2929d691165b77d04fba215f0ac7add621 +event-producer-consumer.zh.md: 45476a6f8497df97c9a09a7d7554c68b4525424f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 91bfb814a4..6869ef2929 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/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:476`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -52,10 +52,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:179`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e7dcd089f2..45476a6f84 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:476`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -54,10 +54,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:169`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:179`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8866f5a1f1..5d08c21d3e 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 93170bd4d76d768d1cf5f1bc5efdf94d1ce2453a -module-graph.zh.md: ac7f7e70d2364469ce5de5bf0734457e8e4dfb3e +module-graph.md: 28885c4bde3215075f213135ad64db8feadb4a9a +module-graph.zh.md: 68d22f46c645dd62426f893bec4a0b5399d5ef6e diff --git a/docs/module-graph.md b/docs/module-graph.md index 93170bd4d7..28885c4bde 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1343,6 +1343,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1900,7 +1901,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ac7f7e70d2..68d22f46c6 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1345,6 +1345,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1902,7 +1903,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 13c2a19a3c..6a61b25f17 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: b9839833308afbd0d561bc227a95b236a10238a1 -persistence-catalog.zh.md: 7b5fa938b23ea9112e370133bf7575c8d689806d +persistence-catalog.md: 6a48b9c674375c6b5fa8b296afb7658a9d508168 +persistence-catalog.zh.md: 367b1c1a11324cea057ff03d0c456d531edc0183 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b983983330..6a48b9c674 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -738,9 +738,9 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — log-only ```ts persistence-catalog /** @@ -749,10 +749,13 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7b5fa938b2..367b1c1a11 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -740,9 +740,9 @@ export type SessionEvent = { 来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — 仅日志 ```ts persistence-catalog /** @@ -751,10 +751,13 @@ export type SessionEvent = { * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 8aa69ce9a2..1d6cccc8a5 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 03d32139157b2f2c2bf9b58ce8f0ea36d2187a66 -subagent.zh.md: 61bb2f32d1eb85a894dfbd40fc259fbdab8924de +subagent.md: 5368e1fd22e2d769314c6edfb2c9734c9a9779dd +subagent.zh.md: d55458e9b7823b04f985b4d2f4b4ee7370a3f0fc diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 03d3213915..5368e1fd22 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -505,10 +505,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentEnabled(): boolean +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 61bb2f32d1..d55458e9b7 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -509,10 +509,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentEnabled(): boolean +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 5b23decb3c..569d755993 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622 -tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819 +tool-catalog.md: 16142c2f7d98cf1037b034d2836c742b62f26594 +tool-catalog.zh.md: 533caacc7a923b5ea36f527292f420b610f1c488 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7b166243fc..16142c2f7d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -1561,7 +1561,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. ```json { @@ -1575,18 +1575,6 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1601,7 +1589,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index b44a0de496..533caacc7a 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -1567,7 +1567,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 ```json { @@ -1581,18 +1581,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1607,7 +1595,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 diff --git a/knip.json b/knip.json index b08fe22a25..6fa4cac826 100644 --- a/knip.json +++ b/knip.json @@ -696,8 +696,7 @@ "@deepseek-ai/dsh-llm-deepseek", "@deepseek-ai/dsh-session-checkpoint-policy", "@deepseek-ai/dsh-session-persistence-jsonl", - "@deepseek-ai/dsh-skill-filesystem", - "@deepseek-ai/dsh-tool-subagent" + "@deepseek-ai/dsh-skill-filesystem" ] }, "packages/shell/tool-pwsh": { diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 9bc399b963..8353039833 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: 2e0cb32e4db6c1bee8576a5addd5492fb7ef53ac -README.zh.md: f67e13f6b1f789da02796397a121e59a55427cca +README.md: 504ff95494d8c374426c18d0a77fb238457561e3 +README.zh.md: e556b4981ce5789e6fe9f74bb7d4dc9c5217ae4c diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 2e0cb32e4d..504ff95494 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. @@ -68,6 +68,7 @@ No direct effect; invoked business Services own any model-visible result. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. +- WebSocket heartbeats keep idle intermediaries active but do not require a timely Pong or terminate an unresponsive peer. Half-open carriers remain subject to TCP or intermediary failure detection before the Client reconnects. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index f67e13f6b1..e556b4981c 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 @@ -68,6 +68,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 +- WebSocket 心跳用于保持空闲中间层活跃,但不会要求及时收到 Pong,也不会主动终止无响应对端。半开 carrier 仍需等待 TCP 或中间层检测失败后,Client 才会重连。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index e74d19946f..13c7e402ae 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -56,7 +56,9 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "ws": "^8.21.0" }, "peerDependencies": { diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 86a71d834a..ec4eb1eaef 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import z from '@deepseek-ai/schemastery' import { remoteMethods, TypertLookupFailure, @@ -106,6 +108,17 @@ interface PendingRemoteEvent { type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal +const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000 + +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} + +interface ResolvedConfig extends Config { + readonly websocketHeartbeatIntervalMs: number +} /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -156,6 +169,10 @@ class RemoteInvocationCancelled extends Error { */ export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + static Config: z = z.object({ + websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) + .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { @@ -171,9 +188,11 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the Gateway against the active Typert registry. * @param ctx - owning Host Context with Typert registry access. + * @param config - validated Gateway transport configuration. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: Config) { super(ctx, 'typertGateway') + const resolved = config as ResolvedConfig ctx.on('internal/service', () => { this.srcClaims = undefined }) @@ -188,6 +207,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const mux = new RemoteStreamMuxServer( (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), this.wireStream.failure, + resolved.websocketHeartbeatIntervalMs, ) webCtx.effect(() => { const route: WebUpgradeRoute = { diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 04b0df0427..28a3589542 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -23,14 +23,17 @@ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure export class RemoteStreamMuxServer { private readonly server = new WebSocketServer({ noServer: true }) private readonly connections = new Set>() + private heartbeatTimer: NodeJS.Timeout | undefined /** * @param open - Gateway stream dispatcher. * @param failure - Gateway error-to-wire mapper. + * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. */ constructor( private readonly open: RemoteStreamOpener, private readonly failure: RemoteStreamFailureMapper, + private readonly heartbeatIntervalMs: number, ) {} /** @@ -41,6 +44,7 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + this.startHeartbeat() const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) const done = connection.run() this.connections.add(done) @@ -50,6 +54,8 @@ export class RemoteStreamMuxServer { /** Terminate all sockets and wait until every iterator has returned. */ async close(): Promise { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = undefined for (const socket of this.server.clients) socket.terminate() const closed = Promise.withResolvers() this.server.close((error) => { @@ -59,6 +65,17 @@ export class RemoteStreamMuxServer { await closed.promise await Promise.all(this.connections) } + + /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */ + private startHeartbeat(): void { + if (this.heartbeatTimer !== undefined) return + this.heartbeatTimer = setInterval(() => { + for (const socket of this.server.clients) { + if (socket.readyState === WebSocket.OPEN) socket.ping() + } + }, this.heartbeatIntervalMs) + this.heartbeatTimer.unref() + } } interface ActiveStream { diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index d7289783d3..debb6763d5 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -5,6 +5,7 @@ import WebSocket, { type RawData } from 'ws' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { bindTypertRemote, Remote, @@ -17,6 +18,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, + type Config as GatewayConfig, type TypertRemoteEventDispatch, type TypertRemoteEventInvocation, type TypertRemoteEventOutcome, @@ -211,6 +213,15 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { + it('validates the WebSocket heartbeat timer range', () => { + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) + for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { + expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() + } + }) + it('opens decoded carrier payloads through the in-process wire adapter', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.wireStream.open( @@ -280,6 +291,19 @@ describe('Typert Remote streams', () => { })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) }) + it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => { + const { ctx } = await setup(true, { websocketHeartbeatIntervalMs: 20 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + const ping = once(socket, 'ping') + await once(socket, 'open') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + + socket.close() + await once(socket, 'close') + }) + it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { const { ctx, service } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -964,7 +988,10 @@ describe('Typert Remote streams', () => { }) }) -async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { +async function setup( + transport: boolean, + gatewayConfig: GatewayConfig = {}, +): Promise<{ readonly ctx: Context; readonly service: FeedService }> { const ctx = new Context() roots.push(ctx) if (transport) { @@ -972,7 +999,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) - await ctx.plugin(TypertGatewayService) + await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) } diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 9746943b63..2cc4c99f8c 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -25,6 +25,31 @@ afterEach(async () => { }) describe('Remote stream mux server carrier lifecycle', () => { + it('sends WebSocket Ping control frames without application messages', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + const messages = vi.fn() + client.on('message', messages) + + const ping = once(client, 'ping') + const pong = once(serverSocket, 'pong') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + expect((await pong)[0]).toEqual(Buffer.alloc(0)) + expect(messages).not.toHaveBeenCalled() + + const closingPing = vi.spyOn(serverSocket, 'ping') + client.pause() + serverSocket.close() + expect(serverSocket.readyState).toBe(WebSocket.CLOSING) + await new Promise((resolve) => { setTimeout(resolve, 25) }) + expect(closingPing).not.toHaveBeenCalled() + + const closed = once(client, 'close') + client.resume() + await closed + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -168,8 +193,8 @@ const mapFailure: RemoteStreamFailureMapper = error => ({ details: {}, }) -async function startMux(open: RemoteStreamOpener): Promise { - const mux = new RemoteStreamMuxServer(open, mapFailure) +async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise { + const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) const http = createServer() http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) await new Promise((resolve, reject) => { diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 46d1b3a88d..54d5f964f3 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -19,6 +19,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../runtime-diagnostics/invariants" }, @@ -30,6 +33,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3325cc86a4..4f66278d0d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 815276847f538f99352e0f0e55fc19af5da67470 -README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b +README.md: dc04e595c9c4bf029ed427d5a1214802041c11c0 +README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 815276847f..dc04e595c9 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 51bf5a98c6..b34c8ad018 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。 + ----- diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index fc97dbff8e..7284f86603 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,14 +7,44 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, ImageAttachmentRef, PromptContentPart } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentIdType, ImageAttachmentRef, PromptContentPart, +} from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { QueueAction } from '../../types.ts' +import type { QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' -import type { SessionSnapshot } from './snapshot.ts' +import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' + +/** + * Why a local submission echo left the snapshot: `observed` when its durable + * `user/message` event or host queue occurrence arrived (with the admitted + * image references in prompt order), `failed` when the prompt was rejected, + * threw, or was aborted before acceptance. + */ +export type PendingSubmissionRetirement = + | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { readonly reason: 'failed' } + +/** Input registering one local submission echo ahead of its prompt call. */ +export interface BeginSubmissionInput { + /** Prompt text exactly as the upcoming prompt will send it. */ + readonly text: string + /** Ordered image previews matching the upcoming prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] + /** Settlement callback fired exactly once when the echo retires. */ + readonly onRetire?: (retirement: PendingSubmissionRetirement) => void +} + +/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */ +export interface SubmissionHandle { + /** The prompt RPC identity; pass it to {@link ISession.prompt}. */ + readonly requestId: SessionRequestId + /** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */ + abandon(): void +} /** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ export interface ProjectionsFace { @@ -33,16 +63,29 @@ export interface ISession { readonly sessionId: SessionId /** Host-computed projection values by key (the useProjection seat). */ readonly projections: ProjectionsFace + /** + * Register one local submission echo in `snapshot.pendingSubmissions`, + * synchronously, before the caller serializes and sends the prompt. The + * echo retires when a durable `user/message` event or queue occurrence + * carrying the returned identity arrives, or when the identified prompt + * call fails. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle /** * Send a prompt into the session. * @param content - text plus browser-owned temporary image uploads. * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> /** * Resolve one durable image referenced by this session. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 3f5f4ebd1c..8aacc0d5d6 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -3,6 +3,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { SessionRequestId } from '../../types.ts' import type { ClientFailure } from './result.ts' /** One transient inbox occurrence from the authoritative queue snapshot. */ @@ -10,11 +11,42 @@ export interface QueuedMessage { readonly id: MessageId readonly messageId: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */ + readonly rpcId?: SessionRequestId readonly content: readonly ContentBlock[] readonly preview: string readonly text: string | null } +/** One image displayed by a local submission echo before durable admission. */ +export interface PendingSubmissionImage { + /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */ + readonly previewUrl: string + /** Browser file name, when the file had one. */ + readonly name?: string + /** Intrinsic pixel width, when the submitter has probed it. */ + readonly width?: number + /** Intrinsic pixel height, when the submitter has probed it. */ + readonly height?: number +} + +/** + * One local prompt-submission echo: inserted synchronously when a submission + * begins, so the conversation can show the message before serialization, + * transport, and durable admission complete. Client-memory only — reload and + * reconnect rebuild the conversation from durable events alone. + */ +export interface PendingSubmission { + /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ + readonly requestId: SessionRequestId + /** Client wall-clock ms when the submission began. */ + readonly time: number + /** Prompt text exactly as it will be sent (one text block). */ + readonly text: string + /** Ordered image previews matching the prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] +} + /** History-open lifecycle of a Session event window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' @@ -28,6 +60,8 @@ export interface PromptError { export interface SessionSnapshot { readonly sessionId: SessionId readonly queue: readonly QueuedMessage[] + /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ + readonly pendingSubmissions: readonly PendingSubmission[] readonly running: boolean readonly subagent: { readonly address: SubagentAddress diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 10a10261bf..bca9f9d3d0 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -40,7 +40,14 @@ export type { SessionProjectionMap, UseProjection, } from './sessions/projection-store.ts' -export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { + BeginSubmissionInput, + ISession, + PendingSubmissionRetirement, + ProjectionsFace, + SessionFace, + SubmissionHandle, +} from './contract/session.ts' export type { ISessions } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { @@ -53,6 +60,8 @@ export type { } from './contract/events.ts' export type { OpenState, + PendingSubmission, + PendingSubmissionImage, PromptError, QueuedMessage, SessionSnapshot, diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 8ae4539607..985ad8a841 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -46,6 +46,7 @@ export class SessionQueueMirror { id: item.id, messageId: item.message.id, placement: item.placement, + ...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }), content, preview: previewOf(content), text: textOf(content), diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 4645d7d334..535f6574fa 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -22,9 +22,11 @@ import type { } from '../../types.ts' import type { ClientFailure, ClientResult } from '../contract/result.ts' import { transportResult } from '../contract/result.ts' -import type { SessionFace } from '../contract/session.ts' import type { - OpenState, PromptError, SessionSnapshot, + BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, +} from '../contract/session.ts' +import type { + OpenState, PendingSubmission, PromptError, SessionSnapshot, } from '../contract/snapshot.ts' import { MutableSessionEventSource } from '../contract/events.ts' import type { @@ -99,6 +101,14 @@ export class Session implements SessionFace { private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null + /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ + private pendingSubmissions: readonly PendingSubmission[] = [] + /** Per-echo settlement state; `retiring` latches the first observation so a + * queue frame and its durable event cannot both retire one echo. */ + private readonly submissionSettlements = new Map void) | undefined + retiring: boolean + }>() /** Owns the addressed page/follow lifecycle while this Session is open. */ private events: SessionEventStream | undefined @@ -168,16 +178,42 @@ export class Session implements SessionFace { // ---- Operations ---- + /** + * Register one local submission echo (see the ISession declaration). + * Synchronous through markDirty: the echo is in the very next snapshot, so + * the conversation can paint it before the caller starts serializing. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle { + const requestId = randomUUID() as SessionRequestId + this.pendingSubmissions = [...this.pendingSubmissions, { + requestId, + time: Date.now(), + text: input.text, + images: input.images, + }] + this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) + // The blank → engaging edge flips here, ahead of prompt(): the composer + // docks and the echo renders on the click's own frame. + this.promptAttempted = true + this.notifier.markDirty() + return { requestId, abandon: () => { this.retireFailedSubmission(requestId) } } + } + /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. * @param content - text plus browser-owned temporary image uploads. * @param mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns the prompt result (also mirrored into promptError on failure). */ async prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> { this.promptError = null this.lastAgentError = null @@ -192,7 +228,7 @@ export class Session implements SessionFace { if (this.address === undefined) { const clientTimeZone = resolvedClientTimeZone() result = toSessionResult(await this.remote.session.prompt({ - requestId: randomUUID() as SessionRequestId, + requestId: requestId ?? randomUUID() as SessionRequestId, sessionId: this.sessionId, mode, content, @@ -222,6 +258,7 @@ export class Session implements SessionFace { result = transportResult(error) } if (!result.ok) { + if (requestId !== undefined) this.retireFailedSubmission(requestId) this.promptError = { op: 'send', error: result.error } this.notifier.markDirty() return result @@ -424,6 +461,7 @@ export class Session implements SessionFace { */ replaceControl(queue: readonly SessionQueuedItem[]): void { this.queueMirror.replace(queue) + this.observeSubmissionQueue(queue) this.notifier.markDirty() } @@ -433,6 +471,7 @@ export class Session implements SessionFace { */ handleControlFrame(frame: Extract): void { this.queueMirror.replace(frame.items) + this.observeSubmissionQueue(frame.items) this.notifier.markDirty() } @@ -513,6 +552,12 @@ export class Session implements SessionFace { * @returns when the Remote iterator has completed teardown. */ async dispose(): Promise { + // Unsettled echoes retire as failed so their owners can restore or + // release browser resources; echoes already scheduled as observed keep + // that settlement. + for (const requestId of [...this.submissionSettlements.keys()]) { + this.retireFailedSubmission(requestId) + } this.openGeneration++ const events = this.events this.events = undefined @@ -571,6 +616,7 @@ export class Session implements SessionFace { if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false if (projections !== undefined) this.projections.seed(projections) this.eventSource.replace(entries, hasMore) + for (const entry of entries) this.observeSubmissionEvent(entry.event) this.notifier.markDirty() } @@ -588,9 +634,70 @@ export class Session implements SessionFace { if (event.type === 'turn/start') this.firstPromptPendingTurn = false const queueChanged = this.queueMirror.acceptDurable(event) this.eventSource.append(entry) + // After the feed append: the conversation assembly's animation frame is + // registered by the feed subscribers above, so the echo-retirement frame + // scheduled here always runs after the durable node became renderable. + this.observeSubmissionEvent(event) return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn } + /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */ + private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void { + if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return + // Structural read: window entries may be compact history records, so the + // fields are narrowed rather than trusted (same posture as Conversation + // assembly matchers). + const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined + const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined + if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + } + + /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ + private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void { + if (this.submissionSettlements.size === 0) return + for (const item of items) { + if (item.rpcId !== undefined) { + this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + } + } + } + + /** + * Latch one observed settlement and remove the echo an animation frame + * later. The delay keeps the echo in the snapshot until the frame in which + * the durable node (whose assembly frame was registered first) is + * renderable; the render-time rpcId dedupe hides the one-frame overlap. + */ + private scheduleObservedRetirement( + requestId: SessionRequestId, + attachments: readonly ImageAttachmentRef[], + ): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + scheduleFrame(() => { this.finishSubmission(requestId, { reason: 'observed', attachments }) }) + } + + /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */ + private retireFailedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + this.finishSubmission(requestId, { reason: 'failed' }) + } + + /** Single removal point: drop the echo, publish, then notify the owner. */ + private finishSubmission(requestId: SessionRequestId, retirement: PendingSubmissionRetirement): void { + const settlement = this.submissionSettlements.get(requestId) + /* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */ + if (settlement === undefined) return + this.submissionSettlements.delete(requestId) + this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId) + this.notifier.markDirty() + settlement.onRetire?.(retirement) + } + /** Publish a terminal background failure only while this stream still owns the Session. */ private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { if (generation !== this.openGeneration || this.events !== events) return @@ -607,6 +714,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, queue: this.queueMirror.snapshot(), + pendingSubmissions: this.pendingSubmissions, running: this.running, subagent: this.address === undefined ? null @@ -634,6 +742,26 @@ export class Session implements SessionFace { } } +/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ +function scheduleFrame(fn: () => void): void { + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() }) + else setTimeout(fn, 0) +} + +/** Image attachment references in one structurally-read content block list, in block order. */ +function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { + if (!Array.isArray(content)) return [] + const refs: ImageAttachmentRef[] = [] + for (const block of content) { + if (typeof block !== 'object' || block === null) continue + const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } + if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef) + } + } + return refs +} + /** Convert a terminal Session stream failure to the Client error vocabulary. */ function openFailure(error: unknown): ClientFailure { const failure = sessionStreamFailure(error) diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 4068b5536d..624c9a9493 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -188,16 +188,24 @@ function queueItems( ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ...project('next-step').map(message => ({ id: message.id, placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ] } +/** Prompt-RPC identity carried by a browser-submitted message's user source. */ +function promptRpcId(message: UserMessage): Pick { + const source = message.source + return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {} +} + function jobView(job: JobSnapshot): SessionJob { return { id: job.id, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c58eb419ca..08b412ce46 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -424,6 +424,8 @@ export type SessionFollowFrame = export interface SessionQueuedItem { readonly id: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */ + readonly rpcId?: SessionRequestId /** JSON-safe message fields consumed by pending-queue presentation. */ readonly message: { readonly id: MessageId diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 9ce0c453a0..b4645dc2f3 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -69,6 +69,30 @@ describe('Session control queue projection', () => { await iterator.next() }) + it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => { + const { control, inbox } = await harness() + const identified = createUserMessage({ + content: [{ type: 'text', text: 'browser prompt' }], + source: { kind: 'user', rpcId: 'req-42' as never }, + }) + inbox.append('next-turn', identified) + inbox.append('next-step', message('plain steering')) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') + const items = opened.value.value.queues['queue-session' as SessionId] ?? [] + expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([ + { id: identified.id, placement: 'queued', rpcId: 'req-42' }, + { id: items[1]?.id, placement: 'steering', rpcId: undefined }, + ]) + expect('rpcId' in (items[1] ?? {})).toBe(false) + + abort.abort() + await iterator.next() + }) + it('ignores inbox events without the exact live Agent session', async () => { const { ctx, control, agent, inbox } = await harness() const abort = new AbortController() diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts new file mode 100644 index 0000000000..261bddbb78 --- /dev/null +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -0,0 +1,258 @@ +/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { Session } from '../src/client/sessions/session.ts' +import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' +import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' +import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { historyValue } from './event-script.client.ts' + +const SID = 'fk-s1' as SessionId + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { + return { api, session: new Session(SID, fakeRemote(api)) } +} + +function imageRef(id: string): ImageAttachmentRef { + return { + attachmentId: id, + mediaType: 'image/png', + bytes: 1, + width: 2, + height: 2, + } as unknown as ImageAttachmentRef +} + +/** A durable browser-prompt user/message whose source echoes `rpcId`. */ +function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { + return { + seq, + time: 1_700_000_000_000 + seq, + type: 'user/message', + surfaceOp: 'append', + data: createUserMessage({ + content: [ + ...refs.map(attachment => ({ type: 'image' as const, attachment })), + { type: 'text' as const, text: '发送' }, + ], + source: { kind: 'user', rpcId }, + }), + } as unknown as SessionEvent +} + +function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem { + return { + id: 'm-queued' as SessionQueuedItem['id'], + placement: 'queued', + rpcId, + message: { + id: 'm-queued' as SessionQueuedItem['id'], + content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'], + }, + } +} + +/** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */ +function settleFrames(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('beginSubmission', () => { + it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) + const handle = session.beginSubmission({ + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }) + expect(session.getSnapshot().promptAttempted).toBe(true) + expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ + requestId: handle.requestId, + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }]) + }) + + it('abandon retires the echo as failed exactly once', () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '放弃', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + handle.abandon() + handle.abandon() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) +}) + +describe('prompt-coupled retirement', () => { + it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '失败的', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) + expect(result.ok).toBe(false) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' }) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) + + it('sends the echo identity as the prompt requestId', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '带 id', images: [] }) + await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) + expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) + }) + + it('an unidentified prompt failure leaves registered echoes alone', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + session.beginSubmission({ text: '还在', images: [] }) + await session.prompt([{ type: 'text', text: '另一个' }], 'queue') + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + }) +}) + +describe('observed retirement', () => { + it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '发送', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-1')] + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never }) + // Synchronously after the append the echo is still in the snapshot; the + // render-time dedupe owns the overlap frame. + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + }) + + it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '排队', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-q')] + session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] }) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + // The queue projection keeps the correlation id for render-time dedupe. + expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) + }) + + it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '重连', images: [] }) + api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) + await session.open() + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) + + it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '先观察', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + handle.abandon() + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) + }) + + it('retires once when the queue and durable event report the same request id', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '同一请求', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + session.handleControlFrame({ + type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])], + }) + await api.pushFollow(SID, { + type: 'event', event: promptEvent(0, handle.requestId) as never, + }) + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) + + it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + frames.push(fn) + return frames.length + }) + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const handle = session.beginSubmission({ text: '帧', images: [] }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + expect(frames).toHaveLength(1) + frames[0]?.(0) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) + +describe('disposal', () => { + it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] + const observed = session.beginSubmission({ + text: '已观察', + images: [], + onRetire: retirement => retirements.push({ text: '已观察', retirement }), + }) + session.beginSubmission({ + text: '未settle', + images: [], + onRetire: retirement => retirements.push({ text: '未settle', retirement }), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never }) + await session.dispose() + await settleFrames() + expect(retirements).toEqual([ + { text: '未settle', retirement: { reason: 'failed' } }, + { text: '已观察', retirement: { reason: 'observed', attachments: [] } }, + ]) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index ee5e5ed156..eaa8457e88 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -357,7 +357,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 3f889dd8b4..d69e66753a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -42,7 +42,7 @@ # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: # Host-owned opt-in sampled when a new Web session receives its preset - # delegation tools. The Models page edits this settings namespace. + # delegation tools. The Plugins page edits this settings namespace. - id: subagent-model-selection-settings name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 02eb11fe9c..6cc57082cb 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2962,9 +2962,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) return { type: 'image', attachment } }) + // The host echoes the prompt's requestId as the user source's rpcId; + // the Session object retires its local submission echo on it. The + // user-rpc source member is declared by dsh-api-session-controller, + // which this standalone fixture does not import — hence the assertion. + const promptSource = { kind: 'user', rpcId: request.requestId } as MessageSource if (mode === 'steer' && replays.has(id)) { // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) return sessionOk({ accepted: true as const }) } const turn = nextTurn.get(id) ?? 0 @@ -2977,7 +2982,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (plan.wanted !== null && plan.wanted !== plan.active) { append(id, { type: 'plan/mode', data: { active: plan.wanted } }) } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) // Capacity parallel of the host token-meter's request/context record: // log-only, appended inside the open turn, and deduplicated against the // route already recorded (the fixture never varies contextWindow). diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 7378631ca9..5e0e50e06a 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: 3e9240f5f2f68dbc9f709ee7e720bb48053ba3b5 -README.zh.md: 0a450d2bc78f183847b945c73107c1466fb3ba0e +README.md: 9fa03432b43686dd55af494640708a8bf0983f9a +README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3e9240f5f2..9fa03432b4 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -54,7 +54,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal | diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 0a450d2bc7..48e467280b 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开 | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖拽邀请 portal | diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index c4de8f73a7..61554fe93f 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -4,8 +4,22 @@ import { ImageLightbox } from './ImageLightbox.tsx' import type { ImageLightboxLabels } from './ImageLightbox.tsx' import css from './MessageImage.module.css' -/** Loads a session-authorized durable image URL. */ -export type ImageLoader = (attachment: ImageAttachmentRef) => Promise +/** Loads a session-authorized durable image URL and may expose a cached URL synchronously. */ +export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { + peek?: (attachment: ImageAttachmentRef) => string | undefined +} + +/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ +export type MessageImageSpec = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + readonly url: string + readonly name?: string + readonly width?: number + readonly height?: number + } + } /** Message-image strings the owner resolves from its own locale namespace. */ export interface MessageImageLabels { @@ -28,11 +42,13 @@ export interface MessageImageLabels { * `object-fit: cover` — and never upscaled past the image's natural size. The * crop anchor keeps the top of very tall images and the left of very wide * ones, where the informative content usually starts. */ -function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } { - const natural = attachment.width / attachment.height +function singleFit( + dimensions: { readonly width: number; readonly height: number }, +): { width: number; height: number; objectPosition: string } { + const natural = dimensions.width / dimensions.height const ratio = Math.min(4, Math.max(0.25, natural)) const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 } - const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height) + const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height) return { width: Math.max(1, Math.round(box.width * scale)), height: Math.max(1, Math.round(box.height * scale)), @@ -40,24 +56,36 @@ function singleFit(attachment: ImageAttachmentRef): { width: number; height: num } } +/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */ +function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined { + if ('attachment' in image) return image.attachment + return image.preview.width !== undefined && image.preview.height !== undefined + ? { width: image.preview.width, height: image.preview.height } + : undefined +} + /** * Compact history renderer with retryable loading and click-to-open original * preview. A lone image renders at its `singleFit` size; an image among - * several renders as a fixed 64px square tile. + * several renders as a fixed 64px square tile. The preview arm displays its + * local URL directly — no loader round-trip, no failure/retry surface. * - * @param props.attachment - the durable image reference to load and bound. - * @param props.load - session-authorized URL loader. + * @param props.image - the durable reference to load, or the local preview to display. + * @param props.load - session-authorized URL loader for the durable arm. * @param props.variant - `single` for a message's lone image, `tile` otherwise. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ -export function MessageImage({ attachment, load, variant, labels }: { - attachment: ImageAttachmentRef +export function MessageImage({ image, load, variant, labels }: { + image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' labels: MessageImageLabels }) { - const [src, setSrc] = useState(null) + const preview = 'preview' in image ? image.preview : undefined + const attachment = 'attachment' in image ? image.attachment : undefined + const [loaded, setLoaded] = useState(() => + attachment === undefined ? null : (load.peek?.(attachment) ?? null)) const [error, setError] = useState(false) const [open, setOpen] = useState(false) // Retry re-arms the one load effect below, so every attempt — first load or @@ -65,20 +93,30 @@ export function MessageImage({ attachment, load, variant, labels }: { const [attempt, setAttempt] = useState(0) const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) + const dimensions = useMemo(() => dimensionsOf(image), [image]) const fit = useMemo( - () => (variant === 'single' ? singleFit(attachment) : undefined), - [attachment, variant], + () => { + if (variant !== 'single') return undefined + // A preview whose intake probe has not resolved sizes as a square crop; + // the durable replacement restores the exact fit. + return dimensions === undefined + ? { width: 240, height: 240, objectPosition: 'center' } + : singleFit(dimensions) + }, + [dimensions, variant], ) useEffect(() => { + if (attachment === undefined) return let live = true setError(false) - setSrc(null) - void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + setLoaded(load.peek?.(attachment) ?? null) + void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) }) return () => { live = false } }, [attachment, load, attempt]) - const label = attachment.name ?? labels.image + const src = preview?.url ?? loaded + const label = (preview?.name ?? attachment?.name) ?? labels.image if (error) return return ( <> @@ -103,7 +141,7 @@ export function MessageImage({ attachment, load, variant, labels }: { /** Wrapping image group shared by user and assistant history: a lone image * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ export function ImageGallery({ images, load, align, labels }: { - images: readonly { attachment: ImageAttachmentRef }[] + images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' labels: MessageImageLabels @@ -113,7 +151,13 @@ export function ImageGallery({ images, load, align, labels }: { return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d7d37bfd97..76a720b983 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -47,9 +47,19 @@ const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_S const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory) describe('MessageImage', () => { + it('renders a cached URL on the first frame while refreshing it', () => { + const load = Object.assign(vi.fn(() => new Promise(() => {})), { + peek: vi.fn(() => 'blob:seeded'), + }) + const view = render() + expect(view.queryByText('图片加载中…')).toBeNull() + expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded') + expect(load).toHaveBeenCalledWith(attachment) + }) + it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -64,7 +74,7 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -74,7 +84,7 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -84,7 +94,7 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -96,7 +106,7 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -105,7 +115,7 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -114,7 +124,7 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -123,7 +133,7 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -131,7 +141,7 @@ describe('MessageImage', () => { it('keeps the tile variant on the failed-load retry control', async () => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe('tile') }) @@ -139,29 +149,74 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() }) }) +describe('MessageImage preview arm', () => { + it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => { + const load = vi.fn() + const view = render( + , + ) + expect(load).not.toHaveBeenCalled() + const img = view.getByAltText('echo.png') as HTMLImageElement + expect(img.src).toContain('blob:echo') + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('120px') + }) + + it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { + const load = vi.fn() + const view = render( + , + ) + const img = view.getByAltText('图片') as HTMLImageElement + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('240px') + }) + + it('opens the lightbox from a preview thumbnail', () => { + const view = render( + '')} variant="tile" labels={labels} />, + ) + fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' })) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + }) +}) + describe('ImageGallery', () => { it('renders nothing without images and an aligned wrapping group with them', async () => { const load = vi.fn().mockResolvedValue('blob:gallery') const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - , + , ) expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) + expect(view.getByAltText('echo.png')).toBeTruthy() }) it('renders a lone image large and several images as square tiles', () => { diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 699fb752bb..5919181d67 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 26d712f58726f13d390efa2e6fd42b38fb373a1f -README.zh.md: 53a98828ccfc9c4841ccc8b4d27d4df527af0ec4 +README.md: cd69eb7fdf704bb0e138e1f2aad88782e26aeeff +README.zh.md: 3e9d1ccab71f2516f92ec5402eac49baaaccdf77 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 26d712f587..cd69eb7fdf 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. The flow tail renders the session's local submission echoes (`SessionSnapshot.pendingSubmissions`) with the same bubble as their eventual durable user nodes, hidden per render once a user/steering node or queue occurrence carries the echo's prompt `rpcId`, so the echo-to-durable swap is atomic. ## Table of Contents diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 53a98828cc..3e9d1ccab7 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。 ## 目录 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 3243c6c642..1e534dd697 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -1,5 +1,6 @@ /** Register the Chat Conversation target, renderers, stats, and details surface. */ import type { Context } from '@deepseek-ai/cordis' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -100,7 +101,10 @@ export function apply(ctx: Context): void { return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void session.loadOlder() }, - loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), + loadImage: Object.assign( + (attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment), + { peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) }, + ), chatScroll: { save: (position) => { if (position === null) chatScrollPositions.delete(sessionId) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index ecce592e67..7b370666f9 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,8 +7,8 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { TurnNavigationItem } from '../contract/snapshot.ts' -import { PendingSteeringBubble } from './MessageItem.tsx' +import type { ChatSnapshot, TurnNavigationItem } from '../contract/snapshot.ts' +import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { TurnNavigator } from './TurnNavigator.tsx' import { formatRunDuration } from './message-chrome.ts' @@ -124,6 +124,33 @@ function isFolderOpenPath(path: string): boolean { return path === '.' } +/** + * Prompt-RPC identities already rendered by durable material: user/steering + * node sources plus queue occurrences. A submission echo whose identity + * appears here is hidden in the same render, so the echo→durable swap is + * atomic — no duplicate, no gap — regardless of when the echo leaves the + * session snapshot. + */ +function observedRpcIds( + order: readonly string[], + nodes: ChatSnapshot['nodes'], + queue: readonly { readonly rpcId?: string }[], +): ReadonlySet { + const observed = new Set() + for (const key of order) { + const node = nodes.get(key) + if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue + const source = (node.data as { readonly source?: unknown }).source as + | { readonly kind?: unknown; readonly rpcId?: unknown } + | undefined + if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) + } + for (const item of queue) { + if (item.rpcId !== undefined) observed.add(item.rpcId) + } + return observed +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -234,6 +261,15 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + // Submission echoes still awaiting their durable counterpart. `order` is the + // recompute trigger: durable user material always arrives as an append, and + // every append replaces the order array. + const visibleSubmissions = useMemo(() => { + if (pendingSubmissions.length === 0) return pendingSubmissions + const observed = observedRpcIds(order, nodeStore, inbox) + return pendingSubmissions.filter(submission => !observed.has(submission.requestId)) + }, [pendingSubmissions, order, nodeStore, inbox]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], @@ -256,6 +292,7 @@ export function ChatView({ const openedRef = useRef(false) const lastKeyRef = useRef(null) const lastSteeringIdRef = useRef(null) + const lastSubmissionIdRef = useRef(null) /** Flow tip signature — follow-scroll only when this moves, never on a * scroll-driven at-bottom chrome re-render (which would snap inertial * scrolls the rest of the way to the floor). */ @@ -266,7 +303,8 @@ export function ChatView({ const lastKey = order.at(-1) ?? null const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` + const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}` const syncActiveTurn = useCallback((): void => { const local = listRef.current @@ -358,6 +396,7 @@ export function ChatView({ firstSeqRef.current = firstSeq lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -374,6 +413,7 @@ export function ChatView({ /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -382,13 +422,15 @@ export function ChatView({ // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current + const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el) }) const onScrollRef = useRef(() => {}) @@ -575,6 +617,14 @@ export function ChatView({ t={t} /> ))} + {visibleSubmissions.map(submission => ( + + ))} {!atBottom && (
diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index aa3395bde1..467e486ecb 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,5 +1,7 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' +import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' +import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' @@ -146,7 +148,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, referenceLabels = [], t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -154,15 +156,25 @@ function UserStyleBubble({ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */ + echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] + /** Local submission-echo previews replacing the content-derived image group. */ + previewImages?: readonly MessageImageSource[] t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images, rest } = contentParts(content) + const { text, images: contentImages, rest } = contentParts(content) + const images = previewImages ?? contentImages const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( -
+
{renderMessageImages({ images, align: 'end' })} {showBubble &&
@@ -209,6 +221,54 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: { ) } +/** + * Render one local submission echo with the exact visual language of the + * durable user node that replaces it: draft text plus object-URL previews, + * visible from the submit click until the durable `user/message` (or its + * queue occurrence) renders. + * @param props - the session snapshot's pending submission and render seats. + * @returns the echoed user bubble. + */ +export function PendingSubmissionBubble({ submission, renderMessageImages, t }: { + submission: PendingSubmission + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + t: ChatViewSlotProps['t'] +}): ReactNode { + const content = useMemo( + () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), + [submission.text], + ) + const previewImages = useMemo( + () => submission.images.map(image => ({ + preview: { + url: image.previewUrl, + ...(image.name === undefined ? {} : { name: image.name }), + ...(image.width === undefined ? {} : { width: image.width }), + ...(image.height === undefined ? {} : { height: image.height }), + }, + })), + [submission.images], + ) + return ( + ( + + )} + /> + ) +} + /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ node, renderMessageImages, t, diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 49c2cb841b..0a4ce1e95c 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,8 +1,7 @@ /** Chat-owned Slot declarations and composed component props. */ -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, + ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, @@ -102,7 +101,7 @@ export interface ChatViewInjected { openDetails: (target: SelectionTarget) => void openFile: (path: string) => Promise loadOlder: () => void - loadImage: (attachment: ImageAttachmentRef) => Promise + loadImage: MessageImageLoader chatScroll: { save: (position: ChatScrollPosition | null) => void read: () => ChatScrollPosition | null diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 0fd861a14a..328868a2dc 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -169,8 +169,10 @@ describe('Chat inject API', () => { injected.chatScroll.save(null) expect(injected.chatScroll.read()).toBeNull() - await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String)) + const loaded = await injected.loadImage(ATTACHMENT) + expect(loaded).toEqual(expect.any(String)) expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 7de458c73c..58b31b4d8e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial = {}): SessionSnaps return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', @@ -756,6 +757,99 @@ describe('ChatView', () => { expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1) }) + it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + pendingSubmissions: [ + { requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull() + + // The durable node arrives while the echo is STILL in the session + // snapshot: the render-time rpcId dedupe keeps exactly one bubble. + act(() => { + h.setChat({ + nodes: [ + assistant(1, 'working'), + { + kind: 'user', seq: 2, time: 2_000, + content: [{ type: 'text', text: '即发即显' }] as never, + source: { kind: 'user', rpcId: 'req-1' }, + }, + ], + }) + }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() + + // The delayed snapshot retirement changes nothing visible. + act(() => { h.setSession({ pendingSubmissions: [] }) }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + }) + + it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + running: true, + pendingSubmissions: [ + { requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('排队中')).toBeTruthy() + act(() => { + h.setSession({ + queue: [{ + id: 'q-occurrence' as never, + messageId: 'q-message' as never, + placement: 'queued' as const, + rpcId: 'req-q' as never, + content: [{ type: 'text' as const, text: '排队中' }], + preview: '排队中', + text: '排队中', + }], + }) + }) + // The queued occurrence renders in the queue dock, not the flow; the + // flow-tail echo yields to it in the same snapshot. + expect(view.queryByText('排队中')).toBeNull() + }) + + it('an image echo renders its previews through the message-image slot', () => { + const h = makeHarness( + { nodes: [] }, + { + pendingSubmissions: [{ + requestId: 'req-img' as never, + time: 7_000, + text: '', + images: [ + { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + { previewUrl: 'blob:echo-b' }, + ], + }], + }, + ) + const baseRenderSlot = h.props.renderSlot + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) + const images = (owner as { images: readonly unknown[] }).images + return
+ }) as unknown as ChatViewSlotProps['renderSlot'] + const view = render() + const gallery = view.getByTestId('echo-images') + expect(gallery.getAttribute('data-count')).toBe('2') + expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({ + preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + }) + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index d4e4022e1c..cc1cf29745 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index bb5d339f0a..ffbde6bb29 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { calls.push(owner) return (
- {owner.images.map(({ attachment: image }, index) => ( - {image.name} - ))} + {owner.images.map((entry, index) => { + if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references') + const image = entry.attachment + return {image.name} + })}
) } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 63e2a89061..656c55e85f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 3a51c20caaa78f1e638d5117a9153112ce530fe2 -README.zh.md: be5b5396620566b356cc51aedc3fed21779cccd9 +README.md: 6e26991dcefa8eb922f3913feb3db4750258646a +README.zh.md: 99cacf47995552bc35e2414ee64283186f2076d2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3a51c20caa..6e26991dce 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,6 +40,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show each durable image part as a thumbnail resolved through the session image URL cache, while an edit exposes the literal sent text. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +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`) before serializing, yields one paint so the echo renders on the click's own frame, and encodes images through the browser's native `FileReader` data-URL path. 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 image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. + While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index be5b539662..99cacf4799 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,6 +40,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把每个持久化图片部分经会话图片 URL 缓存解析为缩略图展示,编辑态则展示字面发送文本。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 + 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 13aa8a82e8..d6a0741200 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -336,14 +336,16 @@ export interface InputState { /** * One in-flight submission attempt: the ONLY id concept in the submit plane. - * Created on enter; carried by adjudicated/submit-settled events; stale - * attempts are dropped (anti-backwash). release/session teardown aborts the - * current attempt, keeping the promise bounded. + * Created on enter; carried by adjudicated/submit-settled/sink-settled + * events; stale attempts are dropped (anti-backwash). Command attempts hold + * the single frozen in-flight slot; default-sink attempts run detached and + * concurrently. release/session teardown aborts them all, keeping every + * promise bounded. */ export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Clipboard-projection draft at enter time; settlement clears it only after acceptance. */ + /** Clipboard-projection draft captured before an optimistic default-send commit. */ readonly draftSnapshot: string /** Default-message delivery intent retained while slash adjudication is pending. */ readonly mode: InputSubmitMode @@ -365,6 +367,8 @@ export type InputEvent = | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } /** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */ | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string } + /** Settlement of one optimistic default send, independent of the frozen command slot. */ + | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -376,7 +380,13 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } + /** Detached default send; the shell captures its editor projection before the following commit effect. */ + | { + readonly type: 'default-sink' + readonly attempt: SubmitAttempt + readonly draft: string + readonly mode: InputSubmitMode + } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } /** * Clear the committed draft in the editor and cut undo history. A string diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index b95b1ce5b1..377a75ee00 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -28,6 +28,10 @@ export interface ComposerAttachment { id: DraftAttachmentId file: File previewUrl: string + /** Intrinsic pixel width, filled asynchronously by the intake header probe. */ + width?: number + /** Intrinsic pixel height, filled asynchronously by the intake header probe. */ + height?: number } /** Input state handed to the optional attachment presentation plugin. */ @@ -44,12 +48,35 @@ export interface ComposerAttachmentsOwnerProps { dropLimits?: { readonly count: number; readonly size: string } | undefined } -/** Durable image group handed to the optional attachment presentation plugin. */ +/** + * One image inside a message record: a durable admitted reference, or the + * local preview of a submission echo whose admission is still in flight. + */ +export type MessageImageSource = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + /** Browser-owned preview URL (lifecycle stays with the submitter). */ + readonly url: string + readonly name?: string + /** Intrinsic pixel width, when the intake probe has resolved it. */ + readonly width?: number + /** Intrinsic pixel height, when the intake probe has resolved it. */ + readonly height?: number + } + } + +/** Durable image loader with an optional synchronous cache read. */ +export type MessageImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { + peek?: (attachment: ImageAttachmentRef) => string | undefined +} + +/** Message image group handed to the optional attachment presentation plugin. */ export interface MessageImagesOwnerProps { - /** Durable image references in source order. */ - images: readonly { readonly attachment: ImageAttachmentRef }[] - /** Session-authorized image URL loader. */ - loadImage: (attachment: ImageAttachmentRef) => Promise + /** Durable references or submission-echo previews in source order. */ + images: readonly MessageImageSource[] + /** Session-authorized image URL loader for the durable arm. */ + loadImage: MessageImageLoader /** Horizontal placement inside the owning record. */ align: 'start' | 'end' } diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index ffb132cd92..6cec0a0fa1 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -214,6 +214,29 @@ export class UiConversation extends Service { return this.images.resolve(sessionId, attachment) } + /** + * Read a cached durable image URL synchronously when one is available. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference from a session event. + * @returns current preview or canonical URL, if cached. + */ + peekImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined { + return this.images.peek(sessionId, attachment) + } + + /** + * Adopt an already-displayable URL for one durable reference (see + * HistoricalImageCache.seed): the transcript node then renders it without a + * byte round-trip. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL displays. + * @param url - browser URL to adopt. + * @returns whether the cache took URL ownership. + */ + seedImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + return this.images.seed(sessionId, attachment, url) + } + /** * Canonicalize one `request/header` event against the previous prompt state. * diff --git a/packages/client/ui-conversation/src/client/conversation/historical-images.ts b/packages/client/ui-conversation/src/client/conversation/historical-images.ts index 602b104c1d..6fca566051 100644 --- a/packages/client/ui-conversation/src/client/conversation/historical-images.ts +++ b/packages/client/ui-conversation/src/client/conversation/historical-images.ts @@ -8,7 +8,8 @@ import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' interface ImageUrlEntry { readonly sessionId: SessionId readonly generation: number - readonly pending: Promise + current?: string + pending: Promise } /** Resolve durable Conversation images and release their browser URLs with Session scope. */ @@ -35,7 +36,7 @@ export class HistoricalImageCache { */ resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed')) - const key = `${sessionId}:${attachment.attachmentId}` + const key = this.key(sessionId, attachment) const cached = this.entries.get(key) if (cached !== undefined) return cached.pending const binding = this.sessions.binding(sessionId) @@ -43,28 +44,105 @@ export class HistoricalImageCache { return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`)) } this.bindScope(sessionId, binding.ctx) - const generation = this.generations.get(sessionId) ?? 0 - const pending = binding.session.readAttachment(attachment.attachmentId) + const entry: ImageUrlEntry = { + sessionId, + generation: this.generations.get(sessionId) ?? 0, + pending: Promise.resolve(''), + } + this.entries.set(key, entry) + entry.pending = this.loadCanonical(key, entry, attachment) + return entry.pending + } + + /** + * Return an already-displayable URL without starting a read. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference. + * @returns current preview or canonical URL when cached. + */ + peek(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined { + return this.entries.get(this.key(sessionId, attachment))?.current + } + + /** + * Adopt a submission preview while fetching the durable admitted bytes. + * The preview is available synchronously, then replaced and revoked when + * the canonical attachment read completes. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL temporarily displays. + * @param url - browser URL to adopt. + * @returns whether the cache took ownership. + */ + seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + if (this.disposed) return false + const key = this.key(sessionId, attachment) + if (this.entries.has(key)) return false + const binding = this.sessions.binding(sessionId) + if (binding === undefined) return false + this.bindScope(sessionId, binding.ctx) + const entry: ImageUrlEntry = { + sessionId, + generation: this.generations.get(sessionId) ?? 0, + current: url, + pending: Promise.resolve(url), + } + this.urls.add(url) + this.entries.set(key, entry) + entry.pending = this.loadCanonical(key, entry, attachment).catch((error: unknown) => { + if (this.entries.get(key) === entry && entry.current === url) { + this.entries.delete(key) + this.releaseUrl(url) + } + throw error + }) + // Seed begins the durable read before a transcript image necessarily + // mounts. Keep that legitimate no-consumer path from becoming an + // unhandled rejection; resolve() still returns the rejecting promise. + void entry.pending.catch(() => {}) + return true + } + + private key(sessionId: SessionId, attachment: ImageAttachmentRef): string { + return `${sessionId}:${attachment.attachmentId}` + } + + private loadCanonical( + key: string, + entry: ImageUrlEntry, + attachment: ImageAttachmentRef, + ): Promise { + const binding = this.sessions.binding(entry.sessionId) + if (binding === undefined) return Promise.reject(new Error(`ui-conversation: unknown session "${entry.sessionId}"`)) + return binding.session.readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed') - if ((this.generations.get(sessionId) ?? 0) !== generation) { - throw new Error('ui-conversation image scope was released before loading completed') - } + this.assertLive(key, entry) + let url: string if (typeof URL.createObjectURL !== 'function') { - return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + url = `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + } else { + const bytes = Uint8Array.from(result.value.data) + url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) } - const bytes = Uint8Array.from(result.value.data) - const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + this.assertLive(key, entry) this.urls.add(url) + const previous = entry.current + entry.current = url + if (previous !== undefined && previous !== url) this.releaseUrl(previous) return url }) .catch((error: unknown) => { - if (this.entries.get(key)?.generation === generation) this.entries.delete(key) + if (this.entries.get(key) === entry && entry.current === undefined) this.entries.delete(key) throw error }) - this.entries.set(key, { sessionId, generation, pending }) - return pending + } + + private assertLive(key: string, entry: ImageUrlEntry): void { + if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed') + if (this.entries.get(key) !== entry + || (this.generations.get(entry.sessionId) ?? 0) !== entry.generation) { + throw new Error('ui-conversation image scope was released before loading completed') + } } private bindScope(sessionId: SessionId, scope: Context): void { @@ -81,15 +159,15 @@ export class HistoricalImageCache { for (const [key, entry] of this.entries) { if (entry.sessionId !== sessionId) continue this.entries.delete(key) - void entry.pending.then((url) => { - if (!this.urls.delete(url)) return - revokeUrl(url) - }, () => { - // Failed and invalidated loads create no browser URL. - }) + if (entry.current !== undefined) this.releaseUrl(entry.current) } } + private releaseUrl(url: string): void { + if (!this.urls.delete(url)) return + revokeUrl(url) + } + private dispose(): void { if (this.disposed) return this.disposed = true diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 67e2f28611..102b385e8c 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -53,7 +53,8 @@ export type { ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews, + MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation, + UseConversationViews, } from './contract/slots.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 0b588c4fd6..e2fb81f09b 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -23,7 +23,7 @@ import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, - QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, + Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -113,6 +113,13 @@ const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu /** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */ const HISTORY_MERGE_DELAY_MS = 1000 +/** Editor and attachment snapshot owned by one detached default send. */ +interface DetachedDraft { + readonly draft: string + readonly occurrences: readonly Occurrence[] + readonly imageIds: readonly DraftAttachmentId[] +} + /** * The per-session input facade: scoped-event application verbs + * setDraft/submit + the published InputState store, over a shell-owned @@ -144,13 +151,24 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] - /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ - private imageSendInFlight = false private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined /** Live lexicon subscription disposer; undefined until the controller resolves. */ private lexiconOff: (() => void) | undefined + /** Default sends retained until admission settles or scope disposal releases their images. */ + private readonly detachedDrafts = new Map() + /** Failed default sends waiting to be restored together in submission order. */ + private readonly failedDetached = new Map() + /** Revision of the last automatic failure restoration. */ + private failedRestoreRev: number | undefined + private restoringFailures = false + private imageFlightSeq = 0 + /** Image-only sends retained until admission settles or scope disposal releases their images. */ + private readonly imageFlights = new Map() constructor(private readonly deps: SessionInputDeps) { this.editor = createEditor({ @@ -218,6 +236,10 @@ export class SessionInputShell implements SessionInput { // caret motion and subscribers do not re-render per caret move. if (projectionContentChanged(prev, this.projection)) { this.rev += 1 + if (!this.restoringFailures && this.failedRestoreRev !== undefined) { + this.failedDetached.clear() + this.failedRestoreRev = undefined + } this.dispatchRun(({ type: 'draft-changed', draft: this.projection.clipboardText })) } const caret = this.projection.caret @@ -337,17 +359,22 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { + if (this.snapshot.phase === 'plain') { const imageIds = [...this.imageIds] - this.imageSendInFlight = true - void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { - this.imageSendInFlight = false - if (this.disposed) return - if (outcome.kind === 'success') this.commitSend(imageIds) - else if (outcome.text !== undefined) this.notify('error', outcome.text) + const controller = new AbortController() + this.imageFlightSeq += 1 + const flight = this.imageFlightSeq + this.imageFlights.set(flight, { controller, imageIds }) + this.commitSend(imageIds) + void this.deps.defaultSink('', imageIds, mode, controller.signal).then((outcome) => { + if (this.disposed || !this.imageFlights.delete(flight)) return + if (outcome.kind === 'success') return + this.restoreImages(imageIds) + if (outcome.text !== undefined) this.notify('error', outcome.text) }, (error: unknown) => { - this.imageSendInFlight = false - if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) + if (this.disposed || !this.imageFlights.delete(flight)) return + this.restoreImages(imageIds) + this.notify('error', error instanceof Error ? error.message : String(error)) }) } return @@ -534,12 +561,29 @@ export class SessionInputShell implements SessionInput { // ---- wiring-layer extras (not on the frozen SessionInput face) ---- - /** Teardown: abort any in-flight attempt, unbind the editor, and stop accepting async settlements. */ - dispose(): void { + /** + * Teardown the shell and return every browser-owned image still retained by + * the draft or an unsettled default send. + * @returns image ids the scope disposer must release. + */ + dispose(): readonly DraftAttachmentId[] { + if (this.disposed) return [] + const retained = new Set(this.imageIds) + for (const record of this.detachedDrafts.values()) { + for (const imageId of record.imageIds) retained.add(imageId) + } + for (const flight of this.imageFlights.values()) { + for (const imageId of flight.imageIds) retained.add(imageId) + flight.controller.abort() + } this.disposed = true this.dispatchRun(({ type: 'release' })) this.unregister() this.editor.setRootElement(null) + this.detachedDrafts.clear() + this.failedDetached.clear() + this.imageFlights.clear() + return [...retained] } /** Read the live input state (guard derivation reads here). */ @@ -635,25 +679,34 @@ export class SessionInputShell implements SessionInput { /** * Prompt serialization before the sink: expand each chip occurrence to its * owner's model form via the session controller's codec routing. Owner - * missing / serialize failure / disposal blocks the send — notice + draft - * and chips retained, never a silent downgrade to the clipboard text. - * Chip-free drafts skip the async detour. + * missing or serialization failure rejects the detached send and restores + * its editor snapshot. Chip-free drafts skip the async detour. */ - private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { + private sinkSerialized( + attempt: SubmitAttempt, + draft: string, + mode: InputSubmitMode, + ): void { const imageIds = [...this.imageIds] + this.imageIds = [] const occurrences = this.projection.occurrences + const record = { draft, occurrences, imageIds } + this.detachedDrafts.set(attempt.seq, record) + if (this.failedRestoreRev === this.rev) { + this.failedDetached.clear() + this.failedRestoreRev = undefined + } if (occurrences.length === 0) { - this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal)) return } const inputTriggers = this.deps.inputTriggers?.() - const controller = new AbortController() void Promise.all(occurrences.map(async (o) => { if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`) return { offset: o.offset, length: o.length, - text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal), + text: await inputTriggers.serializeReference(o.source, o.ref, attempt.signal), } })).then( (parts) => { @@ -668,53 +721,116 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + part.length } out += draft.slice(cursor) - this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal)) }, (error: unknown) => { - controller.abort() if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.dispatchRun(({ - type: 'submit-settled', attempt, ok: false, draft: this.projection.clipboardText, message, - })) + this.settleDetachedFailure(attempt, message) }, ) } - /** Settle one admission attempt; successful sends consume only their captured images. */ - private settleSubmit( + /** Settle one detached default send independently of other sends. */ + private settleSink( attempt: SubmitAttempt, pending: Promise, - imageIds: readonly DraftAttachmentId[] = [], ): void { pending.then( (outcome) => { if (this.dead(attempt)) return - if (outcome.kind === 'success' && imageIds.length > 0) { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + if (outcome.kind !== 'success') { + this.settleDetachedFailure(attempt, outcome.text) + return } - this.dispatchRun(({ - type: 'submit-settled', - attempt, - ok: outcome.kind === 'success', - draft: this.projection.clipboardText, - outcome, - })) + this.detachedDrafts.delete(attempt.seq) + this.dispatchRun(({ type: 'sink-settled', attempt, ok: true, outcome })) }, (error: unknown) => { if (this.dead(attempt)) return - this.dispatchRun(({ - type: 'submit-settled', - attempt, - ok: false, - draft: this.projection.clipboardText, - message: error instanceof Error ? error.message : String(error), - })) + this.settleDetachedFailure(attempt, error instanceof Error ? error.message : String(error)) }, ) } + /** Restore one failed detached send without overwriting text entered after a restoration. */ + private settleDetachedFailure(attempt: SubmitAttempt, message?: string): void { + const record = this.detachedDrafts.get(attempt.seq) + if (record === undefined) return + this.detachedDrafts.delete(attempt.seq) + this.restoreImages(record.imageIds) + this.failedDetached.set(attempt.seq, record) + if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) { + this.restoreFailedDrafts() + } + this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, ...(message === undefined ? {} : { message }) })) + } + + /** Rebuild all currently failed snapshots in submission order. */ + private restoreFailedDrafts(): void { + const records = [...this.failedDetached.entries()].sort(([a], [b]) => a - b).map(([, record]) => record) + if (records.length === 0) return + const separator = '\n\n' + let draft = '' + const occurrences: Occurrence[] = [] + for (const record of records) { + const base = draft.length + (draft === '' ? 0 : separator.length) + if (draft !== '') draft += separator + draft += record.draft + for (const occurrence of record.occurrences) { + occurrences.push({ ...occurrence, offset: base + occurrence.offset }) + } + } + this.restoringFailures = true + try { + this.editor.update(() => { + const root = $getRoot() + root.clear() + let paragraph = $createParagraphNode() + root.append(paragraph) + const appendText = (text: string): void => { + const lines = text.split('\n') + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] + if (line !== '') paragraph.append($createTextNode(line)) + if (i < lines.length - 1) { + paragraph = $createParagraphNode() + root.append(paragraph) + } + } + } + let cursor = 0 + for (const occurrence of occurrences) { + appendText(draft.slice(cursor, occurrence.offset)) + paragraph.append(new ReferenceChipNode({ + source: occurrence.source, + ref: occurrence.ref, + label: occurrence.label, + ...(occurrence.appearance === undefined ? {} : { appearance: occurrence.appearance }), + clipboardText: occurrence.clipboardText, + }, occurrence.invalid === true)) + cursor = occurrence.offset + occurrence.length + } + appendText(draft.slice(cursor)) + root.selectEnd() + }, { discrete: true, tag: HISTORY_MERGE_TAG }) + this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined) + this.failedRestoreRev = this.rev + } finally { + this.restoringFailures = false + } + } + + /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */ + private restoreImages(imageIds: readonly DraftAttachmentId[]): void { + if (imageIds.length === 0) return + const current = new Set(this.imageIds) + const restored = imageIds.filter(id => !current.has(id)) + if (restored.length === 0) return + this.imageIds = [...restored, ...this.imageIds] + this.publish() + } + /** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */ private adjudicate(attempt: SubmitAttempt, draft: string): void { const inputTriggers = this.deps.inputTriggers?.() diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 9e53281302..2cddf23feb 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -121,8 +121,7 @@ export class InputHub implements SessionInputResolver { ] return () => { for (const off of offs) off() - const drafts = shell.snapshot.imageIds - shell.dispose() + const drafts = shell.dispose() this.shells.delete(id) const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined for (const imageId of drafts) conversation?.releaseDraftImage(imageId) diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 5f097c107a..cfc757d63b 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -1,13 +1,11 @@ /** * SubmitMachine: the pure per-session submit-plane state machine. - * Events in, effects out; zero React / DOM / cordis. Package-private — the - * SessionInput shell is the only caller and the sole executor of the - * returned effects. + * Events in, effects out; zero React / DOM / cordis. Package-private; the + * SessionInput shell owns editor state and executes the returned effects. * - * The machine owns phase, claim, and the in-flight SubmitAttempt; it never - * holds the draft. Text truth lives in the shell's Lexical editor, and every - * decision that needs the draft reads it from the event payload (claim - * integrity watch, enter snapshots, settlement suffix/re-entry decisions). + * Claimed commands occupy the frozen in-flight slot. Ordinary messages detach + * at Enter, so the editor can clear immediately and accept another message + * while earlier admissions remain in flight. */ import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts' @@ -17,13 +15,7 @@ function unreachable(value: never): never { throw new Error(`unreachable input event: ${JSON.stringify(value)}`) } -/** - * Strip the claim token off a draft to yield submit args. Leading whitespace - * (incl. newlines — leading-trigger trim) is tolerated; a bare `/name` - * missing the token's trailing separator yields empty args. Exactly one - * separator char is consumed; the remainder — newlines included — stays - * verbatim (`/goal x\ny` → `x\ny`). - */ +/** Strip a claimed command token from its submit-time draft. */ function argsAfter(draft: string, token: string): string { const s = draft.trimStart() if (s.startsWith(token)) return s.slice(token.length) @@ -41,14 +33,7 @@ export interface SubmitSnapshot { readonly claim?: InputState['claim'] } -/** - * Pure submit machine, one instance per session (per-session isolation is by - * construction). The machine constructs one AbortController per SubmitAttempt - * at enter time and aborts it itself on release; the shell never aborts, it - * only observes attempt.signal on its adjudicate/submit promises. Stale - * attempts (any adjudicated / adjudication-failed / submit-settled whose seq - * is not the in-flight one) are dropped: same state, zero effects. - */ +/** Pure phase, claim, and attempt owner for one Session input. */ export class SubmitMachine { private phase: InputState['phase'] = 'plain' private claim: CommandClaim | undefined @@ -57,6 +42,8 @@ export class SubmitMachine { readonly attempt: SubmitAttempt readonly controller: AbortController } | undefined + /** Ordinary sends detached from the editor, retained for settlement validation and cancellation. */ + private readonly detached = new Map() /** Read-only snapshot of the submit-plane state. */ get state(): SubmitSnapshot { @@ -77,8 +64,8 @@ export class SubmitMachine { /** * Feed one event through the machine. - * @param ev - Input event; the single write path for all submit-plane state. - * @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events. + * @param ev - submit-plane event. + * @returns effects for the SessionInput shell, in execution order. */ dispatch(ev: InputEvent): readonly InputEffect[] { switch (ev.type) { @@ -88,14 +75,15 @@ export class SubmitMachine { case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) + case 'sink-settled': return this.onSinkSettled(ev) case 'send-committed': return this.onSendCommitted() case 'release': return this.onRelease() default: return unreachable(ev) } } - /** Claimed integrity watch: any draft that breaks the token prefix releases the claim. */ - private onDraftChanged(draft: string): InputEffect[] { + /** Claimed integrity watch: a draft that breaks the token prefix releases the claim. */ + private onDraftChanged(draft: string): readonly InputEffect[] { if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) { this.phase = 'plain' this.claim = undefined @@ -103,26 +91,52 @@ export class SubmitMachine { return [] } - /** The editor applied a claim-token replacement: enter claimed (busy phases refuse). */ - private onClaim(claim: CommandClaim): InputEffect[] { + /** The editor applied a claim-token replacement; busy phases refuse another claim. */ + private onClaim(claim: CommandClaim): readonly InputEffect[] { if (this.phase !== 'plain' && this.phase !== 'claimed') return [] this.claim = claim this.phase = 'claimed' return [] } - // ---- submit plane ---- - - /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt { + /** Mint an attempt and controller without assigning its lifecycle owner. */ + private mintAttempt(mode: InputSubmitMode, draft: string): { + readonly attempt: SubmitAttempt + readonly controller: AbortController + } { const controller = new AbortController() this.seq += 1 - const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode } - this.inflight = { attempt, controller } - return attempt + return { + attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode }, + controller, + } } - private onEnter(mode: InputSubmitMode, draft: string): InputEffect[] { + /** Mint the frozen command/adjudication attempt. */ + private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt { + const flight = this.mintAttempt(mode, draft) + this.inflight = flight + return flight.attempt + } + + /** Mint an ordinary send that leaves the phase plain. */ + private beginDetached(mode: InputSubmitMode, draft: string): SubmitAttempt { + const flight = this.mintAttempt(mode, draft) + this.detached.set(flight.attempt.seq, flight.controller) + this.claim = undefined + this.phase = 'plain' + return flight.attempt + } + + /** Default-send effects capture the sink input before the editor commit. */ + private detachedEffects(attempt: SubmitAttempt): readonly InputEffect[] { + return [ + { type: 'default-sink', attempt, draft: attempt.draftSnapshot, mode: attempt.mode }, + { type: 'commit-draft', retainSuffixOf: attempt.draftSnapshot }, + ] + } + + private onEnter(mode: InputSubmitMode, draft: string): readonly InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { const attempt = this.beginAttempt(mode, draft) @@ -136,12 +150,13 @@ export class SubmitMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft }] } - const attempt = this.beginAttempt(mode, draft) - this.phase = 'submitting' - return [{ type: 'default-sink', attempt, draft, mode }] + return this.detachedEffects(this.beginDetached(mode, draft)) } - private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { + private onAdjudicated( + attempt: SubmitAttempt, + outcome: Extract['outcome'], + ): readonly InputEffect[] { const flight = this.inflight if (this.phase !== 'adjudicating' || flight === undefined || flight.attempt.seq !== attempt.seq) return [] if (outcome !== undefined && outcome !== 'handled' && 'claim' in outcome) { @@ -154,31 +169,22 @@ export class SubmitMachine { args: argsAfter(attempt.draftSnapshot, outcome.claim.token), }] } - // 'handled' (source dealt internally), {insert}/{text} (no enter-time span - // semantics), or a miss: all land plain; only the miss flows to the sink. - if (outcome === undefined) { - this.phase = 'submitting' - return [{ - type: 'default-sink', - attempt, - draft: attempt.draftSnapshot, - mode: attempt.mode, - }] - } this.inflight = undefined this.phase = 'plain' - return [] + if (outcome !== undefined) return [] + this.detached.set(attempt.seq, flight.controller) + return this.detachedEffects(attempt) } - private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { + private onAdjudicationFailed(attempt: SubmitAttempt, message: string): readonly InputEffect[] { if (this.phase !== 'adjudicating' || this.inflight?.attempt.seq !== attempt.seq) return [] this.inflight = undefined this.phase = 'plain' - // Draft retained: warmup failure never silently downgrades to a prompt. return [{ type: 'notice', level: 'error', text: message }] } - private onSubmitSettled(ev: Extract): InputEffect[] { + /** Claimed command settlement retains the frozen transaction semantics. */ + private onSubmitSettled(ev: Extract): readonly InputEffect[] { const flight = this.inflight if (this.phase !== 'submitting' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return [] this.inflight = undefined @@ -192,10 +198,6 @@ export class SubmitMachine { return effects } const text = ev.message ?? ev.outcome?.text - // Keep the same command claim only while the live draft still equals the - // enter-time draft; user input typed during flight wins. - // Claimed re-entry additionally requires the watch to hold — an - // enter-path snapshot may carry leading whitespace the token never had. if (ev.draft === flight.attempt.draftSnapshot && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) { this.phase = 'claimed' @@ -206,18 +208,28 @@ export class SubmitMachine { return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** Clear the draft after an accepted image-only send (no suffix retention: there was no draft). */ - private onSendCommitted(): InputEffect[] { + /** Settle one ordinary send independently of current phase and other detached sends. */ + private onSinkSettled(ev: Extract): readonly InputEffect[] { + if (!this.detached.delete(ev.attempt.seq)) return [] + const text = ev.message ?? ev.outcome?.text + if (text === undefined) return [] + return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }] + } + + /** Clear after an accepted image-only send; it has no text suffix to retain. */ + private onSendCommitted(): readonly InputEffect[] { if (this.phase !== 'plain') return [] this.claim = undefined return [{ type: 'commit-draft', retainSuffixOf: null }] } - private onRelease(): InputEffect[] { + private onRelease(): readonly InputEffect[] { if (this.inflight !== undefined) { this.inflight.controller.abort() this.inflight = undefined } + for (const controller of this.detached.values()) controller.abort() + this.detached.clear() this.phase = 'plain' this.claim = undefined return [] diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index e8509b2c6f..9bda147004 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -75,8 +75,11 @@ export const zh = { 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', + 'ask.cancelledDetail': '本轮已取消,未提交回答', 'ask.interrupted': '已中断', + 'ask.interruptedDetail': '本轮已中断,未提交回答', 'ask.answered': '{answered}/{total} 已回答', + 'ask.skipped': '未回答', 'bash.running': '运行中', 'bash.failed': '失败', 'bash.stopped': '已停止', @@ -220,8 +223,11 @@ export const en = { 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', + 'ask.cancelledDetail': 'This question set was cancelled before answers were submitted.', 'ask.interrupted': 'interrupted', + 'ask.interruptedDetail': 'This question set was interrupted before answers were submitted.', 'ask.answered': '{answered}/{total} answered', + 'ask.skipped': 'Not answered', 'bash.running': 'Running', 'bash.failed': 'Failed', 'bash.stopped': 'Stopped', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 6b7d94829f..2fb0c99b51 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -9,11 +9,13 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { + ISessions, PendingSubmissionRetirement, SessionFace, +} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' @@ -72,6 +74,62 @@ function browserDraftAttachment(file: File): ComposerAttachment { } } +/** + * Fill the draft's intrinsic dimensions once the browser parses the image + * header (a metadata read off the preview URL, not a full decode). Failures + * and non-browser runtimes leave them absent — consumers size those images + * from CSS constraints instead. The descriptors stay registry-owned; submit + * reads the dimensions into an immutable echo snapshot, so this late write + * does not require a store notification. + */ +function probeDimensions(attachment: ComposerAttachment): void { + if (typeof Image !== 'function') return + const probe = new Image() + probe.onload = () => { + attachment.width = probe.naturalWidth + attachment.height = probe.naturalHeight + } + probe.src = attachment.previewUrl +} + +/** Give the echo one paint opportunity without letting a throttled frame clock block admission. */ +function nextPaint(): Promise { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + setTimeout(resolve, 0) + return + } + let settled = false + const finish = () => { + if (settled) return + settled = true + clearTimeout(fallback) + setTimeout(resolve, 0) + } + const fallback = setTimeout(finish, 100) + requestAnimationFrame(finish) + } else { + setTimeout(resolve, 0) + } + }) +} + +/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */ +function base64Of(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const url = reader.result as string + resolve(url.slice(url.indexOf(',') + 1)) + } + reader.onerror = () => { + reject(reader.error ?? new Error('conversation: image read failed')) + } + reader.readAsDataURL(file) + }) +} + /** Unsupported browser-declared image type, localized by the UI boundary. */ export class UnsupportedImageMediaTypeError extends Error { /** Browser-declared MIME value, possibly empty. */ @@ -125,7 +183,12 @@ export class ConversationController extends Service implements IConversation { } /** - * Submit ordered draft images with text through one host admission. + * Submit ordered draft images with text through one host admission. A local + * submission echo enters the session snapshot synchronously; serialization + * and the prompt round-trip start after the browser can paint it. On the + * echo's observed retirement the draft images hand their preview URLs to + * the durable image cache and leave the registry; on failure they stay + * registered so the composer can restore them. * @param session - target session. * @param text - serialized prompt text. * @param imageIds - ordered draft-local attachment ids. @@ -144,11 +207,41 @@ export class ConversationController extends Service implements IConversation { if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode, signal) + if (session.getSnapshot().subagent !== null) { + const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + const result = await session.prompt(content, mode, signal) + return result.ok ? { kind: 'success' } : { kind: 'error' } + } + let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined + const retirement = attachments.length === 0 + ? undefined + : new Promise((resolve) => { finishRetirement = resolve }) + const submission = session.beginSubmission({ + text, + images: attachments.map(attachment => ({ + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + })), + onRetire: (settlement) => { + this.settleSubmittedImages(session.sessionId, attachments, settlement) + finishRetirement?.(settlement) + }, + }) + let content: Parameters[0] + try { + await nextPaint() + const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + } catch (error) { + submission.abandon() + throw error + } + const result = await session.prompt(content, mode, signal, submission.requestId) if (!result.ok) return { kind: 'error' } - this.releaseDraftImages(attachments) + if (retirement !== undefined && (await retirement).reason !== 'observed') return { kind: 'error' } return { kind: 'success' } } @@ -162,6 +255,7 @@ export class ConversationController extends Service implements IConversation { return files.map((file) => { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) + probeDimensions(attachment) return attachment }) } @@ -264,6 +358,31 @@ export class ConversationController extends Service implements IConversation { return sessions } + /** + * Settle one submission's draft images when its echo retires. Observed: + * each image leaves the registry, handing its preview URL to the durable + * image cache (seeded under the admitted reference so the transcript node + * renders immediately while the cache reads canonical bytes) or revoking it + * when the cache already holds that reference. Failed: nothing changes; + * the ids stay registered for the composer's rail restore. + */ + private settleSubmittedImages( + sessionId: SessionId, + attachments: readonly ComposerAttachment[], + retirement: PendingSubmissionRetirement, + ): void { + if (retirement.reason !== 'observed') return + const uiConversation = this.ctx.get('uiConversation') + attachments.forEach((attachment, index) => { + const live = this.draftAttachments.get(attachment.id) + if (live === undefined) return + this.draftAttachments.delete(attachment.id) + const ref = retirement.attachments[index] + if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return + revokePreview(attachment.previewUrl) + }) + } + /** Convert browser files to canonical base64 prompt parts. */ private serializeImages(images: readonly File[]): Promise[0]> { return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) @@ -273,7 +392,7 @@ export class ConversationController extends Service implements IConversation { private async encodeImage(file: File): Promise { return { mediaType: imageMediaType(file.type), - data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), + data: await base64Of(file), ...(file.name === '' ? {} : { name: file.name }), } } diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index db5bf29baa..b4103dbda4 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -102,10 +102,14 @@ describe('Conversation inject API', () => { actions.setDraft('hello') actions.submit() - await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') }) - expect(b.sessionFake.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), - ) + // Optimistic commit clears the draft at enter; the prompt lands after the + // paint-yield inside the send pipeline. + expect(state.getSnapshot().draft).toBe('') + await vi.waitFor(() => { + expect(b.sessionFake.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), expect.any(String), + ) + }) b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 20a7a0f7aa..9ef8c333dd 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -21,6 +21,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SESSION_ID, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, @@ -43,6 +44,7 @@ function fakeSession(): SessionFace { projections: { faceOf: () => createSnapshotStore(undefined) }, getSnapshot: () => snapshot.getSnapshot(), subscribe: listener => snapshot.subscribe(listener), + beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }), prompt: () => Promise.reject(new Error('unused fake Session operation')), readAttachment: () => Promise.reject(new Error('unused fake Session operation')), updateQueue: () => Promise.reject(new Error('unused fake Session operation')), diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 71aa3d6018..dee570b52a 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -25,4 +25,75 @@ describe('HistoricalImageCache', () => { await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed') await runtime.dispose() }) + + it('shows a seeded URL synchronously, replaces it with canonical bytes, and revokes both', async () => { + const revoked: string[] = [] + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:canonical') + const originalRevoke = URL.revokeObjectURL.bind(URL) + URL.revokeObjectURL = (url: string) => { revoked.push(url) } + try { + const read = Promise.withResolvers>>() + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ id: 's1', session: { readAttachment: () => read.promise } }) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) + expect(cache.peek(sessionId, attachment)).toBe('blob:seeded') + expect(cache.seed(sessionId, attachment, 'blob:duplicate')).toBe(false) + const canonical = cache.resolve(sessionId, attachment) + read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) + await expect(canonical).resolves.toBe('blob:canonical') + expect(cache.peek(sessionId, attachment)).toBe('blob:canonical') + expect(revoked).toContain('blob:seeded') + + await runtime.sessions.remove(sessionId) + await Promise.resolve() + expect(revoked).toContain('blob:canonical') + await runtime.dispose() + } finally { + created.mockRestore() + URL.revokeObjectURL = originalRevoke + } + }) + + it('revokes a seeded preview when canonical bytes cannot be read', async () => { + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ + id: 's1', + session: { + readAttachment: () => Promise.resolve({ + ok: false, + error: { code: 'attachment-error', message: 'missing', details: {} }, + } as never), + }, + }) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-missing'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) + await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-error: missing') + expect(cache.peek(sessionId, attachment)).toBeUndefined() + expect(revoked).toHaveBeenCalledWith('blob:seeded') + await runtime.dispose() + } finally { + revoked.mockRestore() + } + }) + + it('refuses to seed for an unknown session', async () => { + const runtime = await SlotTestRuntime.create() + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-unknown'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + expect(cache.seed('missing' as never, attachment, 'blob:orphan')).toBe(false) + await runtime.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 7fa6c189a5..51dcc77f4d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -376,11 +376,28 @@ describe('image draft rail', () => { sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) - expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) + // Optimistic commit: the rail clears at submit, before the admission settles. + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) await act(async () => { settle({ kind: 'success' }) }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + }) + + it('returns an image-only draft to the rail when its admission fails', async () => { + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + ] + const result = bench({ attachments }) + const { textarea, sink } = result + let fail!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { fail = resolve })) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) }) await vi.waitFor(() => { - expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) }) + expect(result.view.getByRole('alert').textContent).toContain('图片发送失败') }) it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 2133d03477..bdb878672b 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -121,8 +121,9 @@ describe('matrix row: plain', () => { expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) - expect(shell.snapshot.phase).toBe('submitting') - await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + // The detached default send never freezes the composer. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') expect(shell.snapshot.claim).toBeUndefined() }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index 5f07952ec6..c0319e9e36 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -99,9 +99,12 @@ describe('reference submission', () => { }) shell.submit('queue') - expect(shell.snapshot.phase).toBe('submitting') + // Optimistic commit: the composer clears at enter and stays unlocked + // while the detached flight runs. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe(`${mention} `) }) expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot).toMatchObject({ @@ -114,10 +117,10 @@ describe('reference submission', () => { }) shell.submit('queue') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.draft).toBe('') + expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) }) - expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot.occurrences).toEqual([]) expect(serializeReference).toHaveBeenCalledTimes(2) }) @@ -137,11 +140,11 @@ describe('reference submission', () => { }) chip(shell) shell.submit() + // The serializer rejection restores the optimistic commit with its chip. await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe(`${mention} `) }) expect(sink).not.toHaveBeenCalled() - expect(shell.snapshot.draft).toBe(`${mention} `) expect(shell.snapshot.occurrences).toHaveLength(1) expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', @@ -165,7 +168,9 @@ describe('reference submission', () => { shell.dispose() expect(signal?.aborted).toBe(true) expect(shell.snapshot.phase).toBe('plain') - expect(shell.snapshot.draft).toBe('send this') + // The optimistic commit stands: disposal drops the settlement, so the + // sent draft is not restored into the dying composer. + expect(shell.snapshot.draft).toBe('') }) it('retains a rejected default message without duplicating its prompt error notice', async () => { @@ -182,6 +187,25 @@ describe('reference submission', () => { expect(shell.snapshot.draft).toBe('retry this') expect(shell.notices.getSnapshot()).toBeNull() }) + + it('restores concurrent failed messages in submission order', async () => { + const settlements: Array<(outcome: SubmitOutcome) => void> = [] + const shell = new SessionInputShell({ + actx: {} as Context, + defaultSink: () => new Promise((resolve) => { settlements.push(resolve) }), + commandImages, + }) + shell.setDraft('first') + shell.submit() + shell.setDraft('second') + shell.submit() + expect(shell.snapshot.draft).toBe('') + + settlements[0]?.({ kind: 'error' }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first') }) + settlements[1]?.({ kind: 'error' }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('first\n\nsecond') }) + }) }) describe('submit transaction hardening', () => { @@ -223,6 +247,24 @@ describe('submit transaction hardening', () => { expect(shell.notices.getSnapshot()).toBeNull() }) + it('aborts an unsettled image-only send and returns its image id at disposal', () => { + let signal: AbortSignal | undefined + const imageId = 'img-flight' as DraftAttachmentId + const shell = new SessionInputShell({ + actx: {} as Context, + defaultSink: (_text, _ids, _mode, received) => { + signal = received + return new Promise(() => {}) + }, + commandImages, + }) + shell.addImages([imageId]) + shell.submit() + expect(signal?.aborted).toBe(false) + expect(shell.dispose()).toEqual([imageId]) + expect(signal?.aborted).toBe(true) + }) + it('re-tracks at the caret when an insert-text splice lands (directory descent reopens the menu)', () => { const track = vi.fn() const lexicon = { getSnapshot: () => new Map(), subscribe: () => () => {} } diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 75701f7076..f1308c05c5 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -39,6 +39,7 @@ function snapshotWith(queue: QueuedMessage[]): SessionSnapshot { return { sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, + pendingSubmissions: [], lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false, } } 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 4633d07e20..a6582d42b8 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -102,6 +102,28 @@ describe('ConversationController', () => { await b.runtime.dispose() }) + it('releases an image removed from the rail by an unsettled optimistic send', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), + ]) + if (attachment === undefined) throw new Error('draft attachment missing') + b.shell.addImages([attachment.id]) + b.shell.submit() + expect(b.shell.snapshot.imageIds).toEqual([]) + await b.runtime.sessions.remove('s1') + expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(revoked).toHaveBeenCalledWith('blob:detached') + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + it('validates every MIME type before allocating previews', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') @@ -131,6 +153,220 @@ describe('ConversationController', () => { }) }) +describe('sendSession submission echo', () => { + /** Bench with an observable beginSubmission on the session face. */ + async function echoBench() { + const b = await bench() + const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {} + const abandon = vi.fn() + const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { + retire.onRetire = input.onRetire + return { requestId: 'req-echo' as never, abandon } + }) + await b.runtime.sessions.updateSessionSnapshot('s1', () => {}) + const face = b.runtime.sessions.binding('s1')!.session as unknown as Record + face['beginSubmission'] = beginSubmission + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:echo-1') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + const restore = () => { + created.mockRestore() + revoked.mockRestore() + } + return { ...b, beginSubmission, abandon, retire, revoked, restore } + } + + it('registers the echo before serialization and prompts with its identity', async () => { + const b = await echoBench() + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') + // Synchronous: the echo is registered before any encoding starts. + expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ + text: '带图', + images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], + })) + expect(b.prompt).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + expect(b.prompt).toHaveBeenCalledWith( + [ + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'a.png' }, + { type: 'text', text: '带图' }, + ], + 'queue', + undefined, + 'req-echo', + ) + // The draft stays registered until the echo's observed retirement. + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + b.retire.onRetire?.({ reason: 'observed', attachments: [] }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).toHaveBeenCalledWith('blob:echo-1') + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('hands the preview URL to the image cache on observed retirement instead of revoking it', async () => { + const b = await echoBench() + try { + const seedImageUrl = vi.fn(() => true) + b.runtime.ctx.provide('uiConversation') + b.runtime.ctx.set('uiConversation', { seedImageUrl }) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + const ref = { attachmentId: 'att-1' } + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1') + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).not.toHaveBeenCalled() + // Failed retirement keeps nothing to do; a second retire of released ids is a no-op. + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => { + const b = await echoBench() + try { + b.prompt.mockResolvedValueOnce({ + ok: false, error: { code: 'attachment-error', message: 'nope', details: {} }, + } as never) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue')) + .resolves.toEqual({ kind: 'error' }) + b.retire.onRetire?.({ reason: 'failed' }) + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.revoked).not.toHaveBeenCalled() + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('abandons the echo when encoding fails before the prompt', async () => { + const b = await echoBench() + class FailingReader { + onload: (() => void) | null = null + onerror: (() => void) | null = null + error = new Error('read failed') + readAsDataURL(): void { + queueMicrotask(() => this.onerror?.()) + } + } + vi.stubGlobal('FileReader', FailingReader) + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue')) + .rejects.toThrow('read failed') + expect(b.abandon).toHaveBeenCalledOnce() + expect(b.prompt).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('yields through the macrotask fallback where no frame clock exists', async () => { + const b = await echoBench() + vi.stubGlobal('requestAnimationFrame', undefined) + try { + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '纯文本', [], 'queue')).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '纯文本' }], 'queue', undefined, 'req-echo') + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('bounds the paint yield when the frame clock is throttled', async () => { + const b = await echoBench() + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)) + try { + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '后台标签', [], 'queue') + expect(b.prompt).not.toHaveBeenCalled() + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '后台标签' }], 'queue', undefined, 'req-echo') + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('sends a subagent continuation without registering an unobservable echo', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + const snapshot = session.getSnapshot() + const beginSubmission = vi.spyOn(session, 'beginSubmission') + vi.spyOn(session, 'getSnapshot').mockReturnValue({ + ...snapshot, + subagent: { + address: { parentSessionId: 'parent', childSessionId: 'child', mode: 'continuable' } as never, + }, + }) + const prompt = vi.spyOn(session, 'prompt').mockResolvedValue({ ok: true, value: { accepted: true } }) + await expect(b.root.sendSession(session, '继续', [], 'queue')).resolves.toEqual({ kind: 'success' }) + expect(beginSubmission).not.toHaveBeenCalled() + expect(prompt).toHaveBeenCalledWith([{ type: 'text', text: '继续' }], 'queue', undefined) + await b.runtime.dispose() + }) +}) + +describe('draft image dimension probe', () => { + it('fills intrinsic dimensions from the header probe and skips runtimes without Image', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:probe') + class InstantImage { + onload: (() => void) | null = null + naturalWidth = 0 + naturalHeight = 0 + set src(_value: string) { + this.naturalWidth = 640 + this.naturalHeight = 480 + this.onload?.() + } + } + vi.stubGlobal('Image', InstantImage) + try { + const [probed] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), + ]) + expect(probed).toMatchObject({ width: 640, height: 480 }) + vi.stubGlobal('Image', undefined) + const [unprobed] = b.root.createDraftImages([ + new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), + ]) + expect(unprobed?.width).toBeUndefined() + } finally { + vi.unstubAllGlobals() + created.mockRestore() + } + await b.runtime.dispose() + }) +}) + describe('InputHub queue steering (empty-draft accelerated Enter)', () => { const row = (id: string): QueuedMessage => ({ id: id as never, diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 7a1a63a8b6..af1e72f47e 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -60,7 +60,8 @@ describe('submit-machine: plain × enter', () => { expect(sink.draft).toBe('hello') expect(sink.mode).toBe('queue') expect(sink.attempt.draftSnapshot).toBe('hello') - expect(m.state.phase).toBe('submitting') + expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('hello') + expect(m.state.phase).toBe('plain') }) it('retains an explicit steer mode on the default sink effect', () => { @@ -122,7 +123,8 @@ describe('submit-machine: adjudication outcomes', () => { const sink = effectAt(fx, 0, 'default-sink') expect(sink.draft).toBe('/unknown thing') expect(sink.mode).toBe('steer') - expect(m.state.phase).toBe('submitting') + expect(effectAt(fx, 1, 'commit-draft').retainSuffixOf).toBe('/unknown thing') + expect(m.state.phase).toBe('plain') }) it("'handled' lands plain with zero effects (popup shell path)", () => { @@ -315,7 +317,7 @@ describe('submit-machine: per-session isolation', () => { expect(effectAt(fx, 0, 'default-sink').draft).toBe('hello') a.dispatch({ type: 'submit-settled', attempt, ok: true, draft: '/goal x' }) expect(a.state.phase).toBe('plain') - expect(b.state.phase).toBe('submitting') + expect(b.state.phase).toBe('plain') }) }) diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 97946f4c1d..ce6d154c0c 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -76,6 +76,7 @@ function createSessionsBench(_ctx: Context): SessionsBench { const snapshot = createSnapshotStore({ sessionId: id, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 35e7f7557a..9772bd7a42 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da -README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f +README.md: 658b357992a1926f1f21e109c19b7c0975da58ea +README.zh.md: dcc19f80a15e6e7e81c3dea128a999b83e8311ba diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 6ed2bde147..658b357992 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -35,10 +35,6 @@ The primary field on an editor card is a single **API key** input — the page n The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits. -### Subagent model selection - -When the Host advertises the `subagent-model-selection` settings namespace, Models shows a localized switch above the provider rows. The switch defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples the value when it composes a new top-level Session, so changing it does not reconfigure running Sessions. Child Sessions inherit their parent's recorded decision. - ### Adding and deleting providers The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 3806244d4d..dcc19f80a1 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -35,10 +35,6 @@ kind: "package-reference" 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。 -### 子代理模型选择 - -当宿主提供 `subagent-model-selection` 设置 namespace 时,Models 会在提供方行上方显示一个本地化开关。该开关默认关闭,并通过 `settings.update` 携带 namespace revision、只写入 `{ enabled }`。宿主在组合新的顶层 Session 时读取此值,因此更改它不会重新配置正在运行的 Session。子 Session 会继承其父级已记录的决定。 - ### 新增与删除提供方 「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 1c767386dd..fe2fe87d3a 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -40,87 +40,6 @@ color: var(--dsw-alias-state-success-primary); } -.preferenceCard { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px 16px; - margin-top: 4px; - padding: 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; -} - -.preferenceCopy { - min-width: 0; -} - -.preferenceTitle { - margin: 0; - font-size: 14px; - line-height: 22px; - font-weight: 500; - color: var(--dsw-alias-label-primary); -} - -.preferenceDescription { - margin: 2px 0 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -.switch { - box-sizing: border-box; - position: relative; - width: 36px; - height: 20px; - padding: 2px; - border: 0; - border-radius: 10px; - background: var(--dsw-alias-border-l3); - cursor: pointer; -} - -.switchOn { - background: var(--dsw-alias-brand-primary); -} - -.switch:disabled { - cursor: default; - opacity: 0.5; -} - -.switch:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); -} - -.switchThumb { - display: block; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--dsw-alias-label-primary-foreground); - transition: transform 120ms ease; -} - -.switchOn .switchThumb { - transform: translateX(16px); -} - -.preferenceStatus, -.preferenceCard > .error { - grid-column: 1 / -1; -} - -.preferenceStatus { - margin: 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-state-success-primary); -} - .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index b12fb9fc01..7b884ac906 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -23,7 +23,6 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' -import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -309,24 +308,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema) - const subagentModelSelection = state.namespaces.get('subagent-model-selection') return (

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} - {subagentModelSelection === undefined - ? null - : ( - - )} {savedIdentity === undefined ? null : ( diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx deleted file mode 100644 index 8f69f41087..0000000000 --- a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** User control for model-selectable subagent delegation in new sessions. */ - -import { useState } from 'react' -import type { ReactNode } from 'react' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ModelsSettingsStore } from './store.ts' -import type { en } from './locales.ts' -import { messageOf } from './store.ts' -import styles from './ModelsSection.module.css' - -/** Props for the Host-owned subagent model-selection preference. */ -export interface SubagentModelSelectionCardProps { - /** Current redacted namespace view. */ - namespace: SettingsNamespaceView - /** Whether the settings provider accepts writes. */ - writable: boolean - /** Settings wire face. */ - api: SettingsWireFace - /** Models page controller to refresh after a commit. */ - controller: ModelsSettingsStore - /** Localized Models copy. */ - t: (key: keyof typeof en) => string -} - -/** Read the schema-validated resolved boolean from a namespace view. */ -function enabledOf(namespace: SettingsNamespaceView): boolean { - if (typeof namespace.value !== 'object' || namespace.value === null) return false - return (namespace.value as { enabled?: unknown }).enabled === true -} - -/** Render and persist the default-off new-session preference. */ -export function SubagentModelSelectionCard({ - namespace, - writable, - api, - controller, - t, -}: SubagentModelSelectionCardProps): ReactNode { - const [saving, setSaving] = useState(false) - const [saved, setSaved] = useState(false) - const [error, setError] = useState(undefined) - const enabled = enabledOf(namespace) - - const toggle = (): void => { - setSaving(true) - setSaved(false) - setError(undefined) - void api.settings.update( - namespace.ns, - { enabled: !enabled }, - namespace.revision, - ).then(async (response) => { - if (!response.ok) throw new Error(response.error.message) - controller.acceptNamespace(response.value) - await controller.load() - setSaved(true) - }).catch((reason: unknown) => { - setError(messageOf(reason)) - }).finally(() => { setSaving(false) }) - } - - return ( -
-
-

- {t('subagentModelSelectionTitle')} -

-

{t('subagentModelSelectionDescription')}

-
- - {saved - ?

{t('subagentModelSelectionSaved')}

- : null} - {error === undefined ? null :

{error}

} -
- ) -} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 176e33fe5e..f1b0718ba5 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -5,10 +5,6 @@ export const en = { nav: 'Models', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', - subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', - subagentModelSelectionToggle: 'Allow subagents to choose models', - subagentModelSelectionSaved: 'Saved. New sessions use this setting.', edit: 'Edit', editProvider: 'Edit {provider}', remove: 'Delete', @@ -113,10 +109,6 @@ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', - subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', - subagentModelSelectionToggle: '允许 subagent 自选模型', - subagentModelSelectionSaved: '已保存,新会话将使用此设置。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index d3b71ec6ea..6f664bd068 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -137,24 +137,15 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the page's wire faces (credentials Remote, llm reads, settings writes). + * @param api - the page's credentials Remote and LLM wire faces. * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( - private readonly api: ModelsWire, + private readonly api: Pick, private readonly schema: SettingsSchemaOperations, private readonly describeFace: SettingsDescribeFace, ) {} - /** - * Fold one successful settings write into the shared mirror before rejoining - * this page's rows. - * @param view - namespace view returned by the settings wire method. - */ - acceptNamespace(view: SettingsNamespaceView): void { - this.describeFace.acceptView(view) - } - /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 512a6573e5..da23418375 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -8,7 +8,6 @@ import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' -import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -328,72 +327,6 @@ describe('ModelsSection', () => { expect(screen.getByLabelText(en.keyInput)).toBeTruthy() expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false) }) - - it('persists the default-off subagent model-selection switch for new sessions', async () => { - const enabledNamespace: SettingsNamespaceView = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - user: { enabled: true }, - revision: 5, - } - const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace))) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - fireEvent.click(toggle) - - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(update).toHaveBeenCalledWith( - 'subagent-model-selection', - { enabled: true }, - 4, - ) - expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) - }) - - it('reports rejected subagent model-selection updates and permits a retry', async () => { - const update = vi.fn() - .mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected')) - .mockResolvedValueOnce(remoteOk({ - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - revision: 5, - })) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - fireEvent.click(toggle) - expect((await screen.findByRole('alert')).textContent).toBe('revision changed') - - fireEvent.click(toggle) - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(screen.queryByRole('alert')).toBeNull() - }) - - it('keeps malformed and read-only subagent preferences off', () => { - const namespace = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: null, - } as unknown as SettingsNamespaceView - const mutate = vi.fn() - render( - , - ) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect((toggle as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(toggle) - expect(mutate).not.toHaveBeenCalled() - }) - it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { await mountFirstRun() // Nothing is reachable yet, and DeepSeek has no configured credential and diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 150c835953..cd36155215 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md -README.md: 17ef6fda253ead749eba297519ad4b0647fc482e -README.zh.md: 9f48f673c6d145e0ff1e182d5a47a710a531ba72 +README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f +README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 17ef6fda25..8453bd4cb8 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). +Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`). ### What appears here @@ -33,7 +33,9 @@ The tab reads which settings namespaces the Host serves and dispatches one slot ### Editing and saving -A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted — the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. +A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. + +The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects. ### Secret-role fields @@ -55,7 +57,7 @@ The section declares `settings.plugins.tab`, a root list slot whose labels becom ### The write path -Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. +Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 9f48f673c6..6a9b716b84 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ### 这里会出现什么 @@ -33,7 +33,9 @@ kind: "package-reference" ### 编辑与保存 -卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算——卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 +卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。 + +Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。 ### secret 角色字段 @@ -55,7 +57,7 @@ kind: "package-reference" ### 写入路径 -保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 +保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 75b87d26fb..7edd06ff4e 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -47,6 +48,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -55,6 +57,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx index 6965939561..472a9f0e85 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx @@ -14,7 +14,7 @@ * disabled card the user cannot act on. */ -import { useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { CardShell } from './card-form.ts' @@ -46,7 +46,19 @@ export interface PluginCardProps { */ export function PluginCard(props: PluginCardProps) { const [open, setOpen] = useState(false) + const saveStarted = useRef(false) const { state } = props + // Collapse only after Host-confirmed settlement; a rejected write keeps its + // diagnostics and retained drafts visible for correction. + useEffect(() => { + if (state.saving) { + saveStarted.current = true + return + } + if (!saveStarted.current) return + saveStarted.current = false + if (!state.dirty && !state.failed) setOpen(false) + }, [state.dirty, state.failed, state.saving]) if (!state.available) return null const title = props.t(props.titleKey) const blocked = !state.dirty || state.invalid || state.saving diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css new file mode 100644 index 0000000000..3508ec8e48 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -0,0 +1,176 @@ +.permission { + display: grid; + gap: 6px; + padding: 12px 0; +} + +.toggleRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + font-size: 13px; + line-height: 1.5; + color: var(--dsw-alias-label-secondary); +} + +.toggleLabel { + flex: 1; + min-width: 0; +} + +.switch { + box-sizing: border-box; + position: relative; + flex: 0 0 auto; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switchOn { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.switch:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switchOn .thumb { + transform: translateX(16px); +} + +.selection { + display: grid; + gap: 10px; +} + +.hint, +.notice, +.invalid, +.conflict { + margin: 0; + font-size: 12px; + line-height: 1.5; +} + +.hint, +.notice { + color: var(--dsw-alias-label-tertiary); +} + +.invalid, +.conflict { + color: var(--dsw-alias-label-error); +} + +.catalogError { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: var(--dsw-alias-label-error); +} + +.catalogError button { + border: 0; + padding: 0; + background: transparent; + color: var(--dsw-alias-brand-primary); + cursor: pointer; +} + +.models { + display: grid; + gap: 6px; + min-width: 0; + max-height: 280px; + margin: 0; + padding: 10px; + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.models legend { + padding: 0 4px; + font-size: 12px; + color: var(--dsw-alias-label-secondary); +} + +.modelGroup { + display: grid; + gap: 6px; +} + +.modelGroup + .modelGroup { + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--dsw-alias-border-l3); +} + +.providerName { + padding: 0 6px; + font-size: 11px; + font-weight: 500; + color: var(--dsw-alias-label-tertiary); +} + +.model { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px; + border-radius: 6px; + cursor: pointer; +} + +.model:hover { + background: var(--dsw-alias-bg-layer-4); +} + +.modelName, +.route { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelName { + font-size: 13px; + color: var(--dsw-alias-label-primary); +} + +.route { + margin-top: 2px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} + +.unavailable { + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx new file mode 100644 index 0000000000..e5ba1ffc11 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -0,0 +1,143 @@ +/** User control for model-selectable subagent delegation in new sessions. */ + +import clsx from 'clsx' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { + SubagentModelCandidate, + SubagentModelSelectionCardFace, +} from './subagent-model-selection-card-controller.ts' +import type {} from './slot-contract.ts' +import { PluginCard } from './PluginCard.tsx' +import css from './SubagentModelSelectionCard.module.css' + +/** Props the renderer binds for the subagent model-selection card. */ +export type SubagentModelSelectionCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.plugins'> + & InjectFace + +/** + * Render the default-off preference and its exact adapter-route choices. + * @param props - locale copy, the card snapshot, and its toggle action. + * @returns the preference card, or nothing when the namespace is unavailable. + */ +export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { + const { t } = props + const state = props.useSubagentModelSelectionCard(snapshot => snapshot) + const availableGroups = new Map() + const unavailable: SubagentModelCandidate[] = [] + for (const candidate of state.candidates) { + if (!candidate.available) { + unavailable.push(candidate) + continue + } + const group = availableGroups.get(candidate.provider) + if (group === undefined) { + availableGroups.set(candidate.provider, { + providerName: candidate.providerName, + candidates: [candidate], + }) + } else { + group.candidates.push(candidate) + } + } + const renderCandidate = (candidate: SubagentModelCandidate) => ( + + ) + return ( + +
+
+ {t('subagentModelSelectionToggle')} + +
+

+ {t(state.enabled ? 'subagentModelSelectionChoose' : 'subagentModelSelectionOff')} +

+
+ {state.enabled + ? ( +
+ {state.catalogStatus === 'loading' + ?

{t('subagentModelSelectionLoading')}

+ : null} + {state.catalogStatus === 'error' + ? ( +
+ {t('subagentModelSelectionLoadFailed')} + +
+ ) + : null} + {state.catalogPartial + ?

{t('subagentModelSelectionPartial')}

+ : null} + {state.candidates.length > 0 + ? ( +
+ {t('subagentModelSelectionAllowed')} + {[...availableGroups].map(([provider, group]) => ( +
+
{group.providerName}
+ {group.candidates.map(renderCandidate)} +
+ ))} + {unavailable.length > 0 + ? ( +
+
{t('subagentModelSelectionUnavailableGroup')}
+ {unavailable.map(renderCandidate)} +
+ ) + : null} +
+ ) + : state.catalogStatus === 'ready' + ?

{t('subagentModelSelectionEmpty')}

+ : null} + {state.invalid ?

{t('subagentModelSelectionRequired')}

: null} +
+ ) + : null} + {state.conflicted + ?

{t('subagentModelSelectionConflict')}

+ : null} +
+ ) +} diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 11dc449b3e..8dd09f1d03 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -4,11 +4,12 @@ * * The section declares `settings.plugins.tab`; its own `configurable` tab then * declares `settings.plugin.item` and renders whatever cards were registered - * into it. The three cards this package ships are the host-plane sections the + * into it. The cards this package ships are the host-plane sections the * deployment already exposes; each binds its namespace through the client * settings scope, which keeps them unaware of one another and of other tabs. */ +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings shell's SlotMap merge (the 'settings.section' entry) @@ -25,10 +26,14 @@ import { BashCard } from './BashCard.tsx' import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' import { SHELL_NS, BashCardController } from './bash-card-controller.ts' import { ConfigurablePluginsTabController } from './tab-store.ts' +import { + SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, +} from './subagent-model-selection-card-controller.ts' import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' import { en, zh } from './locales.ts' @@ -49,13 +54,14 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope'] /** * Mount the plugin configuration section and the cards this package ships. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { + const { api } = ctx.get('connection') as ConnectionHandle const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries') @@ -63,6 +69,10 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController( ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) + const subagentModelSelection = new SubagentModelSelectionCardController( + ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + api, + ) // The credential a card reports is not part of any settings section, so its // scope publishes nothing when one is written. This is the only signal that @@ -71,6 +81,19 @@ export function apply(ctx: ClientContext): void { () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), 'ui-settings-plugins: credential invalidations', ) + ctx.effect( + () => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent adapter invalidations', + ) + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent settings invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }), + 'ui-settings-plugins: subagent connection generation', + ) + ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') // The shared SettingsScope mirror updates after document commits and reconnects. const configurable = new ConfigurablePluginsTabController( @@ -130,7 +153,7 @@ export function apply(ctx: ClientContext): void { }, PluginsSettingsSection)) // The existing configuration page is one ordinary tab. It keeps ownership - // of the card slot and the three shipped card contributions below. + // of the card slot and the shipped card contributions below. ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'configurable', @@ -154,6 +177,12 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => agentLoop.inject(), }, AgentLoopCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + key: SUBAGENT_MODEL_SELECTION_NS, + locale: NS, + inject: () => subagentModelSelection.inject(), + }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: WEB_SEARCH_NS, diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 1478e39997..b68ab80beb 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -11,6 +11,12 @@ export type PluginsSettingsLocaleKey = | 'webSearchTitle' | 'webSearchDescription' | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' + | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' + | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' + | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' /** English copy. */ export const en: Record = { @@ -51,6 +57,21 @@ export const en: Record = { webSearchBaseUrlHint: 'Leave blank to use the provider default.', webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: 'Control which models agents may choose for subagents.', + subagentModelSelectionToggle: 'Allow agents to choose models for subagents', + subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.', + subagentModelSelectionAllowed: 'Models agents may choose', + subagentModelSelectionLoading: 'Loading models…', + subagentModelSelectionLoadFailed: 'Models could not be loaded.', + subagentModelSelectionRetry: 'Retry', + subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', + subagentModelSelectionUnavailable: 'Currently unavailable', + subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', + subagentModelSelectionEmpty: 'No model provider currently advertises a model.', + subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', + subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } /** Simplified Chinese copy. */ @@ -92,4 +113,19 @@ export const zh: Record = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。', + subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', + subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', + subagentModelSelectionAllowed: 'Agent 可选择的模型', + subagentModelSelectionLoading: '正在加载模型…', + subagentModelSelectionLoadFailed: '无法加载模型。', + subagentModelSelectionRetry: '重试', + subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', + subagentModelSelectionUnavailable: '当前不可用', + subagentModelSelectionUnavailableGroup: '已保存但当前不可用', + subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', + subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', + subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts new file mode 100644 index 0000000000..053074a1ff --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -0,0 +1,360 @@ +/** Staged editor for the Host-owned subagent model allowlist. */ + +import type { + IApiClient, + ModelProviderGroup, +} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { CardShell } from './card-form.ts' + +/** Namespace of the Host-owned subagent model-selection preference. */ +export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' + +/** One exact provider/model route stored as user authorization. */ +export interface AllowedSubagentModel { + provider: string + model: string +} + +/** Settings fields stored for subagent model selection. */ +export interface SubagentModelSelectionSettings { + /** Whether model-facing child route selection applies to new Sessions. */ + enabled: boolean + /** Exact child routes offered to newly composed top-level Sessions. */ + allowedModels: AllowedSubagentModel[] +} + +/** One catalog row joined with a stored route that may no longer be advertised. */ +export interface SubagentModelCandidate extends AllowedSubagentModel { + /** Stable opaque identity used only for lookup. */ + key: string + /** Adapter-owned provider display name. */ + providerName: string + /** Adapter-owned model display name. */ + modelName: string + /** Whether the current adapter catalog advertises this exact route. */ + available: boolean + /** Whether the current draft authorizes this route. */ + selected: boolean +} + +/** State rendered by the staged allowlist card. */ +export interface SubagentModelSelectionCardState extends CardShell { + /** Whether the draft enables model-facing child route selection. */ + enabled: boolean + /** Live catalog joined with stored routes. */ + candidates: readonly SubagentModelCandidate[] + /** Adapter-directory request state. */ + catalogStatus: 'idle' | 'loading' | 'ready' | 'error' + /** Whether any provider-local catalog request failed. */ + catalogPartial: boolean + /** Whether a newer Host revision invalidated the current draft. */ + conflicted: boolean +} + +/** Registration-side face for the subagent model-selection card. */ +export interface SubagentModelSelectionCardFace { + hooks: { + /** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */ + subagentModelSelectionCard: SnapshotStore + } + /** Stage the enabled state; enabling also loads the adapter directory. */ + toggleEnabled: () => void + /** Stage one exact route as allowed or denied. */ + toggleModel: (key: string) => void + /** Retry the adapter directory. */ + retryCatalog: () => void + /** Persist the switch and exact routes as one revision-fenced mutation. */ + save: () => void + /** Drop the staged enabled state and route choices. */ + discard: () => void +} + +/** + * Stable identity for one exact route; callers resolve it by lookup and never parse it. + * @param route - Provider/model route to identify. + * @returns Opaque key for lookup within the card. + */ +export function subagentModelKey(route: AllowedSubagentModel): string { + return `${route.provider}\0${route.model}` +} + +/** + * Join live adapter metadata with stored routes that remain removable after disappearance. + * @param groups - Current model directory grouped by provider. + * @param stored - Routes in the effective settings value. + * @param selected - Opaque route keys selected in the current draft. + * @returns Candidate rows for the card. + */ +export function subagentModelCandidates( + groups: readonly ModelProviderGroup[], + stored: readonly AllowedSubagentModel[], + selected: ReadonlySet, +): SubagentModelCandidate[] { + const storedByKey = new Map(stored.map(route => [subagentModelKey(route), route])) + const candidates = groups.flatMap(group => group.models.map((model): SubagentModelCandidate => { + const route = { provider: group.id, model: model.id } + const key = subagentModelKey(route) + storedByKey.delete(key) + return { + ...route, + key, + providerName: group.name, + modelName: model.name, + available: true, + selected: selected.has(key), + } + })) + for (const route of storedByKey.values()) { + const key = subagentModelKey(route) + candidates.push({ + ...route, + key, + providerName: route.provider, + modelName: route.model, + available: false, + selected: selected.has(key), + }) + } + return candidates +} + +function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly AllowedSubagentModel[]): boolean { + if (left.length !== right.length) return false + const rightKeys = new Set(right.map(subagentModelKey)) + return left.every(route => rightKeys.has(subagentModelKey(route))) +} + +/** Bridges one settings scope and the live adapter directory onto a staged card. */ +export class SubagentModelSelectionCardController { + private catalogGroups: readonly ModelProviderGroup[] = [] + private catalogPartial = false + private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' + private draftEnabled: boolean | undefined + private draftRoutes: Map | undefined + private draftRevision: number | undefined + private saving = false + private failed = false + private conflicted = false + private disposed = false + private saveGeneration = 0 + private catalogGeneration = 0 + private readonly store: SnapshotStore + private readonly unsubscribe: () => void + + /** + * @param scope - bound `subagent-model-selection` settings scope. + * @param api - Host LLM directory face. + */ + constructor( + private readonly scope: SettingsScope, + private readonly api: Pick, + ) { + this.store = createSnapshotStore(this.projection()) + this.unsubscribe = scope.subscribe(() => { + if (!this.saving && this.draftRoutes !== undefined + && this.scope.getSnapshot().revision !== this.draftRevision) { + if (this.currentEnabled() === this.enabled() + && sameRoutes(this.currentRoutes(), this.desiredRoutes())) this.clearDraft() + else this.conflicted = true + } + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + }) + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() + } + + /** Stop observing settings and suppress late directory/write settlements. */ + dispose(): void { + this.disposed = true + this.saveGeneration += 1 + this.catalogGeneration += 1 + this.unsubscribe() + } + + /** + * Build the renderer face for this card. + * @returns The snapshot and staged card actions injected into the renderer. + */ + inject(): SubagentModelSelectionCardFace { + return { + hooks: { subagentModelSelectionCard: this.store }, + toggleEnabled: () => { this.toggleEnabled() }, + toggleModel: (key) => { this.toggleModel(key) }, + retryCatalog: () => { void this.loadCatalog() }, + save: () => { void this.save() }, + discard: () => { this.discard() }, + } + } + + private currentRoutes(): AllowedSubagentModel[] { + return this.scope.getSnapshot().value?.allowedModels.map(route => ({ ...route })) ?? [] + } + + private currentEnabled(): boolean { + return this.scope.getSnapshot().value?.enabled ?? false + } + + private selected(): Set { + return new Set(this.draftRoutes?.keys() ?? this.currentRoutes().map(subagentModelKey)) + } + + private enabled(): boolean { + return this.draftEnabled ?? this.currentEnabled() + } + + private beginDraft(): Map { + if (this.draftRoutes === undefined) { + const snapshot = this.scope.getSnapshot() + this.draftEnabled = snapshot.value?.enabled ?? false + this.draftRoutes = new Map( + snapshot.value?.allowedModels.map(route => [subagentModelKey(route), { ...route }]) ?? [], + ) + this.draftRevision = snapshot.revision + } + return this.draftRoutes + } + + private toggleEnabled(): void { + const snapshot = this.scope.getSnapshot() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return + this.beginDraft() + this.draftEnabled = !this.draftEnabled + this.failed = false + if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + } + + private toggleModel(key: string): void { + if (!this.enabled() || this.saving || !this.scope.getSnapshot().writable) return + const candidate = this.candidates().find(candidate => candidate.key === key) + if (candidate === undefined) return + const routes = this.beginDraft() + if (routes.has(key)) routes.delete(key) + else routes.set(key, { provider: candidate.provider, model: candidate.model }) + this.failed = false + this.publish() + } + + private clearDraft(): void { + this.draftEnabled = undefined + this.draftRoutes = undefined + this.draftRevision = undefined + this.failed = false + this.conflicted = false + } + + private discard(): void { + if (this.saving) return + this.clearDraft() + this.publish() + } + + private candidates(): SubagentModelCandidate[] { + const retained = new Map(this.currentRoutes().map(route => [subagentModelKey(route), route])) + for (const [key, route] of this.draftRoutes ?? []) retained.set(key, route) + return subagentModelCandidates(this.catalogGroups, [...retained.values()], this.selected()) + } + + private desiredRoutes(): AllowedSubagentModel[] { + return [...this.draftRoutes?.values() ?? this.currentRoutes()].map(route => ({ ...route })) + } + + private async save(): Promise { + const snapshot = this.scope.getSnapshot() + const desiredEnabled = this.enabled() + const desired = this.desiredRoutes() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving + || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) + || (desiredEnabled && desired.length === 0)) return + if (this.draftRoutes !== undefined && snapshot.revision !== this.draftRevision) { + this.conflicted = true + this.publish() + return + } + const generation = this.saveGeneration + this.saving = true + this.failed = false + this.conflicted = false + this.publish() + await this.scope.mutate([ + { op: 'set', path: ['enabled'], value: desiredEnabled }, + { + op: 'set', + path: ['allowedModels'], + value: desired.map(route => ({ provider: route.provider, model: route.model })), + }, + ], this.draftRevision) + if (generation !== this.saveGeneration) return + const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) + this.saving = false + this.failed = !landed + if (landed) this.clearDraft() + this.publish() + } + + /** Invalidate and reload model candidates after a Host model input changes. */ + refreshCatalog(): void { + if (this.disposed) return + this.catalogGeneration += 1 + this.catalogStatus = 'idle' + this.catalogPartial = false + if (this.enabled()) void this.loadCatalog() + else this.publish() + } + + /** Drop Host-specific candidates and drafts, then reload after reconnecting. */ + resetConnection(): void { + if (this.disposed) return + this.saveGeneration += 1 + this.saving = false + this.clearDraft() + this.catalogGroups = [] + this.refreshCatalog() + } + + private async loadCatalog(): Promise { + if (this.disposed || this.catalogStatus === 'loading') return + const generation = this.catalogGeneration + this.catalogStatus = 'loading' + this.catalogPartial = false + this.publish() + try { + const response = await this.api.llm.models({}) + if (generation !== this.catalogGeneration) return + if (!response.result.ok) throw new Error(response.result.error.message) + this.catalogGroups = response.result.value.groups + this.catalogPartial = response.result.value.failures.length > 0 + this.catalogStatus = 'ready' + } catch { + if (generation !== this.catalogGeneration) return + this.catalogStatus = 'error' + } + this.publish() + } + + private projection(): SubagentModelSelectionCardState { + const snapshot = this.scope.getSnapshot() + const current = this.currentRoutes() + const desired = this.desiredRoutes() + const enabled = this.enabled() + return { + available: snapshot.status === 'ready', + writable: snapshot.writable, + dirty: this.currentEnabled() !== enabled || !sameRoutes(current, desired), + invalid: enabled && desired.length === 0, + saving: this.saving, + failed: this.failed, + enabled, + candidates: this.candidates(), + catalogStatus: this.catalogStatus, + catalogPartial: this.catalogPartial, + conflicted: this.conflicted, + } + } + + private publish(): void { + this.store.set(this.projection()) + } +} diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index bba43edcbc..6d118856ef 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -11,6 +11,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/clien import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -27,6 +28,9 @@ async function bench(served?: string[]) { locale.setLocale('zh') ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) + const models = vi.fn(() => Promise.resolve({ + rpcId: 'm', result: { ok: true, value: { groups: [], failures: [] } }, + })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } : { @@ -43,9 +47,14 @@ async function bench(served?: string[]) { credentials: { describe: describeCredentials, set: vi.fn() }, settings: { describe: describeSettings }, }) - ctx.provide('connection', { isLoopback: true, api: {} } as never) + ctx.provide('connection', { + isLoopback: true, + api: { llm: { models } }, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } + return { + ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, + } } function declareRoot(slots: SlotRegistry): () => void { @@ -57,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']) }) it('registers one Plugins section and declares the tab and card slots', async () => { @@ -117,7 +126,7 @@ describe('ui-settings-plugins apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) - .toEqual(['shell', 'agent-loop', 'web-search-deepseek']) + .toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek']) }) it('dispatches the served namespaces its cards claim, and no others', async () => { @@ -176,6 +185,23 @@ describe('ui-settings-plugins apply', () => { await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) + it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { + const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') + const { ctx, slots, remote } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + refresh.mockClear() + reset.mockClear() + + remote.emit('llm/adapters-updated', []) + expect(refresh).toHaveBeenCalledTimes(1) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(refresh).toHaveBeenCalledTimes(2) + ctx.emit('connection/reset') + expect(reset).toHaveBeenCalledTimes(1) + }) + it('ignores a credential change for a reference no card watches', async () => { const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) @@ -203,7 +229,7 @@ describe('ui-settings-plugins apply', () => { declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(slots.entries('settings.plugin.item')).toHaveLength(3) + expect(slots.entries('settings.plugin.item')).toHaveLength(4) await fiber.dispose() diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index ad666e9093..e84a00cf49 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -12,6 +12,8 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx' import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' +import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx' import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' @@ -19,6 +21,7 @@ import type { BashCardState } from '../src/client/bash-card-controller.ts' import type { CardFieldState, CardShell } from '../src/client/card-form.ts' import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts' import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -66,7 +69,7 @@ function renderConfigurable(namespaces: string[], cards: Record render() } -function renderBash(state: Partial = {}) { +function renderBashCard(state: Partial = {}) { const store = createSnapshotStore({ ...settled, timeoutMs: field('60000'), @@ -76,6 +79,36 @@ function renderBash(state: Partial = {}) { const actions = cardActions() const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps render() + return { actions, store } +} + +function renderBash(state: Partial = {}) { + return renderBashCard(state).actions +} + +function renderSubagentModelSelection(state: Partial = {}) { + const store = createSnapshotStore({ + ...settled, + enabled: false, + candidates: [], + catalogStatus: 'idle', + catalogPartial: false, + conflicted: false, + ...state, + }) + const actions = { + toggleEnabled: vi.fn(), + toggleModel: vi.fn(), + retryCatalog: vi.fn(), + save: vi.fn(), + discard: vi.fn(), + } + const props = { + ...actions, + t, + useSubagentModelSelectionCard: bindSnapshotSelector(store), + } as unknown as SubagentModelSelectionCardProps + render() return actions } @@ -292,6 +325,137 @@ describe('BashCard', () => { expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() }) + + it('collapses after a successful save settles', () => { + const { actions, store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + expect(actions.save).toHaveBeenCalledOnce() + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) }) + + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + }) + + it('keeps a failed save open', () => { + const { store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) }) + + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() + expect(screen.getByText(en.saveFailed)).toBeTruthy() + }) +}) + +describe('SubagentModelSelectionCard', () => { + it('renders the default-off preference in its staged plugin card', () => { + const actions = renderSubagentModelSelection() + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(control.getAttribute('aria-checked')).toBe('false') + fireEvent.click(control) + + expect(actions.toggleEnabled).toHaveBeenCalledOnce() + }) + + it('groups available adapter candidates by provider', () => { + const actions = renderSubagentModelSelection({ + enabled: true, + candidates: [ + { + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }, + { + key: 'alpha\0deep', + provider: 'alpha', + model: 'deep', + providerName: 'Alpha API', + modelName: 'Deep', + available: true, + selected: false, + }, + ], + catalogStatus: 'ready', + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') + expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() + fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') + }) + + it('renders directory progress, failures, unavailable routes, and validation', () => { + renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() + + cleanup() + const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) + expect(errorActions.retryCatalog).toHaveBeenCalledOnce() + + cleanup() + renderSubagentModelSelection({ + enabled: true, + catalogStatus: 'ready', + catalogPartial: true, + candidates: [{ + key: 'legacy\0old', + provider: 'legacy', + model: 'old', + providerName: 'legacy', + modelName: 'old', + available: false, + selected: true, + }], + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() + + cleanup() + renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() + }) + + it('distinguishes a stale draft from a rejected save', () => { + renderSubagentModelSelection({ dirty: true, conflicted: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() + expect(screen.queryByText(en.saveFailed)).toBeNull() + }) + + it('stays hidden when unavailable and disables writes when read-only', () => { + renderSubagentModelSelection({ available: false }) + expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() + + cleanup() + const actions = renderSubagentModelSelection({ writable: false }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + const control = screen.getByRole('switch') as HTMLButtonElement + expect(control.disabled).toBe(true) + fireEvent.click(control) + expect(actions.toggleEnabled).not.toHaveBeenCalled() + }) }) describe('AgentLoopCard', () => { diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 505079be0c..28c26e73cd 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' @@ -12,6 +13,11 @@ import { SettingsDescribeMirror, type SettingsMirrorSnapshot, } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' +import { + SubagentModelSelectionCardController, + subagentModelCandidates, + type SubagentModelSelectionSettings, +} from '../src/client/subagent-model-selection-card-controller.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -21,6 +27,18 @@ function acceptWrites(host: StubSettingsScope): void { host.set.mockImplementation((field: string, value: unknown) => { host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) }) + host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => { + const value = { ...section() } + const user = { ...layer() } + for (const op of ops) { + const field = op.path[0]! + if (op.op === 'set') { + value[field] = op.value + user[field] = op.value + } + } + host.publish({ value: value as T, user }) + }) host.unset.mockImplementation((field: string) => { const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) const base = host.scope.getSnapshot().base as Record | undefined @@ -37,6 +55,34 @@ function credentialsApi(configured: boolean) { return { api: { describe, set } as never, describe, set } } +function modelsApi(options: { + groups?: readonly { + id: string + name: string + models: readonly { id: string; name: string }[] + }[] + failures?: readonly { id: string; name: string; message: string }[] + error?: string +} = {}) { + const models = vi.fn(() => Promise.resolve({ + rpcId: 'm-1' as never, + result: options.error === undefined + ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } } + : { ok: false as const, error: { code: 'internal_error' as never, message: options.error } }, + })) + return { api: { llm: { models } } as never, models } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, resolve, reject } +} + describe('CardForm', () => { function form() { const host = stubSettingsScope>() @@ -383,6 +429,431 @@ describe('AgentLoopCardController', () => { }) }) +describe('SubagentModelSelectionCardController', () => { + it('joins stored routes with the live catalog without dropping unavailable choices', () => { + const candidates = subagentModelCandidates( + [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + [{ provider: 'legacy', model: 'old' }], + new Set(['legacy\0old']), + ) + + expect(candidates).toEqual([ + { + key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API', + modelName: 'Fast', available: true, selected: false, + }, + { + key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy', + modelName: 'old', available: false, selected: true, + }, + ]) + }) + + it('loads adapter models and saves the switch and routes atomically', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 3, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 3) + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, + dirty: false, + saving: false, + failed: false, + }) + }) + + it('starts an empty draft when a ready test scope has no decoded value', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: true, revision: 0, value: undefined }) + const face = controller.inject() + + face.toggleEnabled() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, dirty: true, invalid: true, + }) + }) + + it('keeps the Host value and reports a rejected write', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + const face = controller.inject() + + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true) + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, + dirty: true, + saving: false, + }) + }) + + it('loads stored routes, stages removal and disablement, and discards both', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().catalogPartial).toBe(true) + + face.toggleModel('missing') + expect(state().dirty).toBe(false) + face.toggleModel('alpha\0fast') + expect(state()).toMatchObject({ dirty: true, invalid: true }) + face.discard() + expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true }) + + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: true, enabled: false }) + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: false, enabled: true }) + }) + + it('retains selected routes when disabling and loads an already-ready enabled card', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() }) + + face.toggleEnabled() + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: false }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 5) + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, dirty: false, + }) + }) + + it('reports a directory error and retries it', async () => { + const host = stubSettingsScope() + const models = modelsApi({ error: 'offline' }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + + face.toggleEnabled() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') }) + face.retryCatalog() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) }) + }) + + it('rejects a draft after the Host revision changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: true, failed: false, dirty: true, + }) + face.save() + await Promise.resolve() + + expect(host.mutate).not.toHaveBeenCalled() + face.discard() + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, failed: false, dirty: false, enabled: true, + }) + }) + + it('settles a draft when a newer Host revision already contains it', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + }) + + it('retains unsaved routes across a catalog refresh', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, revision: 2, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const refreshed = deferred() + const models = vi.fn() + .mockResolvedValueOnce({ + rpcId: 'catalog-1', + result: { ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + } }, + }) + .mockImplementationOnce(() => refreshed.promise) + const controller = new SubagentModelSelectionCardController( + host.scope, { llm: { models } } as never, + ) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + face.toggleEnabled() + await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.refreshCatalog() + expect(state()).toMatchObject({ + catalogStatus: 'loading', + candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })], + }) + refreshed.resolve({ + rpcId: 'catalog-2', + result: { ok: true, value: { groups: [], failures: [] } }, + } as never) + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().candidates).toEqual([ + expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }), + ]) + + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 2) + }) + }) + + it('drops a draft when the connection generation changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.resetConnection() + host.publish({ + revision: 4, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + face.save() + await Promise.resolve() + expect(host.mutate).not.toHaveBeenCalled() + }) + + it('reloads the model catalog after invalidation', async () => { + const host = stubSettingsScope() + host.publish({ + status: 'ready', writable: true, revision: 1, + value: { enabled: true, allowedModels: [] }, user: {}, + }) + const models = vi.fn() + .mockResolvedValueOnce({ + rpcId: 'catalog-1', + result: { ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + } }, + }) + .mockResolvedValueOnce({ + rpcId: 'catalog-2', + result: { ok: true, value: { + groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }], + failures: [], + } }, + }) + const controller = new SubagentModelSelectionCardController( + host.scope, { llm: { models } } as never, + ) + const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) + + controller.refreshCatalog() + + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') }) + expect(models).toHaveBeenCalledTimes(2) + }) + + it('suppresses duplicate actions and late save settlements', async () => { + const host = stubSettingsScope() + const catalog = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const write = deferred() + const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => { + await write.promise + const enabled = ops.find(op => op.path[0] === 'enabled') + const allowedModels = ops.find(op => op.path[0] === 'allowedModels') + host.publish({ value: { + enabled: enabled?.op === 'set' ? enabled.value as boolean : false, + allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [], + } }) + }) + const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api) + const face = controller.inject() + + face.save() + face.toggleModel('alpha\0fast') + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + face.save() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') }) + face.save() + face.toggleModel('alpha\0fast') + face.save() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true) + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + face.discard() + controller.dispose() + write.resolve(undefined) + await write.promise + expect(mutate).toHaveBeenCalledOnce() + }) + + it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + + const pending = deferred() + const models = vi.fn(() => pending.promise) + const controller = new SubagentModelSelectionCardController(host.scope, { llm: { models } } as never) + const face = controller.inject() + face.toggleEnabled() + face.retryCatalog() + expect(models).toHaveBeenCalledOnce() + controller.dispose() + pending.reject(new Error('late failure')) + await pending.promise.catch(() => undefined) + + const pendingResolve = deferred() + const resolving = new SubagentModelSelectionCardController( + host.scope, + { llm: { models: () => pendingResolve.promise } } as never, + ) + const resolvingFace = resolving.inject() + resolvingFace.toggleEnabled() + resolving.dispose() + pendingResolve.resolve({ + rpcId: 'late' as never, + result: { ok: true, value: { groups: [], failures: [] } }, + } as never) + await pendingResolve.promise + }) + + it('ignores writes while read-only and scope notifications after disposal', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} }) + const face = controller.inject() + + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + expect(host.mutate).not.toHaveBeenCalled() + + controller.dispose() + controller.refreshCatalog() + controller.resetConnection() + face.toggleEnabled() + face.retryCatalog() + face.save() + host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) + expect(host.mutate).not.toHaveBeenCalled() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + }) +}) + describe('WebSearchCardController', () => { it('reads the credential state for the reference the tab names', async () => { const host = stubSettingsScope() diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 6184db0dff..9a230d0026 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: a9a45d90eaa46502829ee6d2c1793b7dadb1f0a5 -README.zh.md: bd615cbb5a3236370b7bbc9fc735deeab6efd8ce +README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5 +README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index a9a45d90ea..3e4970bff9 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: one field path fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. +A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. ### Filling the settings slots @@ -55,7 +55,7 @@ The plugin injects `connection` and `remote` and owns the one `settings.describe ### Scope derivation -`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope with the namespace revision as `expectedRevision`; a committed write folds its answer in, a rejected or failed latest write triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. +`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. ### Schema service @@ -95,7 +95,6 @@ None; this package neither assembles nor sends a provider request. These limits define where the settings transport cannot reach; they are current package constraints. - **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API. -- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions. ### Dev Note diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index bd615cbb5a..a527dfe21a 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:单一字段路径以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 +功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 ### 填充设置 slot @@ -55,7 +55,7 @@ kind: "package-reference" ### Scope 派生 -`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入把应答折回镜像,被拒绝或失败的最新写入触发一次恢复读取,被取代的写入把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 +`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 ### Schema 服务 @@ -95,7 +95,6 @@ kind: "package-reference" 这些限制说明设置传输层够不到的地方;它们是当前包约束。 - **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。 -- **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。 ### 开发备注 diff --git a/packages/client/ui-settings/src/client/settings-contract.ts b/packages/client/ui-settings/src/client/settings-contract.ts index 38f95177a7..dd06600781 100644 --- a/packages/client/ui-settings/src/client/settings-contract.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -2,6 +2,8 @@ * Settings-namespace scope contracts owned beside the settings transport. */ +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' + /** Client-side sync state of one settings namespace. */ export interface SettingsScopeSnapshot { /** @@ -46,7 +48,8 @@ export interface SettingsScopeSpec { /** * Reactive owner handle over one namespace's durable section — the browser * mirror of the Host-side `SettingsScope` owner seam. Domain services read - * and observe the snapshot and route explicit user choices through `set`. + * and observe the snapshot and route explicit user choices through its + * mutation methods. */ export interface SettingsScope { /** @returns the current sync snapshot (stable reference until the next change). */ @@ -57,6 +60,16 @@ export interface SettingsScope { * @returns the disposer removing this listener. */ subscribe(listener: () => void): () => void + /** + * Queue one atomic namespace mutation. All operations share one revision + * fence, Host validation, persistence decision, and recovery read. Supplying + * `expectedRevision` preserves an earlier read as the fence instead of using + * the latest queued or mirrored revision. + * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise /** * Queue one field write. Rapid writes preserve mutation order, each carries * the latest known namespace revision, and only the latest settlement may diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index d4ddd632e5..2d728e5154 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -104,7 +104,7 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise { - return this.write({ op: 'set', path: [field], value: value as JsonValue }) + return this.mutate([{ op: 'set', path: [field], value: value as JsonValue }]) } /** @@ -114,16 +114,23 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the clear and any latest-write recovery read. */ unset(field: string): Promise { - return this.write({ op: 'unset', path: [field] }) + return this.mutate([{ op: 'unset', path: [field] }]) } - private write(op: SettingsPathOpView): Promise { + /** + * Queue one atomic namespace mutation; see {@link SettingsScope.mutate}. + * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise { + const ownedOps = structuredClone(ops) as SettingsPathOpView[] const generation = ++this.writeGeneration return this.enqueue(async () => { - const revision = this.pendingRevision ?? this.getSnapshot().revision + const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { - response = await this.api.settings.mutate(this.spec.namespace, [op], revision) + response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) } catch (_settingsWriteFailure) { await this.recover(generation) return diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index b6788f0dba..25091897f0 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -1,7 +1,9 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { + JsonValue, SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' @@ -174,6 +176,56 @@ describe('SettingsScopeController', () => { ) }) + it('sends one copied multi-field mutation behind one revision fence', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 7)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 8))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + const ops: SettingsPathOpView[] = [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ] + + const write = scope.mutate(ops) + ops[0] = { op: 'unset', path: ['enabled'] } + ;(ops[1] as unknown as { value: Array<{ model: string }> }).value[0]!.model = 'changed' + await write + + expect(mutate).toHaveBeenCalledWith( + 'ui-test', + [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], + 7, + ) + }) + + it('preserves an editor-owned revision fence behind earlier queued writes', async () => { + const first = deferred>() + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 7)) + .mockResolvedValueOnce(described({ preference: 'dark' }, 8)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(rejected()) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + + const earlier = scope.set('preference', 'dark') + const fenced = scope.mutate([{ op: 'set', path: ['preference'], value: 'light' }], 7) + first.resolve(ok(view({ preference: 'dark' }, 8))) + await Promise.all([earlier, fenced]) + + expect(mutate).toHaveBeenNthCalledWith( + 2, + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'light' }], + 7, + ) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 }) + }) + it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ede6905289..11f131475f 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md -README.md: 4a5c240cfcb6bad1b4b1c8917dcdbc54fd0ce7b9 -README.zh.md: b243b18cb969cdc1c505884ba8bbfbbd7bc3be41 +README.md: 792a70b7b3cd2d7a48187e872104bffac1d54202 +README.zh.md: 0feeba622cd4bcee1d5affff252e7596b7190338 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 4a5c240cfc..792a70b7b3 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo ### Built-in views -This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. Unsupported or malformed inputs fall back to flattened Tool result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Details and cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes. +The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index b243b18cb9..0feeba622c 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 内置视图 -本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。不受支持或格式错误的输入回退为压平的工具结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 详情与卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 与 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 笔记负责。 +本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。 diff --git a/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css new file mode 100644 index 0000000000..1c84154c42 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css @@ -0,0 +1,67 @@ +.card { + display: flex; + flex-direction: column; + gap: 16px; + max-height: 360px; + margin: 4px 0 4px 4px; + padding: 16px 20px; + overflow-y: auto; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-bg-base); +} + +.item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.question, +.answer { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.question { + color: var(--dsw-alias-label-tertiary); +} + +.answer { + color: var(--dsw-alias-label-primary); +} + +.answerLine { + display: block; +} + +.skipped { + color: var(--dsw-alias-label-tertiary); +} + +.verdict { + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} + +.questionList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding-left: 20px; +} + +.unansweredQuestion { + color: var(--dsw-alias-label-tertiary); + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--dsh-content-font-size, 14px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); +} diff --git a/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx new file mode 100644 index 0000000000..d704aea501 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx @@ -0,0 +1,40 @@ +/** Ask-user transcript rendering from validated plain card data. @module */ + +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' +import css from './AskQuestionCard.module.css' + +/** + * Render a validated ask-user transcript from plain card data. + * @param props - Localized transcript card data. + * @returns the readable answered or unanswered question list. + */ +export function AskQuestionCard({ card }: { card: AskQuestionCardModel }) { + if (card.kind === 'unanswered') { + return ( +
+

{card.verdict}

+
    + {card.questions.map(question => ( +
  • {question.question}
  • + ))} +
+
+ ) + } + return ( +
+ {card.questions.map(question => ( +
+
{question.question}
+
+ {question.answers.length === 0 + ? {card.skippedLabel} + : question.answers.map((answer, index) => ( + {answer} + ))} +
+
+ ))} +
+ ) +} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 8102965a6d..ccf211e468 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -13,8 +13,10 @@ import { import { diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from '../models/primitive-labels.ts' +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' import type { WebCardModelProps } from '../models/web-card-model.ts' +import { AskQuestionCard } from './AskQuestionCard.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -37,6 +39,8 @@ export interface ToolRowProps { body: string | null /** Flattened result text for the expanded Output section; null/absent = no output section. */ output?: string | null | undefined + /** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */ + askQuestion?: AskQuestionCardModel | null | undefined /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ errorSummary?: string | null | undefined /** Terminal card; card fields are mutually exclusive and replace text sections. */ @@ -91,6 +95,7 @@ export function ToolRow({ summarySuffix, body, output, + askQuestion, errorSummary, terminal, diff, @@ -115,8 +120,9 @@ export function ToolRow({ const readBody = read ?? null const searchBody = search ?? null const webBody = web ?? null + const askQuestionBody = askQuestion ?? null const outputText = output ?? null - const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -183,67 +189,69 @@ export function ToolRow({ )} >
- {terminalBody !== null - ? ( - - ) - : diffBody !== null - ? - : readBody !== null - ? - : searchBody !== null - ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
{searchBody.recovery}
- )} - - ) - : webBody !== null - ? - : ( + {askQuestionBody !== null + ? + : terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : readBody !== null + ? + : searchBody !== null + ? ( <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
+ + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
)} - )} + ) + : webBody !== null + ? + : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && (