diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index df81608c10..19e4ac9d22 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: dedfe98ca2b3e64a56518dfa6157244e4d4c16df -2026-07-30-client-locale-full-rollout.zh.md: e9bd1ed19e8b485d812140ab044c779a2ce6e9d3 +2026-07-30-client-locale-full-rollout.md: 2d7c919d420f5681007843d5b8aae5c9c53cc275 +2026-07-30-client-locale-full-rollout.zh.md: 8546d06a365cabad50cd26c0f50e45e762671588 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index dedfe98ca2..2d7c919d42 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -16,7 +16,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **The built-in locale set is closed; the language catalog is extensible.** The package contributes only `zh` and `en`, and typed namespace registration continues to require that bilingual pair. An external client plugin adds a language through `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` and contributes partial translations through the existing single-locale dictionary registration; language definitions and dictionaries may register in either order. An external language id is its validated BCP 47 tag for preference storage, dictionary lookup, browser matching, and ``; `LocaleId` remains a string because the tag carries interoperable language semantics rather than opaque identity. The built-in `zh` definition retains its internal `zh-CN` document tag. Every added language names a registered fallback whose own definition supplies the next fallback, and the chain must terminate at `en`; unknown targets and cycles fail at registration. For each key, lookup walks that chain in the requested namespace, then repeats it in `common`, before displaying the key itself. The Host stores an open string preference; an unavailable saved id remains pending until its language registers, while removal returns an active selection to the available browser match or `en`. Catalog changes advance the `LocaleFace` revision so the Language row follows registration and disposal. -**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). +**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionIndicator`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). **Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation. diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index e9bd1ed19e..8546d06a36 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -16,7 +16,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **内置 locale 集合封闭,语言目录可扩展。** 本包只提供 `zh` 与 `en`,类型化命名空间注册仍要求这对双语字典。外部 client 插件通过 `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` 增加语言,并通过既有的单 locale 字典注册贡献不完整翻译;语言定义与字典可以按任意顺序注册。外部语言 id 是经过校验的 BCP 47 标签,同时用于偏好存储、字典查找、浏览器匹配和 ``;该标签承载可互操作的语言语义而非不透明身份,因此 `LocaleId` 保持 string。内置 `zh` 定义继续使用内部 `zh-CN` 文档标签。每个新增语言都声明一个已注册的 fallback,fallback 自身的定义给出下一层 fallback,整条链必须终止于 `en`;未知目标和循环在注册时失败。每个 key 先在请求的命名空间中沿链查找,再在 `common` 中重复同一条链,最后显示 key 本身。Host 存储开放字符串偏好;不可用的已保存 id 会保持待采用,直至对应语言注册;定义移除后,正在使用的选择会回落到可用的浏览器匹配或 `en`。目录变更推进 `LocaleFace` revision,使语言设置行跟随注册和 dispose。 -**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。 +**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionIndicator` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。 **所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。 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 a73eb1a62f..f717ba76e2 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: 8f26b2977dceeb2085bf270ae603cd21d48157f5 -2026-08-18-session-history-and-event-transport.zh.md: 10edeff16695cac265f2026b300eb206838a53e4 +2026-08-18-session-history-and-event-transport.md: d35ed79dedd5592d15a27b0e1b952e66d80b268f +2026-08-18-session-history-and-event-transport.zh.md: 6e6ccf53e28c9a7ce76bb4aa5d80d94f39e11f10 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 8f26b2977d..d35ed79ded 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 @@ -60,11 +60,13 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do ### Connection generation and physical connections -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 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, but the mux performs no independent retry scheduling. -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. +The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (two 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. Before each Ping, the Host marks the socket as awaiting Pong and terminates it at the next interval if no Pong arrived. -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`. +After an initial connection failure or the loss of a connected socket, open logical streams end their current physical generation with `RemoteStreamCarrierError`. `ConnectionController` owns the bounded exponential retry schedule; each attempt asks the mux to replace any candidate or active socket exactly once before reopening `$events`. A user-requested reconnect resets the attempt sequence and bypasses the delay through the same path ([decision](../feature/2026-08-28-web-connection-recovery-control.md)). + +The browser's network-status events are inputs to the same Controller. `offline` withdraws the Connection generation and suspends automatic retries; the next `online` transition restarts the base backoff. These events never establish connectivity: only a fresh `$events` ready frame publishes a Connection generation. In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. @@ -74,11 +76,11 @@ The Host event source installs incremental listeners synchronously before return `ConnectionController` publishes `connected` only after `$events` readiness, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. -Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` after backoff. +Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` under its bounded backoff unless the browser is offline or a user requests an immediate retry. 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. -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. +Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops retry delays, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. ### General Remote stream model @@ -330,7 +332,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re ## Verification -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. +Gateway mux tests pin connection without logical streams, idle residency, one physical attempt per request, configurable Ping/Pong without application messages, active-stream carrier failure, cancellation, and no reconnect after disposal. Connection tests pin missing, duplicate, and withdrawn generation sources, readiness timeout, and generation withdrawal and rebuilding after failure. 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 10edeff166..6e6ccf53e2 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 @@ -60,11 +60,13 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host ### Connection generation 与物理连接 -浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 +浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻,但 mux 不运行独立的 retry 调度。 -Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。 +Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。每次 Ping 前,Host 把 socket 标记为等待 Pong;若到下一间隔仍未收到 Pong,Host 会终止该 socket。 -首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 +首次建连失败或已连接 socket 丢失后,已打开的 logical stream 会以 `RemoteStreamCarrierError` 结束当前物理 generation。`ConnectionController` 拥有有界的指数 retry 调度;每次尝试都要求 mux 恰好一次替换候选或活动 socket,再重开 `$events`。用户要求的重连通过同一路径重置 attempt 序列并跳过等待(见[决策](../feature/2026-08-28-web-connection-recovery-control.zh.md))。 + +浏览器网络状态事件是同一 Controller 的输入。`offline` 会撤回 Connection generation 并暂停自动 retry;下一次 `online` 转换会从基础退避档重新开始。这些事件不会建立连接;只有新的 `$events` ready 帧才会发布 Connection generation。 进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 @@ -74,11 +76,11 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后 `ConnectionController` 只有在 `$events` ready 后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 -`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,退避后重新建立 `$events`。 +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,随后按有界退避重新建立 `$events`;浏览器离线时暂停,用户要求立即重试时则跳过等待。 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 -Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 +Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止重试等待,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 ### 通用 Remote stream 模型 @@ -330,7 +332,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace ## 验证 -Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、每次请求只做一次物理尝试、可配置且不产生应用消息的 Ping/Pong、活动 stream carrier failure、取消和 dispose 后不再重连。 Connection 测试固定 generation source 缺失、重复注册、撤回、ready 超时,以及 generation 失败后的撤回和重建。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml index 8cf0ccbbd2..a0680163b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.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-23-locale-owned-client-ui-copy.md -2026-08-23-locale-owned-client-ui-copy.md: 7fa2d60f14253a74b2bd3df4398471905a32509b -2026-08-23-locale-owned-client-ui-copy.zh.md: 5515699bb1702d41726c57435b19a2256ee0b896 +2026-08-23-locale-owned-client-ui-copy.md: 5f645a34c386ba340c5a8d52e8bdef2258dd77bd +2026-08-23-locale-owned-client-ui-copy.zh.md: 996ef56b17637a4ca60b8793075e9975faf0e1e1 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md index 7fa2d60f14..5f645a34c3 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md @@ -12,7 +12,7 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d **Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated. -**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionBanner` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning. +**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning. **Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state. diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md index 5515699bb1..996ef56b17 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md @@ -12,7 +12,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整 **所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。 -**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionBanner` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 +**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 **本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。 diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml new file mode 100644 index 0000000000..81a25f4ac3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.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-28-web-connection-recovery-control.md +2026-08-28-web-connection-recovery-control.md: 7adf372498105bad643606cf7cd99c71406b634b +2026-08-28-web-connection-recovery-control.zh.md: c1172c530035590ebcd636e8735bc378c15bc69d diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md new file mode 100644 index 0000000000..7adf372498 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md @@ -0,0 +1,41 @@ +# Agent Note: Web connection recovery control + +Status: implemented + +English | [中文](2026-08-28-web-connection-recovery-control.zh.md) + +## Problem + +The Web Client automatically rebuilt its Remote event generation and physical WebSocket after a failure, but the page exposed neither the outage nor a user recovery action. Its logical-generation and physical-socket retry loops could also drift: a `retry #N` message could describe another logical generation while the browser still waited on the same physical connection candidate. The Host sent an idle WebSocket Ping only every 30 seconds, and a user could not request a fresh attempt after restoring the Host or network. + +## Decision + +The Host sends WebSocket Ping control frames every two seconds by default through the existing validated `websocketHeartbeatIntervalMs` configuration. Before each Ping it marks the socket as awaiting Pong; a socket still awaiting Pong at the next interval is terminated. `ConnectionController` is the sole retry scheduler. Online transport failures enter jittered exponential backoff whose cap starts at 500ms, doubles through 1s, 2s, 4s, and 8s, and stops growing at 10s; the actual delay is 50–100% of the cap. The failed retry in the 10s tier ends automatic recovery and publishes `disconnected`. Each physical retry publishes `connecting`, writes one `retry #N` warning, asks Gateway mux to replace any candidate or active socket exactly once, and reopens the internal `$events` stream. + +The Client Connection service exposes the identity-stable `ctx.connection.state` observable and `ctx.connection.reconnect()`. Its snapshot is undefined until the first connection outcome, then carries `disconnected`, `connecting`, or `connected`; equivalent states do not notify. Manual reconnect interrupts the current generation or retry delay, resets the attempt number, and starts retry 1 immediately through the same physical and logical path as automatic recovery. The browser's `offline` event immediately aborts active connection work, publishes `disconnected`, and suspends automatic retries. The next `online` transition publishes `connecting`, resets the attempt number, and starts again at the 500ms backoff tier; duplicate events do not create another loop. A fresh `$events` ready frame, rather than `navigator.onLine`, proves Host connectivity. Logical streams continue to own their baseline, cursor, and replay semantics after the replacement generation. + +The [Web Client architecture](../architecture/2026-07-19-gui-web-client-architecture.md), [Remote event delivery](../architecture/2026-08-10-remote-event-delivery.md), and [Session event transport](../architecture/2026-08-18-session-history-and-event-transport.md) retain their broader ownership decisions; this note supersedes only their former retry timing. + +The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Connection issue** action, `connecting` stays yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing. + +## Alternatives considered + +**Retry every two seconds without a terminal state.** Rejected because a long outage would create continuous connection traffic. The retained exponential policy retries quickly at first, becomes progressively quieter, and leaves a stable recovery action after the 10s tier fails. + +**Render a full-width `ConnectionBanner` at the top of the viewport.** Rejected because the status belongs beside the recovery action the user named, and a global overlay consumes unrelated page chrome. The primitive is the inline `ConnectionIndicator`; no `ConnectionBanner` compatibility export exists before the first tagged release. + +**Expose lifecycle control through `ctx.remote.$connection`.** Rejected because retry state and commands belong to the Connection service rather than the Remote method namespace. Direct `ctx.connection` use remains exceptional and is appropriate here because the indicator itself controls reconnection. + +**Retry only when the user clicks.** Rejected because recovery must remain automatic when the user is not watching the page; the button resets the backoff and bypasses its current wait. + +## Consequences + +Idle browser connections generate more frequent heartbeat traffic than the former default, while long outages stop generating connection attempts after the capped retry fails. Deployments may override the Host Ping interval. Gateway mux owns no second retry timer, so every `retry #N` warning corresponds to one Controller-requested physical attempt. + +A manual reconnect intentionally disrupts every logical Remote stream sharing the physical socket. Their existing generation supervisors restore state through fresh baselines or cursors, and one-way notifications remain non-replayed. + +The connection state and browser-network input stay in the React-free transport layer. The Settings component receives a framework-bound selector hook and a plain callback, so no UI store duplicates transport state; only the two-second success presentation and 500ms dot animation are presentation-local. + +## Testing + +Connection and Gateway tests pin the two-second heartbeat and Pong deadline, exponential retry limits and logs, browser offline suspension and online reset, manual sequence reset, one socket replacement per requested attempt, state deduplication, listener isolation, and disposal. Component tests pin healthy-state absence, hover/action copy, the independent dot animation, click behavior, and the two-second success state. The assembled Web test drives browser offline/online transitions, failed WebSocket attempts, stable indicator geometry, manual recovery, and the success confirmation through the shipped application. diff --git a/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md new file mode 100644 index 0000000000..c1172c5300 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Web 连接恢复控件 + +Status: implemented + +[English](2026-08-28-web-connection-recovery-control.md) | 中文 + +## Problem + +Web Client 会在故障后自动重建 Remote event generation 与物理 WebSocket,但页面既不显示断联,也不提供用户恢复操作。logical generation 与 physical socket 的重试循环还可能错位:`retry #N` 消息可能描述另一个 logical generation,而浏览器仍在等待同一个物理连接候选。Host 每 30 秒才发送一次空闲 WebSocket Ping,用户在恢复 Host 或网络后也无法主动要求一次全新尝试。 + +## Decision + +Host 默认通过既有且经过校验的 `websocketHeartbeatIntervalMs` 配置,每 2 秒发送一次 WebSocket Ping 控制帧。每次 Ping 前,它把 socket 标记为等待 Pong;到下一间隔仍未收到 Pong 的 socket 会被终止。`ConnectionController` 是唯一的 retry 调度器。在线状态下的传输失败进入带抖动的指数退避:上限从 500ms 开始,依次翻倍为 1s、2s、4s、8s,最终封顶 10s;实际延迟是上限的 50%–100%。10s 档的 retry 仍失败后,自动恢复结束并发布 `disconnected`。每次物理 retry 都发布 `connecting`、写一条 `retry #N` warning、要求 Gateway mux 恰好一次替换候选或活动 socket,再重开内部 `$events` stream。 + +Client Connection 服务暴露 identity 稳定的 `ctx.connection.state` observable 与 `ctx.connection.reconnect()`。snapshot 在首次连接结果前为 undefined,此后为 `disconnected`、`connecting` 或 `connected`;等价状态不触发通知。手动重连会中断当前 generation 或重试等待、重置 attempt 序号,并通过与自动恢复相同的物理和逻辑路径立即开始 retry 1。浏览器的 `offline` 事件会立即中断活动连接工作、发布 `disconnected` 并暂停自动 retry;下一次 `online` 转换会发布 `connecting`、重置 attempt 序号,并从 500ms 退避档重新开始;重复事件不会创建另一条循环。Host 是否可达由新的 `$events` ready 帧证明,而不是由 `navigator.onLine` 证明。替换 generation 建立后,各 logical stream 仍自行持有 baseline、cursor 与 replay 语义。 + +[Web Client 架构](../architecture/2026-07-19-gui-web-client-architecture.zh.md)、[Remote 事件投递](../architecture/2026-08-10-remote-event-delivery.zh.md)和[会话事件传输](../architecture/2026-08-18-session-history-and-event-transport.zh.md)继续持有各自更宽的所有权决策;本笔记只取代其中原有的重试时序。 + +Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 保持黄色,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。 + +## Alternatives considered + +**固定每 2 秒重试且不进入终态。**不采用,因为长时间故障会持续产生连接流量。保留的指数策略先快速重试,再逐步降低频率,并在 10s 档失败后留下稳定的恢复操作。 + +**在视口顶部渲染全宽 `ConnectionBanner`。**不采用,因为状态应放在用户指定的恢复操作旁,全局覆盖层还会占用无关页面界面框架。该原语是内联 `ConnectionIndicator`;首次标签发布前不存在 `ConnectionBanner` 兼容导出。 + +**通过 `ctx.remote.$connection` 暴露生命周期控制。**不采用,因为 retry 状态与命令属于 Connection 服务,而不是 Remote 方法 namespace。直接使用 `ctx.connection` 仍是例外;本指示器本身负责控制重连,因此符合该例外。 + +**仅在用户点击时重试。**不采用,因为用户没有观察页面时仍必须自动恢复;按钮会重置退避并跳过当前等待。 + +## Consequences + +空闲浏览器连接的心跳流量会高于原默认值;长时间故障则在封顶档 retry 失败后停止产生连接尝试。部署仍可覆盖 Host Ping 间隔。Gateway mux 不拥有第二个 retry timer,因此每条 `retry #N` warning 都对应一次由 Controller 请求的物理尝试。 + +手动重连会刻意中断共享物理 socket 的全部 logical Remote stream。它们既有的 generation supervisor 会通过新 baseline 或 cursor 恢复状态;单向通知仍不重放。 + +连接状态与浏览器网络输入都位于 React-free 传输层。Settings 组件只接收框架绑定的 selector hook 与普通回调,因此没有 UI store 复制传输状态;只有 2 秒成功提示和 500ms 点动画属于展示层本地状态。 + +## Testing + +Connection 与 Gateway 测试固定 2 秒心跳及 Pong deadline、指数 retry 上限与日志、浏览器离线暂停和在线重置、手动重置序列、每次请求只替换一个 socket、状态去重、listener 隔离与 dispose。组件测试固定健康状态下不显示、悬浮与操作文案、独立点动画、点击行为与 2 秒成功状态。组装 Web 测试通过随附浏览器应用驱动浏览器 offline/online 转换、失败的 WebSocket 尝试、稳定的指示器几何、手动恢复与成功确认。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 1a01d0c4f9..625ddd73b2 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -3,7 +3,7 @@ // One tiny recorded turn (text-only) drives the whole spec: the empty-state // hero materializes a real Workspace + Session on first send (the jsdom // workspace-flow suite pins the object-layer state machine over the fixture -// client; THIS spec pins the same flow through HTTP RPC + SSE + the host +// client; THIS spec pins the same flow through HTTP RPC + WebSocket + the host // gateway), reload replays everything from the log (zero further model // calls), and the theme scenario proves the shipped dark palette actually // cascades: attribute -> alias token flip -> painted surface change. No @@ -13,7 +13,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Page, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -22,7 +22,9 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' +import { + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -31,6 +33,7 @@ const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') +const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') @@ -282,12 +285,128 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it.skipIf(MODE === 'record')('shows automatic and user-requested connection recovery beside Settings', async () => { + const recoveryPage = await browser.newPage({ + viewport: { width: 1680, height: 1000 }, + locale: ZH_BROWSER_LOCALE, + }) + const recoveryTripwire = watchConsole(recoveryPage) + const sockets: WebSocketRoute[] = [] + let rejectConnections = false + await recoveryPage.routeWebSocket('**/api/remote.mux', (route) => { + sockets.push(route) + if (rejectConnections) { + void route.close({ code: 4001, reason: 'connection recovery test' }) + return + } + route.connectToServer() + }) + onTestFailed(() => saveFailureShot(recoveryPage, 'web-e2e-connection-recovery')) + try { + await recoveryPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await recoveryPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(() => sockets.length).toBe(1) + rejectConnections = true + await recoveryPage.context().setOffline(true) + await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(false) + const offline = recoveryPage.getByRole('button', { + name: '连接异常,点击立即重连', exact: true, + }) + await offline.waitFor({ timeout: 2_000 }) + await recoveryPage.waitForTimeout(750) + expect(sockets).toHaveLength(1) + + await recoveryPage.context().setOffline(false) + await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true) + const connecting = recoveryPage.getByRole('button', { + name: '连接中,点击立即重连', exact: true, + }) + await connecting.waitFor({ timeout: 10_000 }) + expect(await connecting.innerText()).toMatch(/^连接中\.{1,3}$/) + const connectingGeometry = await connectionIndicatorGeometry(connecting) + await connecting.hover() + expect(await connecting.innerText()).toBe('立即重连') + expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry) + await recoveryPage.mouse.move(0, 0) + + await expect.poll(() => sockets.length, { timeout: 40_000 }).toBe(7) + const indicator = recoveryPage.getByRole('button', { + name: '连接异常,点击立即重连', exact: true, + }) + await indicator.waitFor({ timeout: 10_000 }) + expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry) + const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE) + const style = await indicator.evaluate((element) => { + const probe = document.createElement('span') + probe.style.color = 'var(--dsw-alias-state-warn-label)' + probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)' + document.body.append(probe) + const actual = getComputedStyle(element) + const reference = getComputedStyle(probe) + const result = { + background: actual.backgroundColor, + color: actual.color, + referenceBackground: reference.backgroundColor, + referenceColor: reference.color, + } + probe.remove() + return result + }) + expect(style.background).toBe(style.referenceBackground) + expect(style.color).toBe(style.referenceColor) + expect(await indicator.locator('svg').count()).toBe(1) + expect(await indicator.getAttribute('title')).toBeNull() + const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) + await indicator.hover() + expect(await indicator.innerText()).toBe('立即重连') + const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) + expect(hoverBackground).toBe(idleBackground) + await recoveryPage.mouse.down() + await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor)) + .not.toBe(hoverBackground) + rejectConnections = false + await recoveryPage.mouse.up() + + await expect.poll(() => sockets.length).toBe(8) + const recovered = recoveryPage.getByRole('status') + await recovered.waitFor({ timeout: 10_000 }) + expect(await recovered.innerText()).toBe('连接成功') + expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry) + await recovered.waitFor({ state: 'detached', timeout: 5_000 }) + expect(recoveryTripwire.pageErrors).toEqual([]) + expect(recoveryTripwire.warnings.filter(warning => /connection lost, retry #[1-6]/i.test(warning))) + .toHaveLength(7) + } finally { + await recoveryPage.close() + } + }, 60_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'replay.override.json', 'command-menu.expected.md', - 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', + 'command-menu-fuzzy.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) }) + +async function connectionIndicatorGeometry(locator: ReturnType): Promise<{ + readonly outer: readonly number[] + readonly icon: readonly number[] + readonly label: readonly number[] +}> { + return await locator.evaluate((element) => { + const outer = element.getBoundingClientRect() + const icon = element.children.item(0)?.getBoundingClientRect() + const label = element.children.item(1)?.getBoundingClientRect() + if (icon === undefined || label === undefined) throw new Error('connection indicator children missing') + const rounded = (values: readonly number[]): readonly number[] => values.map(value => Math.round(value * 100) / 100) + return { + outer: rounded([outer.x, outer.y, outer.width, outer.height]), + icon: rounded([icon.x - outer.x, icon.y - outer.y, icon.width, icon.height]), + label: rounded([label.x - outer.x, label.y - outer.y, label.width, label.height]), + } + }) +} diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ad34214f63..9ccc5d22db 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: a48163a2ea9b04c99e2322620e207cc0a5aa3b88 -config-catalog.zh.md: bb662eecf45d72eb605a93663e9177cab10fcc34 +config-catalog.md: 9e2b671ee054af797e9a919920fdd799e8c50e61 +config-catalog.zh.md: df386630eeddefaccd9d1630da95a7116492173c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a48163a2ea..9e2b671ee0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -285,7 +285,7 @@ Requires: `typert` ```ts config-catalog /** Gateway transport configuration. */ export interface Config { - /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bb662eecf4..df386630ee 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -287,7 +287,7 @@ export interface Config { ```ts config-catalog /** Gateway transport configuration. */ export interface Config { - /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number } ``` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 04e07d0c19..7112bfb506 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: 5c8d6a4f4c9eff9f2ddee548ecd9003e7a5eda8b -module-graph.zh.md: e507a11e6e3e1c220c7947a76cda16645e79512f +module-graph.md: 7c4ccbf6841070d37116641c1404a70cbb598769 +module-graph.zh.md: 4581a5bdda1e75a9678a731ab109d3b3c71b7434 diff --git a/docs/module-graph.md b/docs/module-graph.md index 5c8d6a4f4c..7c4ccbf684 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1506,6 +1506,7 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_ui_renderer pkg_client_ui_settings_general --> pkg_client_ui_session @@ -1965,7 +1966,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `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-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e507a11e6e..4581a5bdda 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1508,6 +1508,7 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_ui_renderer pkg_client_ui_settings_general --> pkg_client_ui_session @@ -1967,7 +1968,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `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-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index dfaa6be5bf..a88823340e 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: 509f1108a8f940b93b9555399ec59c26325fe4b8 -README.zh.md: 116969c9f04d7791475ff63d8b4602e96c2aaa5b +README.md: 8cbb8048ca2e63efb7ea65cb2b92b06f78455f6a +README.zh.md: a15ad89e15e2725d9802b1676f3ddd1bbd54b03f diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 509f1108a8..8cbb8048ca 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. 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. +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 and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. 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, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. @@ -49,7 +49,9 @@ Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ o `ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under bounded jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. + +`ctx.remote` exposes no Connection lifecycle control. A consumer whose responsibility includes recovery reads `ctx.connection.state` and calls `ctx.connection.reconnect()` directly; ordinary Remote consumers stay on generated namespaces and `$stream()`. The [connection recovery decision](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md) owns this exception. Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. @@ -72,7 +74,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. +- `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 116969c9f0..a15ad89e15 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,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验、每 Client 队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 @@ -49,7 +49,9 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按有界且带抖动的指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 + +`ctx.remote` 不暴露 Connection 生命周期控制。只有职责包含恢复的消费方才直接读取 `ctx.connection.state` 并调用 `ctx.connection.reconnect()`;普通 Remote 消费方仍只使用生成的 namespace 与 `$stream()`。[连接恢复决策](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md)规定这项例外。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 @@ -72,7 +74,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 才会重连。 +- `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 07a76d685f..2ad8c0225a 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -218,7 +218,7 @@ afterEach(async () => { describe('Typert Remote streams', () => { it('validates the WebSocket heartbeat timer range', () => { - expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 }) + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_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]) { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index d0af7669e9..5b4cbdf3b0 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -308,6 +308,7 @@ async function benchFiber( readonly ctx: Context readonly client: Fiber readonly generation: GenerationHarness + readonly start: ReturnType> }> { const ctx = new Context() await ctx.plugin(TypertRegistry) @@ -315,14 +316,15 @@ async function benchFiber( ? { call } : { call, open } const generation = new GenerationHarness() + const start = vi.fn(() => ({ stop: () => {} })) ctx.provide('connection', { rpc, registerGenerationSource: generation.register, - start: () => ({ stop: () => {} }), + start, } as unknown as ConnectionHandle) const client = ctx.plugin({ inject, apply }) await client - return { ctx, client, generation } + return { ctx, client, generation, start } } async function *unexpectedInProcessStream(): AsyncGenerator { @@ -404,7 +406,10 @@ function deferredReadiness(): { return { promise, resolve, reject } } -async function loaderReadinessBench(readiness: Promise): Promise<{ +async function loaderReadinessBench( + readiness: Promise, + carrier: 'in-process' | 'web' = 'in-process', +): Promise<{ readonly client: Fiber readonly start: ReturnType> readonly stop: ReturnType void>> @@ -414,11 +419,9 @@ async function loaderReadinessBench(readiness: Promise): Promise<{ const generation = new GenerationHarness() const stop = vi.fn<() => void>() const start = vi.fn(() => ({ stop })) + const call = vi.fn() ctx.provide('connection', { - rpc: { - call: vi.fn(), - open: () => unexpectedInProcessStream(), - }, + rpc: carrier === 'web' ? { call } : { call, open: () => unexpectedInProcessStream() }, registerGenerationSource: generation.register, start, } as unknown as ConnectionHandle) @@ -584,6 +587,35 @@ describe('Client Remote transport readiness', () => { expect(remote.$host).toBe(afterReady) }) + it('forwards each connection retry to the browser WebSocket owner', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const { client, start } = await benchFiber( + vi.fn(), + 'web', + ) + try { + expect(FakeWebSocket.sockets).toHaveLength(1) + start.mock.calls[0]![0].onReconnectRequested?.() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + } finally { + await client.dispose() + } + }) + }) + + it('does not replace an in-process carrier when Connection retries', async () => { + const { client, start } = await benchFiber( + vi.fn(), + 'in-process', + ) + try { + expect(() => { start.mock.calls[0]![0].onReconnectRequested?.() }).not.toThrow() + } finally { + await client.dispose() + } + }) + it('starts after Loader settlement and stops the owned loop on disposal', async () => { const readiness = deferredReadiness() const { client, start, stop } = await loaderReadinessBench(readiness.promise) @@ -596,6 +628,20 @@ describe('Client Remote transport readiness', () => { expect(stop).toHaveBeenCalledTimes(1) }) + it('starts a fresh WebSocket attempt when Loader settles after the eager attempt failed', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const readiness = deferredReadiness() + const { client, start } = await loaderReadinessBench(readiness.promise, 'web') + expect(FakeWebSocket.sockets).toHaveLength(1) + FakeWebSocket.sockets[0]!.fail() + readiness.resolve() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(start).toHaveBeenCalledOnce() + await client.dispose() + }) + }) + it('does not start when disposal wins the Loader-settlement race', async () => { const readiness = deferredReadiness() const { client, start, stop } = await loaderReadinessBench(readiness.promise) @@ -2237,62 +2283,138 @@ describe('Client Typert API', () => { }) describe('Remote stream client carrier lifecycle', () => { - it('connects without a logical stream, reconnects after failures, and stops permanently', async () => { + it('requires the transport owner to start the physical carrier', async () => { + const client = new RemoteStreamMuxClient() + await expect(client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client not started') + await client.close() + }) + + it('connects without a logical stream, waits for owner-driven retries, and stops permanently', async () => { await withFakeWebSocket('https://harness.example', async () => { FakeWebSocket.autoOpen = false - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const client = new RemoteStreamMuxClient() - client.start() - client.start() - expect(FakeWebSocket.sockets).toHaveLength(1) + const client = new RemoteStreamMuxClient() + client.start() + client.start() + expect(FakeWebSocket.sockets).toHaveLength(1) - const failed = FakeWebSocket.sockets[0]! - failed.fail() - await vi.advanceTimersByTimeAsync(500) - expect(FakeWebSocket.sockets).toHaveLength(2) + const failed = FakeWebSocket.sockets[0]! + failed.fail() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(1) - const connected = FakeWebSocket.sockets[1]! - connected.open() - await vi.advanceTimersByTimeAsync(0) - expect(connected.sent).toEqual([]) - connected.fail() - await vi.advanceTimersByTimeAsync(500) - expect(FakeWebSocket.sockets).toHaveLength(3) + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const connected = FakeWebSocket.sockets[1]! + connected.open() + await Promise.resolve() + client.start() + expect(FakeWebSocket.sockets).toHaveLength(2) + expect(connected.sent).toEqual([]) + connected.fail() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(2) - const replacement = FakeWebSocket.sockets[2]! - replacement.open() - replacement.drop() - await vi.advanceTimersByTimeAsync(500) - expect(FakeWebSocket.sockets).toHaveLength(4) + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(3) }) + const final = FakeWebSocket.sockets[2]! + final.open() + await client.close() + await client.close() + client.start() + await expect(client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed') - const final = FakeWebSocket.sockets[3]! - final.open() - await vi.advanceTimersByTimeAsync(0) - await client.close() - await client.close() - client.start() - await expect(client.open('feed/follow', {}, new AbortController().signal) - [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed') - await vi.advanceTimersByTimeAsync(20_000) + expect(FakeWebSocket.sockets).toHaveLength(3) + expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) - expect(FakeWebSocket.sockets).toHaveLength(4) - expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) - expect(warn).toHaveBeenCalledTimes(3) + const stopping = new RemoteStreamMuxClient() + stopping.start() + const racing = FakeWebSocket.sockets[3]! + racing.open() + racing.drop() + await stopping.close() + expect(FakeWebSocket.sockets).toHaveLength(4) + }) + }) - const stopping = new RemoteStreamMuxClient() - stopping.start() - const racing = FakeWebSocket.sockets[4]! - racing.open() - racing.drop() - await stopping.close() - await vi.advanceTimersByTimeAsync(20_000) - expect(FakeWebSocket.sockets).toHaveLength(5) - } finally { - warn.mockRestore() - vi.useRealTimers() - } + it('mints a new wire stream id when the same endpoint opens on a replacement socket', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + client.start() + const first = client.open('feed/follow', { label: 'same' }, new AbortController().signal) + [Symbol.asyncIterator]() + const firstPending = first.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const firstSocket = FakeWebSocket.sockets[0]! + const firstOpen = JSON.parse(firstSocket.sent[0]!) as { streamId: string } + firstSocket.receive({ type: 'end', streamId: firstOpen.streamId }) + await expect(firstPending).resolves.toEqual({ done: true, value: undefined }) + + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const second = client.open('feed/follow', { label: 'same' }, new AbortController().signal) + [Symbol.asyncIterator]() + const secondPending = second.next() + const secondSocket = FakeWebSocket.sockets[1]! + await vi.waitFor(() => { expect(secondSocket.sent).toHaveLength(1) }) + const secondOpen = JSON.parse(secondSocket.sent[0]!) as { streamId: string } + expect(secondOpen.streamId).not.toBe(firstOpen.streamId) + secondSocket.receive({ type: 'end', streamId: secondOpen.streamId }) + await expect(secondPending).resolves.toEqual({ done: true, value: undefined }) + await client.close() + }) + }) + + it('replaces an in-flight candidate and an open socket on reconnect', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const client = new RemoteStreamMuxClient() + client.start() + const candidate = FakeWebSocket.sockets[0]! + + client.reconnect() + const replacementPending = client.open( + 'feed/follow', + { label: 'replacement' }, + new AbortController().signal, + )[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(candidate.closedWith).toContainEqual({}) + const connected = FakeWebSocket.sockets[1]! + connected.open() + await vi.waitFor(() => { expect(connected.sent).toHaveLength(1) }) + const opened = JSON.parse(connected.sent[0]!) as { streamId: string } + connected.receive({ type: 'end', streamId: opened.streamId }) + await expect(replacementPending).resolves.toEqual({ done: true, value: undefined }) + + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(3) }) + expect(connected.closedWith).toContainEqual({ code: 4000, reason: 'reconnect requested' }) + + await client.close() + client.reconnect() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(3) + }) + }) + + it('coalesces repeated candidate replacements and drops one queued after close', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + const client = new RemoteStreamMuxClient() + client.start() + const first = FakeWebSocket.sockets[0]! + + client.reconnect() + client.reconnect() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + expect(first.closedWith).toContainEqual({}) + + client.reconnect() + await client.close() + await Promise.resolve() + expect(FakeWebSocket.sockets).toHaveLength(2) }) }) @@ -2300,6 +2422,7 @@ describe('Remote stream client carrier lifecycle', () => { await withFakeWebSocket(undefined, async () => { FakeWebSocket.autoOpen = false const client = new RemoteStreamMuxClient() + client.start() const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal) [Symbol.asyncIterator]() const second = client.open('feed/follow', { label: 'second' }, new AbortController().signal) @@ -2321,50 +2444,50 @@ describe('Remote stream client carrier lifecycle', () => { }) }) - it('keeps waiters across failed attempts and contains waiter cancellation', async () => { + it('fails waiters with one socket attempt and lets the owner start the next attempt', async () => { await withFakeWebSocket('null', async () => { FakeWebSocket.autoOpen = false - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const closedClient = new RemoteStreamMuxClient() - const closed = closedClient.open('feed/follow', {}, new AbortController().signal) - [Symbol.asyncIterator]().next() - FakeWebSocket.sockets[0]!.drop() - await vi.advanceTimersByTimeAsync(500) + const closedClient = new RemoteStreamMuxClient() + closedClient.start() + const closed = closedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[0]!.drop() + await expect(closed).rejects.toThrow('Remote stream WebSocket closed before opening') - const replacement = FakeWebSocket.sockets[1]! - replacement.open() - await vi.advanceTimersByTimeAsync(0) - const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string } - replacement.receive({ type: 'end', streamId }) - await expect(closed).resolves.toEqual({ done: true, value: undefined }) - await closedClient.close() + closedClient.reconnect() + const replacementStream = closedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const replacement = FakeWebSocket.sockets[1]! + replacement.open() + await vi.waitFor(() => { expect(replacement.sent).toHaveLength(1) }) + const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string } + replacement.receive({ type: 'end', streamId }) + await expect(replacementStream).resolves.toEqual({ done: true, value: undefined }) + await closedClient.close() - const disposedClient = new RemoteStreamMuxClient() - const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) - [Symbol.asyncIterator]().next() - FakeWebSocket.sockets[2]!.fail() - await disposedClient.close() - await expect(disposed).rejects.toThrow('Remote stream client disposed') + const disposedClient = new RemoteStreamMuxClient() + disposedClient.start() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + await disposedClient.close() + await expect(disposed).rejects.toThrow('Remote stream client disposed') - const abortedClient = new RemoteStreamMuxClient() - const abort = new AbortController() - const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() - abort.abort('cancelled while connecting') - await expect(aborted).rejects.toBe('cancelled while connecting') - await abortedClient.close() - expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux') - } finally { - warn.mockRestore() - vi.useRealTimers() - } + const abortedClient = new RemoteStreamMuxClient() + abortedClient.start() + const abort = new AbortController() + const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + abort.abort('cancelled while connecting') + await expect(aborted).rejects.toBe('cancelled while connecting') + await abortedClient.close() + expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux') }) }) it('fails active streams on an invalid frame and ignores later frames', async () => { await withFakeWebSocket('https://harness.example', async () => { const client = new RemoteStreamMuxClient() + client.start() const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]() const pending = stream.next() await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) @@ -2386,6 +2509,7 @@ describe('Remote stream client carrier lifecycle', () => { it('completes a stream and drops a frame racing with cancellation', async () => { await withFakeWebSocket('https://harness.example', async () => { const client = new RemoteStreamMuxClient() + client.start() const completed = client.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]() const completedPending = completed.next() @@ -2410,6 +2534,7 @@ describe('Remote stream client carrier lifecycle', () => { it('contains non-Error cancellation reasons and late socket close events', async () => { await withFakeWebSocket('http://harness.example', async () => { const cancelledClient = new RemoteStreamMuxClient() + cancelledClient.start() const abort = new AbortController() const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) @@ -2419,6 +2544,7 @@ describe('Remote stream client carrier lifecycle', () => { FakeWebSocket.dispatchClose = false const disposedClient = new RemoteStreamMuxClient() + disposedClient.start() const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) [Symbol.asyncIterator]().next() await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) }) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 2cc4c99f8c..e73c76f8c0 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -50,6 +50,18 @@ describe('Remote stream mux server carrier lifecycle', () => { await closed }) + it('terminates a socket that does not answer the previous heartbeat', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + serverSocket.removeAllListeners('pong') + const terminated = vi.spyOn(serverSocket, 'terminate') + const closed = once(client, 'close') + + await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) + await closed + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -193,7 +205,7 @@ const mapFailure: RemoteStreamFailureMapper = error => ({ details: {}, }) -async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise { +async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): Promise { const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) const http = createServer() http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 2f74ffe9a1..07f88b1a4f 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -57,9 +57,11 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { return () => { generationListeners.delete(listener) } }, }, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, + reconnect: () => {}, registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index fdfbf23105..b050b7c22c 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -199,9 +199,11 @@ function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { const connection: ConnectionHandle = { isLoopback: true, generation: AVAILABLE_CONNECTION.generation, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, + reconnect: () => {}, registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 6be2c4aa21..528a378161 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: af757608aa7face6854aaf9d103654f974b51ed4 -README.zh.md: fef7248abe1e19595b90c43a6a1b9abf0aafa88a +README.md: ee0566bf811a0bad32f7d59b4c8849e26efea613 +README.zh.md: 48425a428247b7749bf0dd75f592b5f7e4088ef2 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index af757608aa..ee0566bf81 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` reconnects with backoff. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. ## Table of Contents @@ -43,7 +43,7 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. -An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws the generation, publishes `reconnecting`, and reopens `$events` after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. +An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. While the browser reports network availability, the controller publishes `connecting` and retries with 50%–100% jitter under caps of 500ms, 1s, 2s, 4s, 8s, and 10s. It logs each attempt, asks Gateway to replace the physical WebSocket, and reopens `$events`; failure in the 10s tier publishes terminal `disconnected`. `ctx.connection.reconnect()` interrupts active work, resets the sequence, and starts retry 1 immediately. Browser `offline` aborts active work, publishes `disconnected`, and suspends automatic attempts; the next `online` transition resets the sequence and starts at the 500ms tier. A ready item publishes `connected`. The Gateway mux performs one physical connection attempt per request rather than running an independent retry schedule. The [connection recovery decision](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md) owns the cadence and manual recovery behavior. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index fef7248abe..48425a4282 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 退避重连。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 ## 目录 @@ -43,7 +43,7 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 -`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 generation、发布 `reconnecting`,并在退避后重开 `$events`。Gateway mux 自己负责重建底层 WebSocket;Connection generation 负责重开 logical stream 并建立下一次 baseline 起点。 +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。浏览器报告网络可用时,Controller 发布 `connecting`,并在 500ms、1s、2s、4s、8s 与 10s 上限内采用 50%–100% 抖动重试。它记录每次尝试、要求 Gateway 替换物理 WebSocket,再重开 `$events`;10s 档失败后发布终态 `disconnected`。`ctx.connection.reconnect()` 会中断活动工作、重置序列,并立即开始 retry 1。浏览器 `offline` 会中断活动工作、发布 `disconnected` 并暂停自动尝试;下一次 `online` 转换会重置序列并从 500ms 档开始。ready 项会发布 `connected`。Gateway mux 每次收到请求只做一次物理连接尝试,不再运行另一套重试调度。[连接恢复决策](../../../.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md)规定重试节奏和手动恢复行为。 ## 模型体验 diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index e317c78f0d..031204dc92 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -9,6 +9,7 @@ import { type ClientTransportHooks, type ConnectionGenerationSource, type ConnectionHandle, + type ConnectionState, } from '../src/client/index.ts' type Win = { @@ -19,8 +20,19 @@ type Win = { afterEach(() => { delete (globalThis as Win).location delete (globalThis as Win).__DSH_TRANSPORT__ + vi.unstubAllGlobals() + vi.useRealTimers() }) +class BrowserNetworkProbe extends EventTarget { + readonly navigator = { onLine: true } + + setOnline(online: boolean): void { + this.navigator.onLine = online + this.dispatchEvent(new Event(online ? 'online' : 'offline')) + } +} + class GenerationProbe { private readonly active = new Set<() => void>() @@ -133,6 +145,23 @@ describe('connection client apply', () => { errorSpy.mockRestore() }) + it('does not notify state subscribers when a pre-ready loop stops', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + handle.registerGenerationSource(signal => new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + })) + const listener = vi.fn() + const unsubscribe = handle.state.subscribe(listener) + const loop = handle.start({}) + + loop.stop() + + expect(handle.state.getSnapshot()).toBeUndefined() + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) + it('allows a replacement owner and ignores the previous owner handle', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() @@ -156,6 +185,94 @@ describe('connection client apply', () => { generation.end() }) + it('lets the connection service force only its current owner to reconnect', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + installGeneration(handle) + const requested = vi.fn() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const loop = handle.start({ onReconnectRequested: requested }, { + backoffBaseMs: 60_000, + backoffFactor: 2, + backoffMaxMs: 120_000, + generationReadyTimeoutMs: 500, + }) + try { + await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(1) }) + handle.reconnect() + await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.id).toBe(2) }) + expect(requested).toHaveBeenCalledOnce() + loop.stop() + handle.reconnect() + expect(requested).toHaveBeenCalledOnce() + } finally { + loop.stop() + warnSpy.mockRestore() + } + }) + + it('ignores a non-browser window shim without navigator state', async () => { + vi.stubGlobal('window', new EventTarget()) + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + installGeneration(handle) + const loop = handle.start({}) + try { + await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') }) + } finally { + loop.stop() + } + }) + + it('feeds browser offline and online events into the owned retry loop', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const browser = new BrowserNetworkProbe() + vi.stubGlobal('window', browser) + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + let calls = 0 + const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + calls++ + ready({ home: '/h' }) + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + handle.registerGenerationSource(source) + const states: Array = [] + const unsubscribe = handle.state.subscribe(() => { states.push(handle.state.getSnapshot()) }) + const loop = handle.start({}, { + backoffBaseMs: 100, + backoffFactor: 2, + backoffMaxMs: 1_000, + generationReadyTimeoutMs: 500, + }) + try { + await vi.advanceTimersByTimeAsync(0) + expect(handle.state.getSnapshot()).toBe('connected') + expect(calls).toBe(1) + + browser.setOnline(false) + expect(handle.state.getSnapshot()).toBe('disconnected') + await vi.advanceTimersByTimeAsync(10_000) + expect(calls).toBe(1) + + browser.setOnline(true) + expect(handle.state.getSnapshot()).toBe('connecting') + await vi.advanceTimersByTimeAsync(49) + expect(calls).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(calls).toBe(2) + expect(handle.state.getSnapshot()).toBe('connected') + expect(states).toEqual(['connected', 'disconnected', 'connecting', 'connected']) + } finally { + unsubscribe() + loop.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + } + }) + it('does not announce a generation synchronously stopped by a generation subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() @@ -180,7 +297,7 @@ describe('connection client apply', () => { } }) - it('retracts the generation while reconnecting and publishes the next generation', async () => { + it('retracts the generation while connecting and publishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) @@ -192,11 +309,11 @@ describe('connection client apply', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const loop = handle.start({ onStateChange: (state) => { - if (state === 'reconnecting') { + if (state === 'connecting') { reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home) } }, - }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) + }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { expect(handle.generation.getSnapshot()?.host.home).toBe('/h') @@ -213,7 +330,45 @@ describe('connection client apply', () => { } }) - it('does not announce reconnecting after a generation subscriber stops the loop', async () => { + it('publishes connection state directly on the service and isolates subscribers', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + const generation = installGeneration(handle) + const snapshots: Array = [] + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const unsubscribe = handle.state.subscribe(() => { snapshots.push(handle.state.getSnapshot()) }) + const stopThrowing = handle.state.subscribe(() => { throw new Error('state subscriber failed') }) + expect(handle.state.getSnapshot()).toBeUndefined() + + const loop = handle.start({}, { + backoffBaseMs: 10, + backoffFactor: 2, + backoffMaxMs: 80, + generationReadyTimeoutMs: 500, + }) + try { + await vi.waitFor(() => { expect(handle.state.getSnapshot()).toBe('connected') }) + const connected = handle.state.getSnapshot() + expect(handle.state.getSnapshot()).toBe(connected) + generation.end() + await vi.waitFor(() => { + expect(snapshots).toEqual([ + 'connected', + 'connecting', + 'connected', + ]) + }) + expect(errorSpy).toHaveBeenCalledWith('[connection] state listener threw:', expect.any(Error)) + } finally { + unsubscribe() + stopThrowing() + loop.stop() + errorSpy.mockRestore() + } + expect(handle.state.getSnapshot()).toBeUndefined() + }) + + it('does not announce disconnection after a generation subscriber stops the loop', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) @@ -224,11 +379,11 @@ describe('connection client apply', () => { stoppedOnRetraction = true owner.loop.stop() }) - const states: string[] = [] + const states: ConnectionState[] = [] const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const loop = handle.start({ onStateChange: (state) => { states.push(state) }, - }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) + }, { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 }) owner.loop = loop try { await vi.waitFor(() => { diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 94038abf8e..3b9432995f 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -5,7 +5,7 @@ import type { ConnectionGenerationSource, ConnectionState } from '../src/client/ import { ConnectionController } from '../src/client/connection.ts' import { FakeGenerationSource } from './fake-generation.client.ts' -const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } +const FAST = { backoffBaseMs: 10, backoffFactor: 2, backoffMaxMs: 80, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { it('announces connected with the Host facts from generation readiness', async () => { @@ -42,6 +42,404 @@ describe('connection lifecycle', () => { expect(source.activeCount).toBe(0) }) + it('uses jittered exponential backoff and stops after the capped retry fails', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reconnectRequested = vi.fn() + let calls = 0 + const states: ConnectionState[] = [] + const source: ConnectionGenerationSource = () => { + calls++ + return Promise.reject(new Error('offline')) + } + const controller = new ConnectionController(source, { + onReconnectRequested: reconnectRequested, + onStateChange: state => states.push(state), + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + expect(states).toEqual(['connecting']) + + for (const [attempt, delay] of [250, 500, 1_000, 2_000, 4_000, 5_000].entries()) { + await vi.advanceTimersByTimeAsync(delay) + expect(calls).toBe(attempt + 2) + } + + expect(reconnectRequested).toHaveBeenCalledTimes(6) + expect(warnSpy).toHaveBeenCalledTimes(6) + expect(warnSpy).toHaveBeenLastCalledWith('[connection] connection lost, retry #6') + expect(states.at(-1)).toBe('disconnected') + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(7) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('treats a non-growing backoff as one terminal retry tier', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const states: ConnectionState[] = [] + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: state => states.push(state), + }, { + backoffBaseMs: 10, + backoffFactor: 1, + backoffMaxMs: 80, + generationReadyTimeoutMs: 500, + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(5) + expect(calls).toBe(2) + expect(states).toEqual(['connecting', 'disconnected']) + await vi.advanceTimersByTimeAsync(1_000) + expect(calls).toBe(2) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('interrupts the retry delay when a reconnect is requested', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reconnectRequested = vi.fn() + let calls = 0 + const source: ConnectionGenerationSource = (signal, ready) => { + calls++ + if (calls === 1) return Promise.reject(new Error('offline')) + ready({ home: '/h' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + const controller = new ConnectionController(source, { onReconnectRequested: reconnectRequested }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + controller.reconnect() + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(2) + expect(reconnectRequested).toHaveBeenCalledOnce() + } finally { + controller.stop() + controller.reconnect() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('pauses retries while offline and restarts the base delay after each recovery', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const states: ConnectionState[] = [] + let calls = 0 + let active = 0 + let maxActive = 0 + const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + calls++ + active++ + maxActive = Math.max(maxActive, active) + ready({ home: '/h' }) + signal.addEventListener('abort', () => { + active-- + resolve() + }, { once: true }) + }) + const controller = new ConnectionController(source, { + onStateChange: state => states.push(state), + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + expect(states).toEqual(['connected']) + + controller.setNetworkAvailable(false) + controller.setNetworkAvailable(false) + expect(states.at(-1)).toBe('disconnected') + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + expect(active).toBe(0) + + controller.setNetworkAvailable(true) + controller.setNetworkAvailable(true) + expect(states.at(-1)).toBe('connecting') + await vi.advanceTimersByTimeAsync(125) + controller.setNetworkAvailable(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + + controller.setNetworkAvailable(true) + await vi.advanceTimersByTimeAsync(249) + expect(calls).toBe(1) + await vi.advanceTimersByTimeAsync(1) + expect(calls).toBe(2) + expect(active).toBe(1) + expect(maxActive).toBe(1) + expect(states).toEqual([ + 'connected', + 'disconnected', + 'connecting', + 'disconnected', + 'connecting', + 'connected', + ]) + expect(warnSpy).toHaveBeenCalledOnce() + expect(warnSpy).toHaveBeenCalledWith('[connection] connection lost, retry #1') + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('allows one manual attempt while offline without starting automatic retries', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const states: ConnectionState[] = [] + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: state => states.push(state), + }) + controller.setNetworkAvailable(false) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(states).toEqual(['disconnected']) + expect(calls).toBe(0) + + controller.reconnect() + expect(states.at(-1)).toBe('connecting') + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + expect(states.at(-1)).toBe('disconnected') + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + } finally { + controller.stop() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('does not lose a reconnect requested synchronously from the terminal state sink', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + let restart = true + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: (state) => { + if (state !== 'disconnected' || !restart) return + restart = false + controller.reconnect() + }, + }, { + backoffBaseMs: 10, + backoffFactor: 2, + backoffMaxMs: 10, + generationReadyTimeoutMs: 500, + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(5) + expect(calls).toBe(3) + expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([ + '[connection] connection lost, retry #1', + '[connection] connection lost, retry #1', + ]) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it.each([ + { + label: 'manual reconnect', + stopState: 'connecting' as const, + interrupt: (controller: ConnectionController) => { controller.reconnect() }, + }, + { + label: 'browser going offline', + stopState: 'disconnected' as const, + interrupt: (controller: ConnectionController) => { controller.setNetworkAvailable(false) }, + }, + ])('honors a synchronous stop from the $label state sink', async ({ stopState, interrupt }) => { + const source = new FakeGenerationSource() + const controller = new ConnectionController(source.source, { + onStateChange: (state) => { + if (state === stopState) controller.stop() + }, + }, FAST) + controller.start() + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) + interrupt(controller) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) + }) + + it('stops when the physical-reconnect sink disposes the controller', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + const reconnectRequested = vi.fn() + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onReconnectRequested: () => { + reconnectRequested() + controller.stop() + }, + }, FAST) + controller.start() + try { + await vi.advanceTimersByTimeAsync(5) + expect(calls).toBe(1) + expect(reconnectRequested).toHaveBeenCalledOnce() + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('stops before opening a retry when the connecting state sink disposes the controller', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }, { + onStateChange: (state) => { + if (state === 'connecting') controller.stop() + }, + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + expect(warnSpy).not.toHaveBeenCalled() + } finally { + controller.stop() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('restarts an active retry immediately and resets its attempt number', async () => { + vi.useFakeTimers() + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reconnectRequested = vi.fn() + const states: ConnectionState[] = [] + let calls = 0 + const source: ConnectionGenerationSource = (signal) => { + calls++ + if (calls <= 2) return Promise.reject(new Error('offline')) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + const controller = new ConnectionController(source, { + onReconnectRequested: reconnectRequested, + onStateChange: state => states.push(state), + }, FAST) + controller.start() + try { + await vi.advanceTimersByTimeAsync(20) + expect(calls).toBe(3) + expect(states.at(-1)).toBe('connecting') + + controller.reconnect() + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(4) + expect(states.at(-1)).toBe('connecting') + expect(reconnectRequested).toHaveBeenCalledTimes(3) + expect(warnSpy.mock.calls.map(([message]) => String(message))).toEqual([ + '[connection] connection lost, retry #1', + '[connection] connection lost, retry #2', + '[connection] connection lost, retry #1', + ]) + } finally { + controller.stop() + randomSpy.mockRestore() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('stops while an automatic retry delay is pending', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + let calls = 0 + const controller = new ConnectionController(() => { + calls++ + return Promise.reject(new Error('offline')) + }) + controller.start() + try { + await vi.advanceTimersByTimeAsync(0) + expect(calls).toBe(1) + controller.stop() + await vi.advanceTimersByTimeAsync(2_000) + expect(calls).toBe(1) + } finally { + controller.stop() + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('replaces an active generation immediately when reconnect is requested', async () => { + const source = new FakeGenerationSource() + const reconnectRequested = vi.fn() + let connected = 0 + const controller = new ConnectionController(source.source, { + onConnected: () => { connected++ }, + onReconnectRequested: reconnectRequested, + }, { backoffBaseMs: 60_000, backoffFactor: 2, backoffMaxMs: 120_000, generationReadyTimeoutMs: 500 }) + controller.start() + try { + await vi.waitFor(() => { expect(connected).toBe(1) }) + controller.reconnect() + await vi.waitFor(() => { expect(connected).toBe(2) }) + expect(reconnectRequested).toHaveBeenCalledOnce() + expect(source.activeCount).toBe(1) + } finally { + controller.stop() + } + }) + it('isolates a connected sink exception from the generation', async () => { const source = new FakeGenerationSource() let connected = 0 @@ -133,7 +531,7 @@ describe('connection lifecycle', () => { source.holdReady = false source.end() await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(states).toEqual(['reconnecting', 'connected']) + expect(states).toEqual(['connecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() @@ -182,7 +580,8 @@ describe('connection lifecycle', () => { ) controller.start() try { - await vi.waitFor(() => { expect(source.activeCount).toBeGreaterThan(0) }) + await Promise.resolve() + expect(source.activeCount).toBe(1) await new Promise(resolve => setTimeout(resolve, 45)) expect(connected).toBe(0) } finally { @@ -191,7 +590,7 @@ describe('connection lifecycle', () => { } }) - it('emits deduplicated connected/reconnecting state transitions', async () => { + it('emits the disconnected, retry-attempt, and connected transitions', async () => { const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 @@ -206,7 +605,7 @@ describe('connection lifecycle', () => { expect(states).toEqual(['connected']) source.fail(new Error('torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) - expect(states).toEqual(['connected', 'reconnecting', 'connected']) + expect(states).toEqual(['connected', 'connecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() @@ -231,7 +630,7 @@ describe('connection lifecycle', () => { expect(connected).toBe(0) }) - it('deduplicates consecutive reconnecting emissions across two straight failures', async () => { + it('keeps one connecting state across consecutive retry attempts', async () => { let sourceCalls = 0 const states: ConnectionState[] = [] let connected = 0 @@ -252,7 +651,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(sourceCalls).toBe(3) }) await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(states).toEqual(['reconnecting', 'connected']) + expect(states).toEqual(['connecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() diff --git a/packages/client/connection/tests/generation.client.spec.ts b/packages/client/connection/tests/generation.client.spec.ts index 0deace5dba..d2b365de4b 100644 --- a/packages/client/connection/tests/generation.client.spec.ts +++ b/packages/client/connection/tests/generation.client.spec.ts @@ -46,8 +46,8 @@ describe('Connection generation facts', () => { }) const loop = connection.start({}, { backoffBaseMs: 1, - backoffFactor: 1, - backoffMaxMs: 1, + backoffFactor: 2, + backoffMaxMs: 8, generationReadyTimeoutMs: 100, }) diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index bb4347c085..70ce31598c 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -34,7 +34,6 @@ export const en = { 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', - 'connection.reconnecting': 'Connection lost; reconnecting…', 'json.collapseNode': 'Collapse JSON node', 'json.expandNode': 'Expand JSON node', 'json.label': 'JSON', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index d5b9a45cfd..30cee308d5 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -32,7 +32,6 @@ export const zh = { 'unknown': '未知', 'none': '无', 'truncated': '已截断', - 'connection.reconnecting': '连接已断开,正在重连…', 'json.collapseNode': '收起 JSON 节点', 'json.expandNode': '展开 JSON 节点', 'json.label': 'JSON', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d672fb1544..d48604ea73 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: c88f101eee2e6988d06ee75f564ca4aeffd5efe6 -README.zh.md: 0af43e4831fc7ca90f113705dc33d5e3411929b6 +README.md: 42c1110e8735dd2191c8b7a2e4dcc1f2b9b938bc +README.zh.md: 9f3c06cacebb7a596204dbe1c8e00a549ce1fcae diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c88f101eee..42c1110e87 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-primitives` is the web client's shared React component library: every feature plugin composes its UI from these atoms, and nothing here depends on Cordis or the slot system. It provides the control set (buttons, pills, inputs, menus, modals, toast banners, disclosure rows, hover cards, connection banners), the icon glyphs and brand marks, positioning hooks for anchored overlays, and the content renderers for agent output: markdown with TeX math, terminal output, file reads, diffs, search results, web retrieval, and JSON inspection. The renderers are built for untrusted model output — raw HTML is dropped, links are neutralized or opened safely, and ANSI escape sequences are parsed rather than passed through. User-facing copy is supplied through label props; the feature plugin that composes an atom owns localization. +`dsh-client-ui-primitives` is the web client's shared React component library: every feature plugin composes its UI from these atoms, and nothing here depends on Cordis or the slot system. It provides the control set (buttons, pills, inputs, menus, modals, toast banners, disclosure rows, hover cards, connection indicators), the icon glyphs and brand marks, positioning hooks for anchored overlays, and the content renderers for agent output: markdown with TeX math, terminal output, file reads, diffs, search results, web retrieval, and JSON inspection. The renderers are built for untrusted model output — raw HTML is dropped, links are neutralized or opened safely, and ANSI escape sequences are parsed rather than passed through. User-facing copy is supplied through label props; the feature plugin that composes an atom owns localization. ## Table of Contents @@ -29,7 +29,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Controls and icons -`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionBanner`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and the `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. +`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. ### Rendering agent output @@ -37,7 +37,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Localizing copy -The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionBanner`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. +The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. ----- diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 0af43e4831..9f3c06cace 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-ui-primitives` 是 Web 客户端共享的 React 组件库:每个功能插件都用这些原子组件拼装自己的 UI,而这里没有任何内容依赖 Cordis 或 slot 系统。它提供控件集(按钮、胶囊、输入框、菜单、模态框、Toast 横幅、折叠行、悬浮卡片、连接横幅)、图标字形与品牌标记、锚定浮层用的定位钩子,以及 agent 输出的内容渲染器:带 TeX 公式的 markdown、终端输出、文件读取、差异、搜索结果、网页检索与 JSON 检查。这些渲染器为不受信任的模型输出而设计——原始 HTML 会被丢弃、链接会被失效或安全打开、ANSI 转义序列会被解析而非透传。面向用户的文案通过 label prop 提供;拼装某个原子组件的功能插件负责本地化。 +`dsh-client-ui-primitives` 是 Web 客户端共享的 React 组件库:每个功能插件都用这些原子组件拼装自己的 UI,而这里没有任何内容依赖 Cordis 或 slot 系统。它提供控件集(按钮、胶囊、输入框、菜单、模态框、Toast 横幅、折叠行、悬浮卡片、连接指示器)、图标字形与品牌标记、锚定浮层用的定位钩子,以及 agent 输出的内容渲染器:带 TeX 公式的 markdown、终端输出、文件读取、差异、搜索结果、网页检索与 JSON 检查。这些渲染器为不受信任的模型输出而设计——原始 HTML 会被丢弃、链接会被失效或安全打开、ANSI 转义序列会被解析而非透传。面向用户的文案通过 label prop 提供;拼装某个原子组件的功能插件负责本地化。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-library" ### 控件与图标 -`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionBanner`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 +`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 ### 渲染 agent 输出 @@ -37,7 +37,7 @@ kind: "package-library" ### 本地化文案 -这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionBanner`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 +这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 ----- diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 1f148c26a5..0c53c53964 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, ConnectionIndicator, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) @@ -419,11 +419,37 @@ describe('Modal', () => { }) }) -describe('ConnectionBanner', () => { - it('renders only while reconnecting', () => { - const { container, rerender } = render() +describe('ConnectionIndicator', () => { + it('renders outage, attempt progress, and recovered states without a native tooltip', () => { + const reconnect = vi.fn() + const labels = { + disconnectedLabel: 'Connection issue', + reconnectLabel: 'Reconnect', + connectingLabel: 'Connecting', + recoveredLabel: 'Connected', + reconnectActionLabel: 'Connection issue, reconnect now', + restartActionLabel: 'Connecting, restart now', + onReconnect: reconnect, + } + const { container, rerender } = render( + , + ) expect(container.firstChild).toBeNull() - rerender() - expect(container.textContent).toContain('Reconnecting') + rerender() + const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' }) + expect(indicator.textContent).toContain('Connection issue') + expect(indicator.textContent).toContain('Reconnect') + expect(indicator.hasAttribute('title')).toBe(false) + expect(indicator.querySelector('svg')).toBeTruthy() + fireEvent.click(indicator) + expect(reconnect).toHaveBeenCalledOnce() + + rerender() + expect(screen.getByRole('button', { name: 'Connecting, restart now' }).textContent) + .toContain('Connecting...') + + rerender() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() }) }) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 93ac13b82e..f827b507f4 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-general/README.md -README.md: f0c4ab7c50798919d42ade0eac6a9a93c8a4df1c -README.zh.md: 912ea0fb16c0d1d512ff846c8eda1e724664f1ea +README.md: acae84a4d4a5dddabb9e11135487911c84d47c96 +README.zh.md: db96d7d867a6d73aba75115c38d47937a1259ad4 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index f0c4ab7c50..acae84a4d4 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-settings-general` is the settings shell of the dsh web client: the Settings panel opens from the sidebar's bottom control with the trigger chrome and modal shell, the navigation is built from the sections features contribute, and first-run users are walked through one onboarding step at a time. It also registers everything on the Settings pages that belongs to no single feature: the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages; the shell itself ships no onboarding copy of its own. +`dsh-client-ui-settings-general` is the settings shell of the dsh web client: the Settings panel opens from the sidebar's bottom control, a connection-failure indicator beside that control offers immediate recovery, the navigation is built from the sections features contribute, and first-run users are walked through one onboarding step at a time. It also registers everything on the Settings pages that belongs to no single feature: the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages; the shell itself ships no onboarding copy of its own. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. After a Host connection failure, a pale-yellow **Connection issue** action appears to the right of Settings. Automatic recovery shows **Connecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. ### The General section @@ -53,6 +53,10 @@ The shell owns the chrome and the projections; every piece of content and copy b The navigation is a projection of the `settings.section` ledger; nav labels may be locale-following thunks, resolved through `resolveSlotLabel` and re-rendered on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order; the active registrant receives its id, `complete()`, and an `openSection(id)` callback, and completing or skipping transfers ownership to the next entry. +### Connection recovery + +The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the two-second recovered-state timer. + ### Document availability On a loopback page, the Client loads the provider's `hasDocument` capability through `settings/describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action calls the pathless, browser-authenticated `settings/openSettingsDocument` Remote; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 912ea0fb16..db96d7d867 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-general` 是 dsh Web 客户端的设置外壳:Settings 面板从侧边栏底部的控件打开,带触发控件与模态外壳;导航由各功能贡献的分区构建;首次运行的用户一次只走一个引导步骤。它还注册设置页面上所有不属于单一功能的内容:触发器、标题栏与关闭控件界面框架、「本地配置文件」操作、「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)与条件式首次使用引导步骤仍由各自的功能包提供;外壳本身不自带任何引导文案。 +`dsh-client-ui-settings-general` 是 dsh Web 客户端的设置外壳:Settings 面板从侧边栏底部的控件打开,该控件旁的连接故障指示器提供即时恢复操作;导航由各功能贡献的分区构建;首次运行的用户一次只走一个引导步骤。它还注册设置页面上所有不属于单一功能的内容:触发器、标题栏与关闭控件界面框架、「本地配置文件」操作、「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)与条件式首次使用引导步骤仍由各自的功能包提供;外壳本身不自带任何引导文案。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。Host 连接失败后,浅黄色的**连接异常**操作会出现在 Settings 右侧;自动恢复期间显示**连接中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。图标、文字起点、高度和宽度在所有可见状态中保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 ### 「通用」分区 @@ -53,6 +53,10 @@ kind: "package-reference" 导航是 `settings.section` 账本的投影;导航 label 可以是跟随语言的 thunk,经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。引导账本按升序投影;当前注册方会收到该条目的 id、`complete()` 与 `openSection(id)` 回调,完成或跳过当前步骤后,所有权转交给下一项。 +### 连接恢复 + +外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;2 秒恢复状态计时器归外壳所有。 + ### 文档可用性 在 loopback 页面上,Client 通过 `settings/describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染配置文件操作。该操作调用无路径参数且经浏览器认证的 `settings/openSettingsDocument` Remote;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index a557855323..b7168e8f6c 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -8,6 +8,12 @@ export const zh = { 'openDocument': '打开配置文件', 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', + 'connection.error': '连接异常', + 'connection.retry': '立即重连', + 'connection.connecting': '连接中', + 'connection.connected': '连接成功', + 'connection.reconnect': '连接异常,点击立即重连', + 'connection.restart': '连接中,点击立即重连', } satisfies Record /** The settings namespace key union. */ @@ -21,4 +27,10 @@ export const en = { 'openDocument': 'Open configuration file', 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', + 'connection.error': 'Connection issue', + 'connection.retry': 'Reconnect now', + 'connection.connecting': 'Connecting', + 'connection.connected': 'Connected', + 'connection.reconnect': 'Connection issue, reconnect now', + 'connection.restart': 'Connecting, restart now', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 5b9c4d04ed..128332e258 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -47,6 +47,10 @@ async function bench(isLoopback = true) { }) // The fixed Host facts the shell reads its loopback-only action from. remote.$host = { home: undefined, isLoopback } + ctx.provide('connection', { + state: { getSnapshot: () => 'connected', subscribe: () => () => {} }, + reconnect: () => {}, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } @@ -75,7 +79,7 @@ function generalEntry(slots: SlotRegistry) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.settings', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) it('fills all five seats for declarations before or after apply', async () => { @@ -123,8 +127,12 @@ describe('ui-settings-general apply', () => { const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() expect(b.locale.bind('settings')('title')).toBe('设置') + expect(b.locale.bind('settings')('connection.error')).toBe('连接异常') + expect(b.locale.bind('settings')('connection.connecting')).toBe('连接中') + expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功') b.locale.setLocale('en') expect(b.locale.bind('settings')('close')).toBe('Close') + expect(b.locale.bind('settings')('connection.reconnect')).toBe('Connection issue, reconnect now') b.locale.setLocale('zh') await fiber.dispose() // The (ns, locale) seats are free again — the dictionary disposer ran. diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index 7d61bbc78c..cce7ed163c 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -2,10 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' +import { en } from '../src/client/locales.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) type Row = { id: string; order: number; label: string } type Step = { id: string; order: number } @@ -19,11 +24,13 @@ const SEAT_CONTENT: Record = { } type AttentionSnapshot = Parameters[0]>[0] +type ConnectionSnapshot = Parameters[0]>[0] const noAttention: AttentionSnapshot = new Map() const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention) function mount({ wide = true, + connectionState = 'connected', onboardingActive = true, rows = [ { id: 'general', order: 0, label: 'General' }, @@ -34,11 +41,20 @@ function mount({ { id: 'welcome', order: -100 }, { id: 'credential', order: 0 }, ], -}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) { +}: { + wide?: boolean + connectionState?: ConnectionSnapshot + onboardingActive?: boolean + rows?: Row[] + steps?: Step[] +} = {}) { // Mutable row source standing in for the bound useSections hook; bump() // plays a ledger change through the same observable contract. let current = rows + let currentConnectionState = connectionState const listeners = new Set<() => void>() + const connectionListeners = new Set<() => void>() + const reconnect = vi.fn() const renderSlot = vi.fn( ((key: string, _owner: unknown, opts?: { only?: string }) => { if (key === 'settings.section') return
@@ -58,6 +74,17 @@ function mount({ useSessionPendingInteraction, useWorkspaces: unusedHook, wide, + reconnect, + t: makeTranslate(en), + useConnectionState: (select) => { + const [, force] = useState(0) + useEffect(() => { + const listener = () => { force(n => n + 1) } + connectionListeners.add(listener) + return () => { connectionListeners.delete(listener) } + }, []) + return select(currentConnectionState) + }, useOnboardingSteps: select => select(steps), useSections: (select) => { const [, force] = useState(0) @@ -77,7 +104,13 @@ function mount({ for (const fn of [...listeners]) fn() }) } - return { view, renderSlot, bump, listeners } + const setConnectionState = (next: typeof currentConnectionState) => { + act(() => { + currentConnectionState = next + for (const fn of [...connectionListeners]) fn() + }) + } + return { view, renderSlot, bump, listeners, reconnect, setConnectionState } } function openPanel() { @@ -103,6 +136,36 @@ describe('SettingsRoot trigger', () => { const { renderSlot } = mount({ wide: false }) expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false }) }) + + it('shows outage, retry progress, and a two-second recovery confirmation', () => { + vi.useFakeTimers() + const mounted = mount() + expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull() + + mounted.setConnectionState('disconnected') + const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' }) + expect(indicator.textContent).toContain('Connection issue') + expect(indicator.hasAttribute('title')).toBe(false) + expect(indicator.querySelector('svg')).toBeTruthy() + fireEvent.click(indicator) + expect(mounted.reconnect).toHaveBeenCalledOnce() + + mounted.setConnectionState('connecting') + expect(screen.getByRole('button', { name: 'Connecting, restart now' }).textContent) + .toContain('Connecting...') + + mounted.setConnectionState('connected') + expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() + act(() => { vi.advanceTimersByTime(1_999) }) + expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('keeps the reconnect indicator out of the collapsed rail', () => { + mount({ wide: false, connectionState: 'disconnected' }) + expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull() + }) }) describe('SettingsPanel chrome seats', () => { diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 9dbb7ebafe..8e33a75a10 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -24,6 +24,12 @@ async function bench() { const settings = { describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }), } + const reconnect = vi.fn() + const connectionState = { + getSnapshot: () => 'connected' as const, + subscribe: () => () => {}, + } + ctx.provide('connection', { state: connectionState, reconnect } as never) ctx.provide('remote', { $on: () => () => {}, $host: { home: undefined, isLoopback: false }, @@ -31,7 +37,7 @@ async function bench() { } as never) ctx.provide('remote.settings', settings as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry } + return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect } } function declare(slots: SlotRegistry): () => void { @@ -59,7 +65,7 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', ]) }) @@ -111,6 +117,16 @@ describe('ui-settings apply', () => { off() }) + it('projects the Gateway connection control without copying its state', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + expect(injected.hooks.connectionState).toBe(b.connectionState) + injected.reconnect() + expect(b.reconnect).toHaveBeenCalledOnce() + }) + it('projects onboarding entries into stable coordinator order', async () => { const b = await bench() declare(b.slots) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 47713be8ec..bef6356509 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -499,12 +499,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', }, { name: 'ConnectionHostInfo', declaration: 'export interface ConnectionHostInfo {\n readonly home: string;\n}', }, + { + name: 'ConnectionLoop', + declaration: 'export interface ConnectionLoop {\n stop(): void;\n}', + }, { name: 'ConnectionRpcFailure', declaration: 'export interface ConnectionRpcFailure {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n}', @@ -515,11 +519,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionSinks', - declaration: 'export interface ConnectionSinks {\n onConnected?: (host: ConnectionHostInfo) => void;\n onStateChange?: (state: ConnectionState) => void;\n}', + declaration: 'export interface ConnectionSinks {\n onConnected?: (host: ConnectionHostInfo) => void;\n onStateChange?: (state: ConnectionState) => void;\n onReconnectRequested?: () => void;\n}', }, { name: 'ConnectionState', - declaration: 'export type ConnectionState = \'connected\' | \'reconnecting\';', + declaration: 'export type ConnectionState = \'connected\' | \'disconnected\' | \'connecting\';', + }, + { + name: 'ConnectionStateSource', + declaration: 'export interface ConnectionStateSource {\n getSnapshot(): ConnectionState | undefined;\n subscribe(listener: () => void): () => void;\n}', }, { name: 'EntryKeyOf', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b37eee6d1..f790a3c1a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3106,6 +3106,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/snapshots/web/lifecycle-chrome/connection-error.expected.md b/snapshots/web/lifecycle-chrome/connection-error.expected.md new file mode 100644 index 0000000000..ded710efed --- /dev/null +++ b/snapshots/web/lifecycle-chrome/connection-error.expected.md @@ -0,0 +1,4 @@ +- button "设置": + - img + - text: 设置 +- button "连接异常,点击立即重连": 连接异常