Merge remote-tracking branch 'origin/master' into fix/github-install-connectivity

This commit is contained in:
ZiyaZhang committed 2026-09-22 22:18:07 -07:00
commit d7b45fb415
532 files changed
+12733 -9626

No files matched your search

@@ -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-09-18-chat-navigation-performance.md
2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9
2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af
2026-09-18-chat-navigation-performance.md: d82c68098fc422abb181ed4e5e1ca41189cd0951
2026-09-18-chat-navigation-performance.zh.md: 9c2f81a51ce53660aabf2dd1e729d02e8028b7b6
@@ -22,6 +22,8 @@ The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes.
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth.
Shared nested-follow mechanics and control placement are recorded in the [Chat scroll and footer decision](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md); sampled outer reading and historical navigation retain the owners above.
### One publication per historical jump
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy.
@@ -22,6 +22,8 @@ Status: implemented
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。
共用的嵌套跟随机制与控件定位见 [Chat 滚动与页脚决策](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md);外层采样阅读与历史导航仍由上述对象负责。
### 每次历史跳转统一发布
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。
@@ -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-09-21-conversation-build-groups.md
2026-09-21-conversation-build-groups.md: 7c02de8dd0aaf842690806ea67ddaf5d4561fc4e
2026-09-21-conversation-build-groups.zh.md: 4f2b331596cacdd63d902c9db73931d65ac589ad
2026-09-21-conversation-build-groups.md: bccb48b0e40a076bbcb59aaca9e9ef6208e9469d
2026-09-21-conversation-build-groups.zh.md: 5d2ec9e684c75b3db3568c4f046129431295f736
@@ -35,6 +35,7 @@ The grouping foundation provides a separate, Node-input `ConversationGroupDefini
| Record | Relationship |
|---|---|
| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. |
| [Chat scroll and footer](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md) | Owns clipping and independent nested following without changing Group Definition membership. |
Grouping preserves the independent Node-assembly decisions. Cross-View navigation and `toolCallFocus` remain separate responsibilities; Group references carry no View handles or resource-navigation policy.
@@ -106,7 +107,7 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. |
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, group-local subscriptions, nested Node seats. |
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. |
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. |
@@ -35,6 +35,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
| 记录 | 关系 |
|---|---|
| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 |
| [Chat 滚动与页脚](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md) | 负责裁剪与独立的嵌套跟随,不改变 Group Definition 成员关系。 |
分组保留独立的节点组装决策。跨 View 导航与 `toolCallFocus` 仍是独立职责,Group 引用不携带 View 句柄或资源导航策略。
@@ -106,7 +107,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 |
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 |
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 |
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、组内订阅及嵌套 Node 容器。 |
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 |
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
2026-08-17-durable-web-queue-recovery.md: 5fbfdf0e62c1034fbea138016cc2979489de474f
2026-08-17-durable-web-queue-recovery.zh.md: 2aaffd6bc571aab449d9429758fdf45bb4dd8d4c
2026-08-17-durable-web-queue-recovery.md: 0f18cd7a2b9491d6a8bdfa960409d97a5338f95d
2026-08-17-durable-web-queue-recovery.zh.md: 476c1b2fe88156ba8c34e281ff1d1e5663c7913c
@@ -49,3 +49,5 @@ Client coverage pins generic Inbox projection delivery, reconnect invalidation f
Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks.
Clients receive the raw two-list Inbox value and decide which messages their surface presents. The projection state version invalidates cached rows whenever its serialized state or fold semantics change.
The [input echo admission decision](2026-09-22-input-echo-admission-ownership.md) owns local Chat/Dock handoff and suppression of delayed Inbox rows without replacing this recovery model.
@@ -49,3 +49,5 @@ Host 投影覆盖会读取包含待处理输入的已分离持久 Session,在
待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。
客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。
[输入回显入档决策](2026-09-22-input-echo-admission-ownership.zh.md)负责本地 Chat/Dock 接管和迟到 Inbox 行的排除,不替换这里的恢复模型。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md
2026-09-22-chat-scroll-follow-and-footer-geometry.md: 56cd1681eef23162df2595a138545ade25e34212
2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md: 9979688d52373ae7a64a3f4fc22b932b43091b34
@@ -0,0 +1,54 @@
# Agent Note: Separate Chat clipping and independent follow intent
Status: implemented
English | [中文](2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md)
## Problem
Transcript overflow must remain clipped without trapping floating controls or changing the scroll ancestor of sticky content. An `overflow-y: hidden` ancestor becomes the nearest scroll container even when readers scroll a different outer element. It prevents Markdown code banners and expanded compaction headers from sticking to the visible conversation scrollport; floating controls inside that ancestor also use transcript geometry instead of viewport geometry.
A height-capped process group also has a separate reading position. Outer bottom-follow cannot expose newly appended content inside a group whose own scroll offset stays unchanged. Treating every non-bottom scroll event as reader input does not support native smooth following: the browser emits those same events while an animation approaches its target.
## Decision
The transcript, floating controls, and scroll intent retain separate ownership. The [scroll-state table](../../../../packages/client/ui-chat/README.md#scroll-ownership) and [group display rules](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md#display-modes) define the current product behavior.
| Area | Implementation | Reason |
|---|---|---|
| Transcript clipping | `ChatView` uses `overflow-x: visible; overflow-y: clip` on `.root` inside an unclipped frame. | Constrain vertical overflow without introducing a scroll container between sticky content and its actual scrollport. |
| Floating controls | TurnNavigator and the back-to-bottom button are siblings of the clipped transcript. | Shared Conversations use the outer scrollport for sticky positioning; standalone Chat uses its frame for absolute positioning. |
| Follow intent | `useScrollFollow` retains one independent controller per scrollport. | Reuse thresholds, position sampling, and native scrolling without sharing one follow flag. |
| Group lifecycle | `useProcessScroll` owns its existing body/content observer, directional fades, and one-shot opening position. | Group growth remains observable after the body reaches its cap; opening performs no immediate geometry read. |
The clipped root owns no scroll offset. Shared Conversation scrolling, standalone Chat scrolling, capped process groups, and terminal sections retain their existing scrollports. The horizontal axis stays `visible`: pairing vertical `clip` with horizontal `auto` or `hidden` computes the vertical overflow back to `hidden` and restores the unwanted scroll container.
An unclosed group opens at its bottom; a closed group opens at its top. The group’s `closed` value, not the whole Turn’s status or an all-node search, decides that initial position. A later close does not reset an already-open reader. Browser find keeps its own reveal position, and display-mode changes preserve mounted members.
Native smooth growth keeps an outstanding target separate from the current position. Animation progress retains follow intent. Input interrupts native motion before the browser applies reader movement; the resulting actual displacement determines whether following stops or resumes. Further growth retains the issued target until `scrollend`. Arrival at that target, including shrink clamping, allows one request to the latest floor; an off-target stop releases following so focus and find keep their landing. Reduced motion and explicit opening positions use immediate scrolling. Outer following keeps its existing immediate behavior and sampling policy.
The back-to-bottom button depends only on outer follow intent and restores only that intent. Inner scrolling affects the outer controller only when scroll chaining actually moves the outer scrollport. Group height growth can resize the outer transcript, but the controllers do not invoke each other’s follow actions. Paging and semantic restoration remain with the existing outer reading/navigation owners.
## Alternatives considered
**Remove transcript clipping.** That would abandon the overflow constraint. Vertical `clip` retains clipping without creating a scroll container; the unclipped sibling frame keeps floating controls outside the clipped area without a Portal or document-level coordinate synchronization.
**Keep `overflow-y: hidden` and move only floating controls.** That protects the controls but leaves sticky content inside the unwanted scroll container. Changing the root to `clip` also preserves the scroll ancestor of code and compaction headers without moving those headers out of their content.
**Share one follow flag, or duplicate the complete controllers.** One flag would pull a group away from a reader merely because the outer transcript follows. Two complete implementations would duplicate animation attribution and threshold rules. Sharing the small controller retains independent state without moving paging, Turn navigation, or semantic memory into it.
**Only replace `scrollTop` assignment with smooth `scrollTo`.** Intermediate animation offsets would appear to be reader movement and disable following. Native motion needs an explicit target and input interruption; a custom animation-frame interpolator is unnecessary.
## Verification
[Controller tests](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts) cover independent thresholds, animation progress, target reuse, input interruption, reduced motion, and immediate positioning. [Chat component tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover group opening, pause/resume, closing while open, and browser-find preservation. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) retain the outer anchor and attribution behavior.
[Browser scroll cases](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) exercise floating-control geometry, nested following, paging, streaming, and real wheel input through the shipped Web composition.
[Seeded-history cases](../../../../apps/web/tests/seeded-history.e2e.ts) check an expanded compaction header and its nested code banner against the actual conversation scrollport, including their stacking and copy-button access.
## Consequences
The shared controller stores intent without scheduling React renders; callers still publish visible state changes, and group edge fades update only when their booleans change. Follow operations read the affected scrollport, not its Node collection. Native scrolling still performs browser layout and paint work. These changes establish behavior and ownership, not a measured latency or memory improvement.
The [navigation decision](../architecture/2026-09-18-chat-navigation-performance.md) still owns sampled reading, known landings, and batched historical jumps. The [grouping decision](../architecture/2026-09-21-conversation-build-groups.md) still owns Definition-based membership and stable rendering identities. Extracting scroll-follow mechanics does not supersede either decision.
@@ -0,0 +1,54 @@
# Agent Note: 分离 Chat 裁剪与独立滚动跟随意图
Status: implemented
[English](2026-09-22-chat-scroll-follow-and-footer-geometry.md) | 中文
## 问题
正文溢出必须保持裁剪,同时不能限制悬浮控件或改变 sticky 内容的滚动祖先。即使读者实际滚动的是外层元素,`overflow-y: hidden` 祖先也会成为最近的滚动容器。这会阻止 Markdown 代码块头栏和展开的压缩摘要头栏吸附在可见会话滚动区域;位于该祖先内的悬浮控件也会参照正文几何,而非视口几何。
限高过程组也有独立阅读位置。若组内偏移不变,外层跟随底部无法展示组内新增内容。把每个未到底的滚动事件都当成读者输入,也无法支持原生平滑跟随:浏览器在动画接近目标期间同样会发出这些事件。
## 决策
正文、悬浮控件及滚动意图分别由独立对象负责。[滚动状态表](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership)与[分组展示规则](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#display-modes)定义当前产品行为。
| 范围 | 实现 | 原因 |
|---|---|---|
| 正文裁剪 | `ChatView` 在无裁剪的外框内,对 `.root` 使用 `overflow-x: visible; overflow-y: clip`。 | 限制纵向溢出,不在 sticky 内容与其实际滚动区域之间引入滚动容器。 |
| 悬浮控件 | TurnNavigator 与回到底部按钮位于正文裁剪层的同级。 | 共享 Conversation 相对外层滚动容器做 sticky 定位;独立 Chat 相对自身外框做 absolute 定位。 |
| 跟随意图 | `useScrollFollow` 为每个滚动区保留独立控制器。 | 共用阈值、位置采样及原生滚动,不共用一个跟随开关。 |
| 小组生命周期 | `useProcessScroll` 负责既有的正文与内容观察器、方向渐隐和一次性展开位置。 | 正文达到限高后,内容增长仍可被观察;展开时不立即读取几何。 |
被裁剪的根节点不持有滚动偏移。共享 Conversation、独立 Chat、限高过程组和终端区域保留各自原有的滚动容器。横轴保持 `visible`:纵向 `clip` 如果与横向 `auto` 或 `hidden` 组合,纵向溢出的计算值会变回 `hidden`,重新引入不需要的滚动容器。
未关闭的小组展开到自己的底部,已关闭的小组展开到顶部。初始位置由组自身的 `closed` 决定,不取整个 Turn 的状态,也不遍历全部节点。组随后关闭不会重置已展开的阅读位置。浏览器查找保留自身的展开定位,模式切换保留已挂载成员。
原生平滑跟随将未完成的目标与当前位置分开记录。动画进度保留跟随意图。输入在浏览器处理读者移动前中断原生动画,再由实际位移决定是否暂停或恢复跟随。后续增长保留已发出的目标,直到 `scrollend`。到达该目标(包括内容缩短后的钳制位置)才允许再次请求最新底部;停在目标之外则释放跟随,让焦点和查找保留落点。减少动态效果与明确的展开定位使用即时滚动。外层跟随保留既有的即时行为和采样策略。
回到底部按钮只取决于外层跟随意图,也只恢复该意图。只有滚动传递确实移动外层滚动区域时,内层滚动才影响外层控制器。小组长高可以改变外层正文尺寸,但两个控制器不会互相调用跟随操作。分页和语义恢复仍由既有的外层阅读与导航对象负责。
## 考虑过的替代方案
**删除正文裁剪。** 这会放弃溢出约束。纵向 `clip` 保留裁剪而不创建滚动容器;无裁剪的同级外框让悬浮控件位于裁剪区域之外,不需要 Portal 或文档级坐标同步。
**保留 `overflow-y: hidden`,仅移动悬浮控件。** 这可以保护控件,但 sticky 内容仍位于不需要的滚动容器内。将根节点改为 `clip` 也能保留代码和压缩摘要头栏的滚动祖先,无需把这些头栏移出其正文。
**共用一个跟随开关,或复制完整控制器。** 共用开关会仅因外层跟随就把小组拉离读者位置。复制两套完整实现则会重复动画归因和阈值规则。共用小控制器可以保留独立状态,同时不把分页、轮次导航及语义记忆搬入其中。
**只把 `scrollTop` 赋值换成平滑 `scrollTo`。** 动画中间偏移会被误判为读者移动,导致跟随关闭。原生动画需要明确的目标与输入中断处理,无需自写动画帧插值器。
## 验证
[控制器测试](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts)覆盖独立阈值、动画进度、目标复用、输入中断、减少动态效果和即时定位。[Chat 组件测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)覆盖小组展开、暂停与恢复、展开期间关闭及浏览器查找位置保持。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)保留外层锚点与归因行为。
[浏览器滚动用例](../../../../apps/web/tests/chat-scroll-contract.e2e.ts)通过正式 Web 装配验证悬浮控件几何、嵌套跟随、分页、流式内容与真实滚轮输入。
[历史回放用例](../../../../apps/web/tests/seeded-history.e2e.ts)相对实际会话滚动区域检查展开的压缩摘要头栏及其内部代码头栏,包括层叠关系和复制按钮的可访问性。
## 影响
共享控制器只保存意图,不自行调度 React 渲染;调用方仍发布可见状态变化,小组边缘渐隐仅在布尔值变化时更新。跟随操作读取受影响的滚动容器,而非节点集合。原生滚动仍有浏览器布局和绘制成本。这些改动确立行为与职责,不代表已测得延迟或内存收益。
[导航决策](../architecture/2026-09-18-chat-navigation-performance.zh.md)继续负责采样阅读、已知落点与聚合历史跳转。[分组决策](../architecture/2026-09-21-conversation-build-groups.zh.md)继续负责 Definition 成员关系与稳定渲染身份。提取滚动跟随机制不取代这两项决策。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md
2026-09-22-input-echo-admission-ownership.md: 227857bd8168aa1d7f51b621737c2ed0ab572632
2026-09-22-input-echo-admission-ownership.zh.md: 1d64de220a3c6d5c9357770175c79266b8f9f063
@@ -0,0 +1,54 @@
# Agent Note: Input echoes retain display ownership through admission
Status: implemented
English | [中文](2026-09-22-input-echo-admission-ownership.zh.md)
## Problem
An idle submission can appear first as a local Chat echo, then as an Inbox row in QueueDock, and finally as a durable user message. Inbox projection and conversation history arrive independently: retiring the echo on acceptance leaves a gap before admission, while a delayed Inbox projection can duplicate an already-admitted message. Separately, turn/start precedes user/message, so an empty progress title can appear before the echo and exchange positions with it when the real input arrives.
These transitions concern display ownership, not scrolling or composer geometry. A delay cannot identify which representation is authoritative, and moving every echo before the progress title would move running steering ahead of existing work.
## Decision
- A submission keeps its locally selected placement: idle input stays in Chat, running steering stays at the process tail, and explicitly queued input stays in QueueDock. Inbox acceptance does not change that placement.
- Chat hides an echo by matching the durable input's rpcId in the same render, and excludes stale next-step Inbox rows matched by admitted local Chat submissions. QueueDock excludes only Inbox rows matched by local transcript echoes; unrelated queued messages remain visible and actionable.
- Session retains an admitted transcript or steering submission's local suppression identity until the Inbox projection watermark reaches its claim sequence. Higher-sequence projection retention then prevents an older accepted row from reappearing after that identity retires. Claim tracking and cancellation use the existing per-submission receipt for both Inbox targets; no new SessionSnapshot field, Host event, or persisted format is needed.
- At an open, input-free progress control at the end of Chat, the first transcript echo precedes the control. Other echoes remain at the tail. Last-input Turn tracking shares the existing rpcId scan; it does not add a history traversal or predict a sequence of future Turns.
- Pending bubbles and durable seats share one keyed React list. Inserting a progress title preserves the pending bubble's mounted identity. Once admitted, the durable node owns ordering and ordinary Group segmentation.
The [durable Inbox recovery decision](2026-08-17-durable-web-queue-recovery.md) still owns cold reads, reconstruction and projection transport. The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) still owns attachment preparation and draft recovery; retirement returns each submission's durable references once, in attachment order.
## Alternatives considered
**Delay QueueDock or echo removal by a fixed interval.** CPU throttling, transport batching and projection ordering can exceed any such interval. Time does not establish admission or confirm that stale queue state is gone.
**Retain the echo without changing the other readers.** Chat-side Inbox deduplication can still remove the echo, and QueueDock can still display it. The Session lifetime, Chat handoff and Dock exclusion must agree.
**Delete the suppression identity as soon as user/message arrives.** A later Inbox acceptance projection can resurrect a Dock or pending-steering row. The claim watermark supplies the required ordering evidence without assuming the streams publish together.
**Move all pending inputs before the newest Turn control.** Running steering belongs after existing process content, and several ordinary submissions can target different Turns. Only the first local transcript echo receives the empty-control placement adjustment.
**Add a Host correlation event or a client-side Turn scheduler.** Exact prediction across competing clients and reordered requests would widen the change beyond local optimistic presentation. Durable admission remains authoritative; optimistic ordering is deliberately approximate in those races.
## Verification
| Evidence | Covered behavior |
|---|---|
| [Session submissions](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | Projection-first and history-first handoff, claim watermark, overlapping opening/queue/steering identities, attachment callbacks, cancellation, unadmitted endings, reconnect baselines and disposal. |
| [Projection store](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | Missing/cached watermarks, monotonic sequence retention and clearing. |
| [Chat rendering](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | Same-render rpcId replacement, stable echo mounting, title-before-input delivery in all three modes, steering remaining behind the title, historical empty Turns, and six local-versus-Host orderings. |
| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | Local transcript exclusion, unrelated rows/actions, and all six three-message acceptance orders followed by FIFO claims. |
| [Recorded Web replay](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | Real Web composition with FIFO-preserving delivery barriers: both Inbox/history arrival orders at 6× CPU throttling, an explicit turn/start-to-user/message gap, and complete three-Turn handoff for all six request orders. |
| [Steering Web replay](../../../../apps/web/tests/steering.e2e.ts) | Local shortcut steering remains unique when durable admission precedes its Inbox claim projection. |
The single-input regression fails before the fix: Inbox acceptance moves the bubble into Dock, and a separately delivered Turn title precedes the echo. The corrected path retains one Chat representation at a stable vertical position. Multi-input replay verifies durable order and absence of duplicate or residual echoes; it does not assert that optimistic ordering predicts Host order.
## Consequences
- Steering without a locally tracked submission still follows the Inbox projection; a delayed projection can briefly duplicate its durable row. Local deduplication adds no history scan for other clients' submissions.
- Reconnect may discard receipt-confirmed optimistic echoes and display the new authoritative baseline. A claimed input outside that baseline can briefly have no bubble; reconnect does not promise DOM continuity.
- Submissions made before the local running update can all remain transcript echoes. If A, B and C are submitted locally in that order but B or C enters first, durable admission changes their visible order. Even when A enters first, remaining local echoes can differ from the Host queue order.
- Cross-client competition and request-preparation races can change the actual opening input. The client does not migrate an idle-classified echo into Dock merely because the Host queued it behind another Turn.
- The changes prevent representation handoff flicker on the ordinary online path without changing model input, Inbox scheduling, scroll-follow policy, or released Session data. The browser barriers establish behavior, not a general latency or throughput improvement.
@@ -0,0 +1,54 @@
# Agent Note: 输入回显在入档期间保持展示归属
Status: implemented
[English](2026-09-22-input-echo-admission-ownership.md) | 中文
## 问题
空闲时提交的输入可能先显示为 Chat 本地回显,再变为 QueueDock 的 Inbox 行,最后才显示为持久用户消息。Inbox 投影与会话历史独立到达:受理时回收回显会在入档前留下空档,迟到的 Inbox 投影又可能重复显示已经入档的消息。此外,turn/start 先于 user/message,因此空过程标题可能先出现在回显前面,真实输入到达时再与它交换位置。
这些转换涉及展示归属,而非滚动或 composer 几何。延迟不能判断哪种表示已经成为权威,将所有回显移到过程标题前又会把运行中的 steering 移到已有工作之前。
## 决策
- 提交保留本地发送时选择的 placement:空闲输入留在 Chat,运行中的 steering 留在过程末尾,明确排队的输入留在 QueueDock。Inbox 受理不改变展示归属。
- Chat 在同一次渲染中按持久输入的 rpcId 隐藏回显,并排除匹配已入档本地 Chat 提交的旧 next-step Inbox 行。QueueDock 只排除匹配本地 transcript 回显的 Inbox 行;其他排队消息仍可见且可操作。
- Session 保留已入档 transcript 或 steering 提交的本地排除身份,直到 Inbox 投影水位达到其领取序号。随后,较高序号投影优先的规则阻止旧受理行在该身份回收后重新出现。领取跟踪和取消共用已有逐提交 receipt,并覆盖两种 Inbox target;不新增 SessionSnapshot 字段、Host 事件或持久格式。
- Chat 末尾为进行中且没有真实输入的过程控制行时,第一条 transcript 回显显示在控制行前,其他回显留在末尾。最后输入的轮次信息复用现有 rpcId 扫描,不新增历史遍历,也不预测未来轮次序列。
- 待处理气泡与持久节点容器共用一个 keyed React 列表。插入过程标题保留回显气泡的挂载身份。入档后,由持久节点决定排序及普通 Group 切分。
[持久 Inbox 恢复决策](2026-08-17-durable-web-queue-recovery.zh.md)仍负责冷读取、重建与投影传输。[通用文件上传决策](../feature/2026-08-26-generic-file-upload.zh.md)仍负责附件准备及草稿恢复;回收按附件顺序返回各提交的持久引用,且只回调一次。
## 考虑过的替代方案
**固定延迟 QueueDock 或回显回收。** CPU 降速、传输批处理与投影到达顺序都可能超过该间隔。时间不能证明入档,也不能确认过期队列状态已经消失。
**只延长回显生命周期,不调整其他读取方。** Chat 侧按 Inbox 去重仍会移除回显,QueueDock 仍可能展示同一消息。Session 生命周期、Chat 接管和 Dock 排除必须一致。
**user/message 到达后立即删除排除身份。** 较晚的 Inbox 受理投影会复活 Dock 行或待处理 steering 行。领取水位提供必要的排序依据,不要求两条流同时发布。
**把全部待处理输入移到最新 Turn 控制行前。** 运行中的 steering 属于已有过程内容之后,多条普通提交也可能属于不同轮次。只有第一条本地 transcript 回显参与空控制行前的定位调整。
**新增 Host 关联事件或客户端 Turn 调度器。** 精确预测跨客户端竞争和请求乱序会超出本地乐观展示的范围。持久入档仍是权威;这些竞争场景中的乐观排序有意保持近似。
## 验证
| 证据 | 覆盖行为 |
|---|---|
| [Session 提交](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | 投影先到与历史先到的接管、领取水位、开场/排队/steering 身份交错、附件回调、取消、未入档结束、重连基线及销毁。 |
| [投影存储](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | 缺失或缓存水位、单调序号保留及清理。 |
| [Chat 渲染](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | 同帧 rpcId 替换、回显稳定挂载、三种模式下标题先于输入到达、steering 留在标题之后、历史空轮次及六种本地与 Host 排列。 |
| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | 本地 transcript 排除、其他行及操作、三条消息的全部六种受理顺序及后续 FIFO 领取。 |
| [录制 Web 回放](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | 真实 Web 装配配合保留 FIFO 的投递屏障:6× CPU 降速下的 Inbox/历史两种到达顺序、明确的 turn/start 至 user/message 空档,以及六种请求排列的完整三轮接管。 |
| [Steering Web 回放](../../../../apps/web/tests/steering.e2e.ts) | 本地快捷键 steering 的持久入档先于 Inbox 领取投影到达时,仍只显示一份。 |
单条输入回归在修复前失败:Inbox 受理将气泡移入 Dock,单独交付的 Turn 标题又会出现在回显之前。修复后的路径保留唯一的 Chat 表示,且纵向位置稳定。多输入回放验证持久顺序及无重复、无残留回显,不断言乐观排列可以预测 Host 顺序。
## 后果
- 没有本地提交身份的 steering 仍按 Inbox 投影显示;投影迟到时可能短暂与持久节点重复。本地去重不为其他客户端的提交增加历史扫描。
- 重连可撤销已有接收回执的乐观回显,并展示新的权威基线。已领取但不在该基线中的输入可能短暂无气泡;重连不保证 DOM 连续性。
- 本地 running 更新前连续提交的消息可能都留为 transcript 回显。若本地按 A、B、C 提交,但 B 或 C 先入档,持久接管会调整可见顺序。即使 A 先入档,其余本地回显也可能与 Host 队列顺序不同。
- 跨客户端竞争和请求准备的先后差异可能改变实际开场输入。客户端不会仅因 Host 将输入排在另一轮后面,就把已按空闲分类的回显迁入 Dock。
- 这些改动消除普通在线路径中表示切换带来的抖动,不改变模型输入、Inbox 调度、滚动跟随规则或已发布 Session 数据。浏览器屏障证明行为,不代表普遍的延迟或吞吐提升。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba
2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d
2026-08-26-generic-file-upload.md: 196d048da9ffc1342cd62cb9c25674b7cecaffac
2026-08-26-generic-file-upload.zh.md: fce7cef76cf36060f55165d009c568480abb0f16
@@ -52,3 +52,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat
## Related
The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts.
The [input echo admission decision](../bug-fix/2026-09-22-input-echo-admission-ownership.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments.
@@ -52,3 +52,5 @@ Status: implemented
## 相关记录
[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。
[输入回显入档决策](../bug-fix/2026-09-22-input-echo-admission-ownership.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md
2026-09-14-composer-selection-keyboard.md: fb353a7072e7c2245aeeb22af9018edd9a6009f0
2026-09-14-composer-selection-keyboard.zh.md: f51e5ca5096785ae6d059e3c8497292483490437
2026-09-14-composer-selection-keyboard.md: 675e7239108604c51f4e9a82b98e164833ff9173
2026-09-14-composer-selection-keyboard.zh.md: 2b296a609cdd76a407fd4ddcc5855eef2fcfa047
@@ -20,6 +20,8 @@ The model seat's dropdown (`ModelSelect`, whose Effort row opens the reasoning-l
`ModelSelect` keeps `↑`/`↓` as its focus walk over the rows of the shown pane (wrapping; a step taken while the trigger still holds focus enters at the near end). Tab settles: it activates the row the keyboard is on, and with focus still on the trigger it enters the menu at the row of the value in use instead. Escape and Shift+Tab leave a drilled pane first and otherwise close back to the trigger. A pane switch replaces the row that had focus, so each switch names where the keyboard lands: drilling focuses the row of the value in use (its checked row, or the first row when none is marked), and returning to the root pane focuses the cell that opened the pane left. Without that handoff the focus the unmounted row left on the page body sits outside the card's subtree, where its key handling never sees a keystroke.
Mouse presses on the `ModelSelect` trigger or portaled menu buttons prevent the browser’s default focus transfer: WebKit can otherwise blur the focused row and close the menu before `click`. Native `click` still owns selection and cancellation. Opening the menu or closing it through the trigger puts focus on the trigger. Selection requests return focus there before disabling the option rows, so a rejected request leaves keyboard navigation available.
`Menu`, the shared anchored dropdown behind the composer's permission seat, the preset chip, file cards, and the settings rows, carries the same pairing: while its list is open, Tab settles the focused row — from the anchor's button, Tab enters the list instead — and Escape or Shift+Tab close it and return focus to the anchor's first button. Only a keyboard already on the anchor or inside the list is intercepted, so Tab presses elsewhere on the page keep the browser's traversal even while a menu is open. The row's focus fill is the row's own indication: the browser's default ring would double it, and the fill is the same one the row shows under the pointer, so the visual language stays one per state. Selecting a row hands the keyboard back to the anchor unless the owner moved it itself — a card that focuses its own preview keeps it. The arrows, Home, and End walk the list whether or not `autoFocus` is set — that option now decides only whether opening focuses the first row — the row the keyboard is on carries the same fill a hovered row gets, and closing on Escape or Shift+Tab returns focus to the anchor whenever the menu held it.
A dismissed menu stays dismissed: `InputTriggerController` records the identity of the hit the user closed — Escape or Shift+Tab, a pointer dismissal, or a settling pick — and a re-track of that same token with the same query leaves the menu closed, so restoring the caret after a dismissal (or a settled command closing the surface it opened) cannot revive it. A new query or another token re-arms it.
@@ -42,7 +44,7 @@ The row contracts are unchanged. Producers already mark their current row `activ
## Verification
[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives a real Chromium against the shipped bundles: it opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet.
[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives real Chromium and WebKit against the shipped bundles: both verify native mouse selection, cancellation, focus after rejected requests, and keyboard navigation. Each opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet.
## Consequences
@@ -20,6 +20,8 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab
`ModelSelect` 的 `↑`/`↓` 仍是在所显示面板行间移动焦点(循环;焦点仍在触发器上时从近端进入)。Tab 执行接受:它激活键盘所在的那一行;焦点仍在触发器上时,改为进入菜单并把焦点放在正在使用的那一行。Escape 与 Shift+Tab 都先退出已下钻的面板,否则关闭并回到触发器。面板切换会替换持有焦点的那一行,因此每次切换都指明键盘的落点:下钻落在正在使用的值那一行(其选中行;没有标记行时为首行),返回根面板则落在打开该面板的那个格子上。没有这一步,已卸载的行留在页面 body 上的焦点就位于卡片自身子树之外,挂在那里的按键处理永远收不到键盘事件。
在 `ModelSelect` 触发按钮或 portal 菜单按钮上按下鼠标时,阻止浏览器的默认焦点转移:否则 WebKit 可能先让聚焦行失焦,并在 `click` 前关闭菜单。选择和取消仍由原生 `click` 决定。打开菜单或通过触发按钮关闭菜单时,焦点落在触发按钮上。选择请求在禁用选项行前将焦点交回该按钮,因此请求被拒绝后仍可使用键盘导航。
`Menu`——权限档位、预设芯片、文件卡与设置行背后的共享锚定下拉——沿用同一组配对:列表打开期间,Tab 选定聚焦行(键盘在锚点按钮上时,Tab 改为进入列表),Escape 或 Shift+Tab 关闭并把焦点还给锚点的第一个按钮。只拦截已经位于锚点或列表内的键盘,因此页面其它位置按下的 Tab 即使在菜单打开时也仍归浏览器。行的焦点填充就是行自己的焦点提示:浏览器默认轮廓会与之叠加,而这一填充与指针悬停时用的是同一个,视觉语言因此一态一义。选定一行会把键盘还给锚点——除非拥有者自己移动了焦点(例如文件卡把自己的预览按钮设为焦点)。`↑`/`↓` 与 Home/End 无论是否设置 `autoFocus` 都在列表中走位——该选项如今只决定打开时是否聚焦首行——键盘所在的那一行使用与悬停行相同的填充(否则键盘走位看不出当前行),而 Escape 或 Shift+Tab 关闭时,只要键盘原本在菜单里就把焦点归还锚点。
被关闭的菜单保持关闭:`InputTriggerController` 记下用户关闭的那个命中(Escape/Shift+Tab、指针关闭或一次落定选定)的身份,同一 token 以同一查询重新 track 时菜单保持关闭,因此关闭后恢复光标、或已选定命令关闭它打开的界面,都无法把它召回来;新的查询或另一个 token 才会重新武装。
@@ -42,7 +44,7 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab
## 验证
[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 驱动已发布的 bundle:打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。
[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 与 WebKit 驱动已发布的 bundle:两者都验证原生鼠标选择、取消、请求被拒绝后的焦点以及键盘导航。每种引擎都会打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。
## 影响
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d
2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee
2026-07-26-ci-failover-runbook.md: d46f9d140860580011e06aa3b51bb8618a23a114
2026-07-26-ci-failover-runbook.zh.md: a229d46f16d959356e9108842edd557420e699ae
@@ -20,7 +20,7 @@ The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) gove
### What the in-house pool is
`vm-backup`: one shared VM with multiple always-on systemd-managed runner instances. Registrations share its CPU, memory, and disk; their count is not a count of independent machines. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.
`vm-backup`: one shared VM with multiple always-on systemd-managed runner instances. Registrations share its CPU, memory, and disk; their count is not a count of independent machines. Its image must preinstall the Linux system packages for Playwright Chromium and WebKit; CI downloads the lockfile-selected browsers but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.
#### Windows pool
@@ -20,7 +20,7 @@ Status: implemented
### 自有池是什么
`vm-backup`:一台共享虚拟机,运行多个常驻 systemd 管理的运行器实例。注册实例共享 CPU、内存和磁盘;实例数量不代表独立机器数量。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。
`vm-backup`:一台共享虚拟机,运行多个常驻 systemd 管理的运行器实例。注册实例共享 CPU、内存和磁盘;实例数量不代表独立机器数量。其镜像必须预装 Playwright Chromium 和 WebKit 所需的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。
#### Windows 池
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
2026-08-10-npm-release-sequences.md: 6f52c00a31870939981994965b70504e8f8143ca
2026-08-10-npm-release-sequences.zh.md: effa5538cefbc8f25b2e047ee40e0dc7306bb2fb
2026-08-10-npm-release-sequences.md: 6a4f9d1ec23d1fd1c7b74630e106974b680fe41a
2026-08-10-npm-release-sequences.zh.md: d33d48e4458a62951aa957e694dd70eedcf2a6c8
@@ -78,9 +78,9 @@ Two registry behaviours shape how a publish is attempted. Writes are spaced by a
### Workspace-internal references use the `workspace:` protocol
Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substitutes a range matching the target version: sibling `peerDependencies` follow the family version, and a reference to a vendored package follows that package's own line. The Landlock platform packages keep `workspace:*`, which publishes the exact version, because a platform package and its entry must agree exactly.
Every reference to a workspace member uses the `workspace:` protocol. The [release-range policy](2026-09-22-workspace-release-ranges.md) requires exact `workspace:*` DSH references and `workspace:~` vendor/native references in every dependency section and consumer, including the native entry's optional platform packages. Local workspace linking is unchanged.
`scripts/check-workspace-constraints.ts` requires the protocol, so a new package cannot reintroduce a hand-written range; the invariant-companion rule requires `workspace:^` for `@deepseek-ai/dsh-invariants` for the same reason.
`scripts/check-workspace-constraints.ts` reads every member declared in `pnpm-workspace.yaml` plus the root manifest and enforces ranges by dependency target, not consumer directory. The invariant-companion rule requires `workspace:*` for `@deepseek-ai/dsh-invariants`; the dependency repairer preserves vendor/native tilde ranges. Published DSH peers therefore require the matching release instead of admitting later compatible versions.
### Published dependency faces use an explicit policy
@@ -133,7 +133,7 @@ The installed-consumer probe captures npm's HTTP diagnostics and includes them w
|---|---|
| release-set manifests | `private: true` removed; `publishConfig.access` per sequence and `repository` with each package's `directory` added |
| release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*` |
| dependency protocol | workspace-internal references are `workspace:^`, with `check-workspace-constraints.ts` and the invariant-companion rule requiring it |
| dependency protocol | every workspace consumer uses `workspace:*` for DSH targets and `workspace:~` for vendor/native targets |
| root `AGENTS.md` | the convention that vendored packages are `private: true` no longer holds |
| `vendor/README.md` | records `src` joining `cordis`'s `files` as a local modification |
| the three native packages | `publishConfig.access: public`, and their workflow passes no `--access` |
@@ -78,9 +78,9 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
### workspace 内部引用走 `workspace:` 协议
所有指向 workspace 成员的引用都用 `workspace:^`,由 `pnpm pack` 替换成匹配目标版本的范围:兄弟包的 `peerDependencies` 跟随族版本,指向 vendored 包的引用跟随那个包自己的版本线。Landlock 平台包保留 `workspace:*`(发布成精确版本),因为平台包与它的入口必须版本完全一致。
所有指向 workspace 成员的引用都用 `workspace:` 协议。[发布范围策略](2026-09-22-workspace-release-ranges.zh.md)要求每个消费者的所有依赖区段对 DSH 使用精确的 `workspace:*` 引用,对 vendor/native 使用 `workspace:~` 引用,包括原生入口的可选平台包。本地 workspace 链接方式不变。
`scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。
`scripts/check-workspace-constraints.ts` 读取 `pnpm-workspace.yaml` 声明的全部成员及根清单,按依赖目标而非消费者目录校验范围。Invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 使用 `workspace:*`;依赖修复器保留 vendor/native tilde 范围。因此,发布的 DSH peer 要求匹配的发布版本,而不接纳后续兼容版本。
### 发布依赖门面使用显式策略
@@ -133,7 +133,7 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven
|---|---|
| 发布集 manifest | 去掉 `private: true`;按序列补 `publishConfig.access` 与带各自 `directory` 的 `repository` |
| 发布集边界 | `packages/*/*`、`apps/*`、`vendor/*` 的全部成员 |
| 依赖协议 | workspace 内部引用为 `workspace:^`,由 `check-workspace-constraints.ts` 与 invariant companion 规则强制 |
| 依赖协议 | 每个 workspace 消费者对 DSH 目标使用 `workspace:*`,对 vendor/native 目标使用 `workspace:~` |
| 根 `AGENTS.md` | 「vendored 包是 `private: true`」这条约定不再成立 |
| `vendor/README.md` | 记录「`src` 加入 `cordis` 的 `files`」这条本地修改 |
| native 三包 | `publishConfig.access: public`,且其 workflow 不传 `--access` |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
2026-08-26-published-dependency-faces.md: 1a94ccedcf6f614c7853c96c329e88d037167460
2026-08-26-published-dependency-faces.zh.md: 4ac3f461018c21047af6359ca5e27d050da5a2c4
2026-08-26-published-dependency-faces.md: f188ccc0fc91b2899ccb618d3000a9b3cb94eb59
2026-08-26-published-dependency-faces.zh.md: d59bb724ceb15f6eae65b509f8437a5dcfa05a23
@@ -28,7 +28,7 @@ A workspace package reached by a runtime value import from the Host entry closur
An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each export-table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports covered by neither the package list nor an export table; namespace, dynamic, and side-effect imports remain unbounded unless the complete exact entry is package-classified.
Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. Workspace references use `workspace:^`.
Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. [Release ranges](2026-09-22-workspace-release-ranges.md) require `workspace:*` for DSH and `workspace:~` for vendor/native references.
Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`.
@@ -28,7 +28,7 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只
constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。每个导出表的 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝既没有包级分类、也没有导出级分类的导出;除非完整的精确入口已按包分类,否则 namespace、dynamic 和 side-effect import 仍无法限定范围。
Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。Workspace 引用使用 `workspace:^`。
Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。[发布范围](2026-09-22-workspace-release-ranges.zh.md)要求 DSH 引用使用 `workspace:*`,vendor/native 引用使用 `workspace:~`。
部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-22-workspace-release-ranges.md
2026-09-22-workspace-release-ranges.md: 57c74df3eaeac16ba40f55cdc4069762ae8fe2ef
2026-09-22-workspace-release-ranges.zh.md: f39ede4fb1941956af12d0391f5b88bd244db6f8
@@ -0,0 +1,27 @@
# Agent Note: Workspace dependency release ranges
Status: implemented
English | [中文](2026-09-22-workspace-release-ranges.zh.md)
## Problem
DSH packages share one product release. Cordis, its vendored libraries, and Node Addon System have independent releases; consumers need their patch updates without automatically accepting a new minor version.
## Decision
Every workspace consumer uses `workspace:*` for DSH targets and `workspace:~` for targets under `vendor/` or in the `native/system` package family. This includes root tooling, apps, peers, development dependencies, and the native entry's optional platform dependencies. Directory placement never exempts a consumer.
Packing substitutes the target package's current version: `workspace:~` becomes `~4.0.3` for Cordis at `4.0.3` and `~0.1.2` for Node Addon System at `0.1.2`. DSH references remain exact. Native family members still share one release version, while an installed entry may resolve a later platform patch.
This range policy refines [npm release sequences](2026-08-10-npm-release-sequences.md) and [published dependency faces](2026-08-26-published-dependency-faces.md). Their independent release families and dependency-section classification remain unchanged.
## Alternatives considered
**Caret vendor ranges.** For stable packages such as Cordis, caret ranges also admit later minor versions.
**Exact vendor and native ranges.** Exact ranges require a consumer declaration change to admit each patch release.
## Consequences
Vendor and native patch releases must preserve their consumer-facing APIs and binary interfaces. Local workspace linking and lockfile resolutions remain unchanged by the range conversion. The workspace gate rejects DSH tilde ranges and vendor/native caret or exact ranges; packed-manifest tests check the emitted versions.
@@ -0,0 +1,27 @@
# Agent Note: Workspace 依赖发布范围
Status: implemented
[English](2026-09-22-workspace-release-ranges.md) | 中文
## Problem
DSH 包共享同一次产品发布。Cordis、其 vendored 库和 Node Addon System 独立发布;消费者需要接收它们的补丁更新,而不自动接纳新的次版本。
## Decision
每个 workspace 消费者对 DSH 目标使用 `workspace:*`,对 `vendor/` 下或 `native/system` 包族中的目标使用 `workspace:~`。规则覆盖根目录工具、应用、peer、开发依赖以及原生入口的可选平台依赖。消费者不因目录位置而获得豁免。
打包时替换为目标包的当前版本:Cordis 为 `4.0.3` 时,`workspace:~` 转为 `~4.0.3`;Node Addon System 为 `0.1.2` 时转为 `~0.1.2`。DSH 引用仍为精确版本。原生包族的成员仍以同一版本发布,但已安装的入口可以解析到后续的平台包补丁版本。
这套范围策略细化了 [npm 发布序列](2026-08-10-npm-release-sequences.zh.md)和[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)。它们的独立发布族和依赖区段分类保持不变。
## Alternatives considered
**Vendor 使用 caret 范围。** 对 Cordis 这类稳定版本的包,caret 范围也会接纳后续次版本。
**Vendor 和 native 使用精确范围。** 每次接纳补丁发布都需要修改消费者的依赖声明。
## Consequences
Vendor 和 native 的补丁发布必须保持面向消费者的 API 和二进制接口兼容。范围转换不改变本地 workspace 链接及锁文件解析结果。Workspace 门禁拒绝 DSH 的 tilde 范围,以及 vendor/native 的 caret 或精确范围;打包清单测试校验最终输出的版本。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md
2026-09-09-nontransactional-loader.md: 6a3cb1476e1f296199067a8d048c6d7c8bba4069
2026-09-09-nontransactional-loader.zh.md: 4e91a31ed86be4e45313e1322db8af486477aea7
2026-09-09-nontransactional-loader.md: 98b0b1ce0c1250e651c5280c9b918948b17cd963
2026-09-09-nontransactional-loader.zh.md: 77ae1ce91aa0d20940f0c63f2a2d42a0f3c07edb
@@ -18,7 +18,7 @@ Application consumers own their completion checks. The CLI waits for its fallbac
Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure.
Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:^` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary.
Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary.
## Alternatives considered
@@ -18,7 +18,7 @@ Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录
Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。
保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:^` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。
保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。
## 考虑过的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
2026-07-24-web-gui-browser-e2e-lane.md: b08d99a7d750bdeabac3dcbb7007e922d32dbdcd
2026-07-24-web-gui-browser-e2e-lane.zh.md: fbb92a094bbd14042d33e5ebd20d2d3ffd490d43
2026-07-24-web-gui-browser-e2e-lane.md: eb79591e7b94fccb8eb92aec4024738825d214fc
2026-07-24-web-gui-browser-e2e-lane.zh.md: f715f4ca93b202f72e632e5ddae0d13559a927a7
@@ -10,7 +10,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin
## Decision
`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replay through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, with normalized aria goldens for user-visible states and in-process assertions for durable world state. The supporting product contracts are `dsh-llm-replay` pacing, consumption checks, and validated indexed override patches; cross-package `dsh-llm` failures retain validated provider facts through own data properties; and the shipped web composition mounts `llm-retry` for transient model failures.
`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replay through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, with normalized aria goldens for user-visible states and in-process assertions for durable world state. Chromium runs the full lane; the [model and reasoning picker scenario](../../../../apps/web/tests/declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. The supporting product contracts are `dsh-llm-replay` pacing, consumption checks, and validated indexed override patches; cross-package `dsh-llm` failures retain validated provider facts through own data properties; and the shipped web composition mounts `llm-retry` for transient model failures.
### Scaffold: `apps/web/tests/scaffold.ts`
@@ -32,7 +32,7 @@ No single-shot transient-DOM assertions: every hop from replay yield to React co
Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one browser per file and engine, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
### Expected outputs
@@ -50,7 +50,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e
### CI stance
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium and WebKit, restores their OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases.
@@ -95,4 +95,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
## Consequences
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable, threshold-free diagnostic workload whose measurements require manual interpretation. The separate required [frontend performance benchmarks](2026-09-06-frontend-performance-budgets.md) enforce calibrated budgets in the isolated benchmark CI job; they do not add thresholds to the manual inventory.
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium and WebKit provisioning and the browser lane so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable, threshold-free diagnostic workload whose measurements require manual interpretation. The separate required [frontend performance benchmarks](2026-09-06-frontend-performance-budgets.md) enforce calibrated budgets in the isolated benchmark CI job; they do not add thresholds to the manual inventory.
@@ -10,9 +10,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
## 决策
布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。Chromium 运行整个车道;[模型与推理强度选择场景](../../../../apps/web/tests/declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
### Scaffold:`apps/web/tests/scaffold.ts`
@@ -28,11 +26,13 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);禁止轮询持久化文件来充当轮次完成或持久性屏障(NFS 上慢,且被 `whenIdle` 取代),但工具控制的临时就绪标记可以仅作为该完成屏障之前的交互门控进行轮询;`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。导航断言会在页面加载前同时监听 `session.list` 和 `workspace.list` 的初始响应,随后等待播种数据投影到 DOM;仅凭 shell 已挂载不能判定就绪,因为较晚完成的 bootstrap 可能替换受控状态。
布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。
分页驱动会等待交互式加载行退出 pending 状态,并在滚动前记录请求前的行数。因此,即时提交的常驻页仍然可观测,不会变成一次滚动手势不会重复触发的请求基线。
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每个文件中的每种引擎各启动一个浏览器、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
### 预期输出
@@ -50,7 +50,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
### CI 立场
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.zh.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.zh.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium 和 WebKit,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。
高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold:工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态,然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC,并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定,而不会因压缩(compaction)消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状,但时间仍不设阈值,因为机器速度不属于正确性约定。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts` 和 `*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。
@@ -95,4 +95,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
## 后果
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留可重复、无阈值的诊断工作负载,其测量需要人工解读。独立的必需[前端性能基准](2026-09-06-frontend-performance-budgets.zh.md)在隔离的基准 CI job 中执行经校准的预算;它们不向手动清单添加阈值。
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 与 WebKit 供给及浏览器车道的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留可重复、无阈值的诊断工作负载,其测量需要人工解读。独立的必需[前端性能基准](2026-09-06-frontend-performance-budgets.zh.md)在隔离的基准 CI job 中执行经校准的预算;它们不向手动清单添加阈值。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md
2026-07-30-web-browser-snapshot-ci-gate.md: d1c8dcdd859d0515804cc6aed0d2f3341beb6dd0
2026-07-30-web-browser-snapshot-ci-gate.zh.md: 9d642819e09abc63384f4f4eb105d13261a9eb95
2026-07-30-web-browser-snapshot-ci-gate.md: 352072bca6e1a9e1d5b72973d2c3c9e96bc58ad9
2026-07-30-web-browser-snapshot-ci-gate.zh.md: cd04f33a5db6ddb3c88a235fb34ea31da0e5a869
@@ -12,13 +12,13 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs
For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing.
The consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions.
The consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. Chromium runs the full suite; the model and reasoning picker also runs in WebKit to cover native mouse focus behavior. On hosted runners, CI installs both engines and their system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns both engines’ Linux system packages and CI installs only the browser binaries, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions.
Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
CI’s `scripts/run-web-snapshots.ts` runs `hmr-live.e2e.ts` separately because it mutates built workspace state. One six-worker Vitest pool then runs every remaining file, including historical Cordis-card rendering. Every child inherits stdio, and `run-gates` streams its output.
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision both engines. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
Completed local replays measured the six-worker browser command at about 65–71 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 15–20 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule.
@@ -36,4 +36,4 @@ Completed local replays measured the six-worker browser command at about 65–71
## Consequences
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium and WebKit provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright or browser upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
@@ -12,13 +12,13 @@ Status: implemented
Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts` 把 `test:web:ci` 登记为 `ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。
消费方 job 在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。
消费方 job 在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。Chromium 运行完整套件;模型与推理强度选择场景还在 WebKit 中运行,以覆盖原生鼠标焦点行为。在托管运行器上,CI 按锁文件中的 Playwright 版本安装这两种引擎及其系统依赖。在持久化故障切换 VM 上,镜像负责预装两种引擎的 Linux 系统软件包,CI 只安装浏览器程序,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。
本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。
CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-live.e2e.ts`。随后由一个六 worker Vitest 池运行其余文件,包括历史 Cordis 卡片渲染。所有子进程继承 stdio,由 `run-gates` 流式转发输出。
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不会同时安装这两种引擎。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
完整本地 replay 中,6-worker 浏览器命令耗时约 65–71 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。
@@ -36,4 +36,4 @@ CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-
## 后果
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致;漏刷会在改变该组装的同一个 PR 中失败。成本是消费方 job 需要安装 Chromium、串行运行 2 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致;漏刷会在改变该组装的同一个 PR 中失败。成本是消费方 job 需要安装 Chromium 和 WebKit、串行运行 1 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright 或浏览器升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
+62
View File
@@ -0,0 +1,62 @@
---
name: dsh-client-ui-ux
description: Design and review DeepSeek Harness client UI changes — visual token discipline, reuse-before-adding, feedback surfaces (toast vs in-place notice vs empty state), overlay and menu safety, platform window adaptation, loading states, and error copy placement. Use when adding or changing product-user-visible GUI behavior in packages/client, or when reviewing such a PR.
---
# DeepSeek Harness Client UI/UX
This skill is guidance, not a complete checklist. It covers judgment calls that lint, typecheck, and the i18n gate cannot make: where feedback appears, what gets reused, and how surfaces behave at window edges and across platforms. Copy quality itself follows [dsh-prose-standard](../dsh-prose-standard/SKILL.md); locale ownership follows the [locale-owned copy decision](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md); styling ownership and token rules live in [docs/web-styling.md](../../../docs/web-styling.md) and the [ui-primitives component catalog](../../../packages/client/ui-primitives/README.md#component-catalog) — this skill adds judgment on top of those rules, not a second copy of them.
## Visual foundation
- **Use existing tokens for color, radius, and spacing.** Before introducing a literal value, search the target page and its nearest established sibling (the Plugins page is the usual reference for full-page surfaces) for the token or value already in use, and adopt it. The same applies to whole treatments — toasts, menus, in-message artifact previews, sidebar tab layout, colors, clearance rules, page padding: reuse the established component or rule wherever one exists instead of inventing a parallel one.
- **Verify every color in both light and dark mode.** An ink/background pair must stay readable under both themes before it ships. Dimmed tones are the standing counter-example: they suit decorative surfaces, but on text they routinely fall below readability in one theme — never put a dimmed color on text without checking both.
- **Font weight tops out at 500 in feature CSS you add or change.** Do not use 600+ for emphasis; differentiate with size or ink instead. The markdown typography tokens owned by ui-theme are the standing exception.
- **Pick font sizes from the theme typography variables when an existing role matches, otherwise from the page's existing scale.** Parallel content shares one size; never invent a size for a single element, and keep the number of distinct sizes on a page small (13px is the established row/detail size). When the page itself has no precedent, borrow the size and spacing ratios of the most similar existing page or element.
- **Icons, text, and neighbouring elements stay proportionate and aligned.** Every element lines up with its siblings along a deliberate axis (baseline, vertical center, or shared edge), and an icon's size stays in proportion to the text beside it; never ship an element that aligns to nothing or visibly outweighs its neighbours.
## Reuse before adding
- **Extend an existing component, container, or interaction before creating a new one.** A new capability that fits an existing surface (an extra menu item, a new prop on an existing primitive) beats a parallel element stacked beside it.
- **Icons come from the existing icon library.** Pick the semantically closest existing icon; a new glyph requires designer approval before it lands — when reviewing a PR that adds one, suggest requesting a designer review.
- **Right-sidebar content registers a slot in the existing sidebar**, never a separate sidebar. Every sidebar tab declares a tab icon matching its meaning.
- **Icon-only actions whose meaning is not obvious get a Tooltip.** Informational content the pointer must rest on or select uses HoverCard instead. Prefer these shared primitives over ad-hoc title attributes.
## Feedback surfaces
Choose the surface by the lifetime of the message relative to the surface that produced it:
- **Transient operation outcomes use the app-wide Toast primitive**, not in-place notices, and the toast's state and rendering live in a host that outlives the reporting surface (a `shell.overlay` entry, as RowActionToast does) — a toast rendered by the panel itself unmounts with that panel. A deletion can close the panel or tab that requested it; only a toast held outside that surface survives the close. Report success and failure; do not report still-pending states.
- **A failed operation keeps the data visible.** A failed deletion keeps its row; the toast announces the failure. Never blank content to show an error for a transient operation.
- **In-place notices are for states tied to the surface itself**: a query failure with its Retry action, field validation on the form that owns the field. A populated panel keeps a compact notice beside its retained content; only an empty panel centers a full error state.
- **Error copy is plain language, and short.** No internal jargon or technical nouns unless an error code must be exposed for debugging. Prefer one clear sentence; anything explainable in two sentences must not grow into paragraphs. A Chinese notice of at most two sentences omits the trailing 句号(。). The notice must not break the existing layout — reserve its space or overlay it; never shift neighbouring elements.
## Loading states
- Lists use their skeleton; every other page-level load centers a bare spinner. Never place a spinner in a corner of the page.
- One loading treatment per page/component — concurrent regions must not each flash their own style.
- No loading text unless the context needs it (narrow sidebars almost never do).
## Overlays and menus
Every menu, popover, and tooltip must be verified for all three before merge:
- **Dismissable**: outside click (and Escape where focusable) closes it.
- **Viewport-fitting**: it slides or flips to stay inside the window with an edge margin, and flips only into a side that actually fits (no oscillation when neither side fits).
- **Unclipped**: it escapes `overflow` ancestors and containing blocks (portal to body where needed) and is never covered by neighbouring elements.
Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge.
## Platform window adaptation
- Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec.
- State the intended total offset (clearance + page inset) when reviewing, so two pages aligning to "the same top spacing" agree on the sum, not just one addend.
## Spacing review
- Review every changed region for gaps: nothing sits flush against its neighbour without an intentional gap, and adjacent icons and elements keep the region's established spacing.
- Prefer symmetry for parallel elements; an unexplained one-off gap or offset usually signals a missed shared value.
## Design review escalation
- A PR with extensive UI changes, or any clearly user-perceivable change to UI or interaction, must get a designer or product review. When no approver holds that role, suggest requesting one from the design/product reviewers (roster as of 2026-09: yx zhang, yifffan, zhangziya, gaokaige; update this list here when membership changes).
+1
View File
@@ -14,6 +14,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
- [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline.
- [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings.
- [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures.
- [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes.
- [docs/testing.md](../../../docs/testing.md) and the [quality-gates Agent Note](../../notes/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates.
- [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto.
- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation.
+2 -2
View File
@@ -165,8 +165,8 @@ jobs:
# The persistent VM image owns Playwright's Linux system packages; this
# step also proves that browser provisioning remains usable for failover.
- name: Install Playwright Chromium
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
- name: Install Playwright Chromium and WebKit
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium webkit
- name: Prepare bubblewrap (unrestrict userns)
run: bash scripts/prepare-ci-bubblewrap.sh
+4 -4
View File
@@ -344,18 +344,18 @@ jobs:
r'https://\1/', source.read_text()))
PY
- name: Install Playwright Chromium and hosted dependencies
- name: Install Playwright Chromium, WebKit, and hosted dependencies
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
# Ephemeral runner images can carry Ubuntu mirrors whose HTTP endpoints are unreachable.
run: |
sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) -exec sed -i -E 's@http://([a-z]{2}\.)?archive\.ubuntu\.com/ubuntu@https://archive.ubuntu.com/ubuntu@g; s@http://security\.ubuntu\.com/ubuntu@https://security.ubuntu.com/ubuntu@g' {} +
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit
# The persistent VM image owns Playwright's Linux system packages; do
# not mutate the shared host with apt on every failover run.
- name: Install Playwright Chromium on the failover VM
- name: Install Playwright Chromium and WebKit on the failover VM
if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]'
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium webkit
- name: Build and execute the Linux Office runtime
run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads"
+1 -1
View File
@@ -126,7 +126,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
## Conventions
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package.
- Packages use `@deepseek-ai/dsh-<name>`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-09-22-workspace-release-ranges.md)).
- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
- **Runtime invariants assert owned relationships.** Publish `./invariant` only when independent observations can diverge. Otherwise omit its source and wiring and record why in its README; empty installers and checks of service presence, plugin metadata, effects, or fixed examples are invalid ([package invariant rules](packages/AGENTS.md)).
+116 -116
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.1.7-alpha.1",
"version": "0.1.7-alpha.2",
"publishConfig": {
"access": "public"
},
@@ -20,127 +20,127 @@
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-hmr": "workspace:^",
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/cordis-plugin-timer": "workspace:^",
"@deepseek-ai/dsh-acp-app": "workspace:^",
"@deepseek-ai/dsh-agent-instructions": "workspace:^",
"@deepseek-ai/dsh-agent-tool-presentation": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-base": "workspace:^",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-compaction-basic": "workspace:^",
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
"@deepseek-ai/dsh-headless": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-hooks-claude-code": "workspace:^",
"@deepseek-ai/dsh-hooks-codex": "workspace:^",
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-persona": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-schedule": "workspace:^",
"@deepseek-ai/dsh-sdk-app": "workspace:^",
"@deepseek-ai/dsh-sdk-minimal": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-filesystem": "workspace:^",
"@deepseek-ai/dsh-skill-office": "workspace:^",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
"@deepseek-ai/dsh-time-context": "workspace:^",
"@deepseek-ai/dsh-tmux-context": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-present": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
"@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-web-app": "workspace:^",
"@deepseek-ai/dsh-webhook": "workspace:^",
"@deepseek-ai/dsh-webhook-github": "workspace:^",
"@deepseek-ai/dsh-workflow-ptc": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-hmr": "workspace:*",
"@deepseek-ai/cordis-plugin-include": "workspace:~",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
"@deepseek-ai/dsh-acp-app": "workspace:*",
"@deepseek-ai/dsh-agent-instructions": "workspace:*",
"@deepseek-ai/dsh-agent-tool-presentation": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-base": "workspace:*",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:*",
"@deepseek-ai/dsh-client-ui-cordis": "workspace:*",
"@deepseek-ai/dsh-cmdline": "workspace:*",
"@deepseek-ai/dsh-command-compact": "workspace:*",
"@deepseek-ai/dsh-command-goal": "workspace:*",
"@deepseek-ai/dsh-compaction-basic": "workspace:*",
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:*",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-goal": "workspace:*",
"@deepseek-ai/dsh-goal-round-driver": "workspace:*",
"@deepseek-ai/dsh-headless": "workspace:*",
"@deepseek-ai/dsh-home-paths": "workspace:*",
"@deepseek-ai/dsh-hooks-claude-code": "workspace:*",
"@deepseek-ai/dsh-hooks-codex": "workspace:*",
"@deepseek-ai/dsh-jobs-local": "workspace:*",
"@deepseek-ai/dsh-launch-environment": "workspace:*",
"@deepseek-ai/dsh-mcp-client": "workspace:*",
"@deepseek-ai/dsh-persona": "workspace:*",
"@deepseek-ai/dsh-plan-mode": "workspace:*",
"@deepseek-ai/dsh-pwsh-local": "workspace:*",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:*",
"@deepseek-ai/dsh-schedule": "workspace:*",
"@deepseek-ai/dsh-sdk-app": "workspace:*",
"@deepseek-ai/dsh-sdk-minimal": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-session-reference": "workspace:*",
"@deepseek-ai/dsh-skill": "workspace:*",
"@deepseek-ai/dsh-skill-filesystem": "workspace:*",
"@deepseek-ai/dsh-skill-office": "workspace:*",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
"@deepseek-ai/dsh-terminal": "workspace:*",
"@deepseek-ai/dsh-terminal-bash": "workspace:*",
"@deepseek-ai/dsh-time-context": "workspace:*",
"@deepseek-ai/dsh-tmux-context": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
"@deepseek-ai/dsh-tool-bash": "workspace:*",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:*",
"@deepseek-ai/dsh-tool-cordis": "workspace:*",
"@deepseek-ai/dsh-tool-present": "workspace:*",
"@deepseek-ai/dsh-tool-fs": "workspace:*",
"@deepseek-ai/dsh-tool-fs-search": "workspace:*",
"@deepseek-ai/dsh-tool-goal": "workspace:*",
"@deepseek-ai/dsh-tool-jobs": "workspace:*",
"@deepseek-ai/dsh-tool-pwsh": "workspace:*",
"@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*",
"@deepseek-ai/dsh-tool-ralph": "workspace:*",
"@deepseek-ai/dsh-tool-skill": "workspace:*",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*",
"@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:*",
"@deepseek-ai/dsh-tool-todo": "workspace:*",
"@deepseek-ai/dsh-tool-web": "workspace:*",
"@deepseek-ai/dsh-tool-workflow": "workspace:*",
"@deepseek-ai/dsh-web-app": "workspace:*",
"@deepseek-ai/dsh-webhook": "workspace:*",
"@deepseek-ai/dsh-webhook-github": "workspace:*",
"@deepseek-ai/dsh-workflow-ptc": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"commander": "^15.0.0",
"js-yaml": "^4.2.0",
"node-addon-require-builtin": "^0.1.6",
"@deepseek-ai/dsh-http-proxy": "workspace:^",
"@deepseek-ai/dsh-mcp-resources": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-agent-preset": "workspace:^",
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^"
"@deepseek-ai/dsh-http-proxy": "workspace:*",
"@deepseek-ai/dsh-mcp-resources": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-agent-preset": "workspace:*",
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*"
},
"devDependencies": {
"@agentclientprotocol/sdk": "1.4.0",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
"@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:^",
"@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^",
"@deepseek-ai/dsh-fs-observation-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-host-frontend-static": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-replay": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
"@deepseek-ai/dsh-attachment-local": "workspace:*",
"@deepseek-ai/dsh-bash-local": "workspace:*",
"@deepseek-ai/dsh-credentials-local": "workspace:*",
"@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*",
"@deepseek-ai/dsh-experimental-agent-team": "workspace:*",
"@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:*",
"@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:*",
"@deepseek-ai/dsh-fs-observation-policy": "workspace:*",
"@deepseek-ai/dsh-fs-sandbox": "workspace:*",
"@deepseek-ai/dsh-host-frontend-static": "workspace:*",
"@deepseek-ai/dsh-host-webserver": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-llm-deepseek": "workspace:*",
"@deepseek-ai/dsh-llm-mock-server": "workspace:*",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:*",
"@deepseek-ai/dsh-llm-replay": "workspace:*",
"@deepseek-ai/dsh-loader-smoke": "workspace:*",
"@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
"@deepseek-ai/dsh-session-log-deepseek": "workspace:*",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-shell-env": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/ws": "8.18.1",
"ajv": "^8.20.0",
+12 -12
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.1.7-alpha.1",
"version": "0.1.7-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -10,16 +10,16 @@
"lib/index.js"
],
"dependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^",
"@deepseek-ai/dsh-skill-office": "workspace:^",
"@deepseek-ai/dsh-deepseek-account": "workspace:^"
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-host-webserver": "workspace:*",
"@deepseek-ai/dsh-home-paths": "workspace:*",
"@deepseek-ai/dsh-jobs": "workspace:*",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*",
"@deepseek-ai/dsh-skill-office": "workspace:*",
"@deepseek-ai/dsh-deepseek-account": "workspace:*"
}
}
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.1.7-alpha.1",
"version": "0.1.7-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -39,15 +39,15 @@
"electron-updater": "^6.8.9",
"semver": "^7.8.5",
"ws": "^8.21.0",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/dsh-api-gateway": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/node-addon-system": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:*",
"@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*",
"@deepseek-ai/dsh-home-paths": "workspace:*",
"@deepseek-ai/node-addon-system": "workspace:~",
"@electron/get": "^5.1.0",
"@electron/notarize": "2.5.0",
"@types/js-yaml": "^4.0.9",
@@ -62,14 +62,14 @@
"koffi": "^3.1.0",
"pnpm": "11.7.0",
"typescript": "^6.0.3",
"@deepseek-ai/dsh-deepseek-account": "workspace:^",
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
"@types/ws": "^8.18.1",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"vite": "^6.0.0",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^"
"@deepseek-ai/dsh-client-ui-theme": "workspace:*"
}
}
+22 -22
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.1.7-alpha.1",
"version": "0.1.7-alpha.2",
"publishConfig": {
"access": "public"
},
@@ -30,21 +30,21 @@
},
"license": "MIT",
"devDependencies": {
"@deepseek-ai/cordis-plugin-group": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
"@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
"@deepseek-ai/dsh-remote-mock": "workspace:^",
"@deepseek-ai/cordis-plugin-group": "workspace:~",
"@deepseek-ai/dsh-client-modules": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:*",
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:*",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
"@deepseek-ai/dsh-client-web": "workspace:*",
"@deepseek-ai/dsh-cmdline": "workspace:*",
"@deepseek-ai/dsh-pwsh-local": "workspace:*",
"@deepseek-ai/dsh-experimental-agent-team": "workspace:*",
"@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*",
"@deepseek-ai/dsh-remote-mock": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
@@ -59,11 +59,11 @@
"vite": "^6.0.0",
"vitest": "^4.1.8",
"ws": "8.21.0",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-workspace-changes": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-experimental-auto-review": "workspace:^",
"@deepseek-ai/dsh-experimental-speech-to-text": "workspace:^",
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^"
"@deepseek-ai/dsh-launch-environment": "workspace:*",
"@deepseek-ai/dsh-workspace-changes": "workspace:*",
"@deepseek-ai/dsh-subprocess-local": "workspace:*",
"@deepseek-ai/dsh-experimental-auto-review": "workspace:*",
"@deepseek-ai/dsh-experimental-speech-to-text": "workspace:*",
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*"
}
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2
README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f
README.md: 1dce900ab2ffa16850fe50573f9177088c8201db
README.zh.md: fd14377bc01897e7d330d36603601f9dce16e295
+11 -1
View File
@@ -2,7 +2,15 @@
English | [中文](README.zh.md)
These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
These tests boot the real web composition in-process and drive it with real browsers over real HTTP. Chromium runs the full lane; the [model and reasoning picker scenario](declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
After installing workspace dependencies, install the browsers and their system dependencies from the repository root:
```sh
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit
```
On Linux, `--with-deps` installs dependencies through the system package manager. The persistent CI VM must provide these dependencies through image maintenance; CI installs only the browser binaries, as required by the [failover runbook](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md).
Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios.
@@ -10,6 +18,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling.
## These are Host-face tests
They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile.
+11 -1
View File
@@ -2,7 +2,15 @@
[English](README.md) | 中文
这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。
这些测试在进程内启动真实的 web 组合,并用真实浏览器通过真实 HTTP 驱动它。Chromium 运行整个 lane;[模型与推理强度选择场景](declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。
安装工作区依赖后,在仓库根目录安装浏览器及其系统依赖:
```sh
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit
```
在 Linux 上,`--with-deps` 会通过系统包管理器安装依赖。持久化 CI VM 必须通过镜像维护提供这些依赖,CI 只安装浏览器程序,遵循[故障切换手册](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md)的要求。
普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。
@@ -10,6 +18,8 @@
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。
## 这些是 Host 面的测试
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。
+194
View File
@@ -524,6 +524,75 @@ describe('web e2e: long Chat scroll contract', () => {
await browser?.close()
})
it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-floating-controls',
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
}, async (world) => {
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
const host = world.page.locator('[data-conversation-scroll]')
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
const expectControls = async (): Promise<void> => {
await expect.poll(() => host.evaluate((element) => {
const rail = element.querySelector('nav[aria-label="Turn navigation"]')
const button = element.querySelector('button[aria-label="Back to bottom"]')
const composer = element.querySelector('[data-composer-seat]')
if (rail === null || button === null || composer === null) return Infinity
const viewport = element.getBoundingClientRect()
const railBox = rail.getBoundingClientRect()
const buttonBox = button.getBoundingClientRect()
const composerBox = composer.getBoundingClientRect()
const top = viewport.top + element.clientTop
return Math.max(
Math.abs((railBox.top + railBox.bottom) / 2 - (top + composerBox.top) / 2),
Math.abs(viewport.left + element.clientLeft + element.clientWidth - railBox.right - 12),
Math.abs(composerBox.top - buttonBox.bottom - 16),
)
}), { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
}
await wheelTranscript(world.page, -1_200)
await backToBottom.waitFor({ timeout: 10_000 })
await expectControls()
await wheelTranscript(world.page, -1_200)
await expectControls()
const seat = world.page.locator('[data-composer-seat]')
const initialHeight = await seat.evaluate(element => element.getBoundingClientRect().height)
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
await composer.fill(Array.from({ length: 8 }, (_, index) => `draft line ${index}`).join('\n'))
await expect.poll(() => seat.evaluate(element => element.getBoundingClientRect().height))
.toBeGreaterThan(initialHeight + 40)
await expectControls()
await composer.fill('')
await expectControls()
const rail = world.page.getByRole('navigation', { name: 'Turn navigation', includeHidden: true })
const frame = rail.locator('../..')
const originalStyle = await frame.getAttribute('style')
try {
for (const clearance of [16, 8]) {
for (const contentWidth of [901, 900, 899, 901]) {
await frame.evaluate((element, { clearance, contentWidth }) => {
element.style.width = `${contentWidth + 2 * (clearance + 16)}px`
element.style.setProperty('--dsh-composer-side-clearance', `${clearance}px`)
}, { clearance, contentWidth })
await expect.poll(() => rail.isVisible()).toBe(contentWidth > 900)
}
}
} finally {
await frame.evaluate((element, style) => {
if (style === null) element.removeAttribute('style')
else element.setAttribute('style', style)
}, originalStyle)
}
await expectControls()
await backToBottom.click()
await expectBottom(world.page)
assertClean(world)
})
})
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-history-stream',
@@ -608,6 +677,131 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
it.skipIf(MODE === 'record')('follows a growing process group independently of the outer transcript', async () => {
const parts = [
'GROUP_SCROLL_START\n\n',
...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) =>
`Group batch ${batch} paragraph ${row}: inspect the next recorded operation.\n\n`).join('')),
'GROUP_SCROLL_END\n\n',
]
const chunks: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'reasoning' },
...parts.map((text): StreamChunk => ({ type: 'reasoning-delta', index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'reasoning', text: parts.join('') } },
{ type: 'block-start', index: 1, blockType: 'text' },
{ type: 'text-delta', index: 1, text: 'GROUP_SCROLL_DONE' },
{ type: 'block-end', index: 1, block: { type: 'text', text: 'GROUP_SCROLL_DONE' } },
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 512 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
await withScrollWorld({
failureShot: 'web-e2e-chat-process-follow',
replay: [replayEntry(chunks)],
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
}, async (world) => {
await world.page.emulateMedia({ reducedMotion: 'no-preference' })
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
const gates = parts.slice(1).map(() => Promise.withResolvers<undefined>())
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
let index = -1
for await (const chunk of next()) {
if (chunk.type === 'reasoning-delta') await gates[index++]?.promise
yield chunk
}
})
const settled = world.scaffold.whenTurnSettled(60_000)
try {
await world.page.locator('[data-composer-input][contenteditable="true"]').last().fill(
'Inspect the recorded operations while I scroll the conversation.',
)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
const group = world.page.locator('[data-step-process]:has([data-variant="think"][data-state="running"])')
await group.locator('[data-process-activity]').click()
await group.locator('[data-disclosure-row]').click()
const body = group.locator('[data-step-process-body]')
await nextPaint(world.page)
expect(await body.evaluate(element => element.scrollHeight <= element.clientHeight)).toBe(true)
const height = () => body.evaluate(element => element.scrollHeight)
const top = () => body.evaluate(element => element.scrollTop)
const expectGroupBottom = async (): Promise<void> => {
await expect.poll(() => body.evaluate(element =>
element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
}
const grow = async (index: number): Promise<void> => {
const before = await height()
gates[index]!.resolve(undefined)
await expect.poll(height).toBeGreaterThan(before + 200)
}
// Outer following is a precondition independent of opening the inner disclosure.
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
await wheelTranscript(world.page, -400)
await backToBottom.click()
await expectBottom(world.page)
await grow(0)
await expectGroupBottom()
await expectBottom(world.page)
// Per-frame growth exercises the real observer and native animation
// independently of transport batching while the next model chunk waits.
const content = group.locator('[data-step-process-content]')
const heightStyle = await content.evaluate(element => element.style.height)
const beforeGrowth = await top()
try {
const duringGrowth = await body.evaluate(async (scroller) => {
const content = scroller.querySelector<HTMLElement>('[data-step-process-content]')!
const initialHeight = content.getBoundingClientRect().height
let halfway = scroller.scrollTop
for (let frame = 1; frame <= 60; frame++) {
content.style.height = `${initialHeight + frame * 20}px`
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
if (frame === 30) halfway = scroller.scrollTop
}
return halfway
})
expect(duringGrowth).toBeGreaterThan(beforeGrowth + 20)
await expectGroupBottom()
} finally {
await content.evaluate((element, previous) => { element.style.height = previous }, heightStyle)
}
await expectGroupBottom()
await wheelTranscript(world.page, -800)
await backToBottom.waitFor()
const outerTop = (await scrollGeometry(world.page)).scrollTop
await grow(1)
await expectGroupBottom()
expect(Math.abs((await scrollGeometry(world.page)).scrollTop - outerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
await backToBottom.click()
await expectBottom(world.page)
const pinnedTop = await top()
await body.hover()
await world.page.mouse.wheel(0, -160)
await expect.poll(top).toBeLessThan(pinnedTop - 100)
await nextPaint(world.page)
const readerTop = await top()
await grow(2)
expect(Math.abs(await top() - readerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
await expectBottom(world.page)
await group.locator('[data-process-activity]').click()
await group.locator('[data-process-activity]').click()
await expectGroupBottom()
await body.hover()
await world.page.mouse.wheel(0, 10_000)
await expectGroupBottom()
await grow(3)
await expectGroupBottom()
assertClean(world)
} finally {
for (const gate of gates) gate.resolve(undefined)
dispose()
await settled
}
})
})
it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => {
await withScrollWorld({
failureShot: 'web-e2e-turn-rail-jump',
+132 -13
View File
@@ -6,9 +6,9 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Browser, Page, Request } from 'playwright'
import { chromium, webkit } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
@@ -19,9 +19,13 @@ import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './s
const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/declared-reasoning', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./expected/declared-reasoning/ui.expected.md', import.meta.url))
const POINTER_EXPECTED = fileURLToPath(new URL('./expected/declared-reasoning/pointer-menu.expected.md', import.meta.url))
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
describe.skipIf(MODE === 'record').each([
{ name: 'Chromium', engine: chromium },
{ name: 'WebKit', engine: webkit },
])('web e2e: declared reasoning efforts reach the composer ($name)', ({ engine }) => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -39,15 +43,17 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.acme.example/v1',
models: [{
id: 'acme-think',
name: 'Acme Think',
models: [
{ id: 'acme-think', name: 'Acme Think' },
{ id: 'acme-swift', name: 'Acme Swift' },
].map(model => ({
...model,
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
}],
})),
},
},
})
browser = await chromium.launch()
browser = await engine.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -56,12 +62,15 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
try {
await browser?.close()
} finally {
await scaffold?.close()
}
})
it('offers exactly the declared levels and records the picked one', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
onTestFailed(() => saveFailureShot(page, `web-e2e-declared-reasoning-${engine.name()}`))
const trigger = page.getByRole('button', { name: /^选择模型/ })
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
@@ -125,7 +134,117 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('opens from the pointer with keyboard focus and closes from the trigger in every pane', async () => {
onTestFailed(() => saveFailureShot(page, `web-e2e-model-trigger-${engine.name()}`))
const trigger = page.getByRole('button', { name: /^选择模型/ })
const menu = page.getByRole('menu')
for (const pane of ['root', 'model', 'effort']) {
await page.locator('[data-composer-input][contenteditable="true"]').focus()
await trigger.click()
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
if (pane === 'root') {
await page.keyboard.press('ArrowDown')
await expect.poll(() => page.getByRole('menuitem', { name: /^模型/ })
.evaluate(element => element === document.activeElement)).toBe(true)
} else {
await page.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }).click()
await expect.poll(() => page.locator('[role="menuitemradio"][aria-checked="true"]')
.evaluate(element => element === document.activeElement)).toBe(true)
}
await trigger.click()
await menu.waitFor({ state: 'detached' })
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
}
expect(tripwire.pageErrors).toEqual([])
})
it('selects model and effort by mouse and keeps keyboard control after cancelled or rejected clicks', async () => {
onTestFailed(() => saveFailureShot(page, `web-e2e-model-pointer-${engine.name()}`))
let selections = 0
const countSelection = (request: Request): void => {
if (new URL(request.url()).pathname.endsWith('/session/selectModel')) selections++
}
page.on('request', countSelection)
onTestFinished(() => { page.off('request', countSelection) })
const trigger = page.getByRole('button', { name: /^选择模型/ })
const menu = page.getByRole('menu')
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
const current = page.getByRole('menuitemradio', { name: 'Acme Think', exact: true })
const target = page.getByRole('menuitemradio', { name: 'Acme Swift', exact: true })
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
// Native mousedown must not blur the focused row and unmount the menu before click in WebKit.
await target.getByText('Acme Swift', { exact: true }).hover()
await page.mouse.down()
try {
await expect.poll(() => menu.count()).toBe(1)
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
await page.getByText('Acme Gateway', { exact: true }).hover()
} finally {
await page.mouse.up()
}
// Later selection counts also include any request from this cancelled press.
expect(selections).toBe(0)
await page.keyboard.press('ArrowDown')
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Escape')
await page.keyboard.press('Shift+Tab')
await menu.waitFor({ state: 'detached' })
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
await target.getByText('Acme Swift', { exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(selections).toBe(1)
expect(scaffold.ctx.agentDefaultModel.currentSelection()?.model).toBe('acme-swift')
await trigger.click()
await page.getByRole('menuitem', { name: /推理等级/ }).click()
await page.getByRole('menuitemradio', { name: 'Max', exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(selections).toBe(2)
expect(scaffold.ctx.agentDefaultModel.currentSelection()?.reasoningEffort).toBe('max')
await page.route('**/api/session/selectModel', async (route) => {
const envelope = route.request().postDataJSON() as { rpcId: string }
await route.fulfill({
json: {
type: 'server-response', rpcId: envelope.rpcId,
result: {
ok: false,
error: { code: 'session/writer-held', message: 'writer held', details: { sessionId: 'held-session' } },
},
},
})
}, { times: 1 })
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
await current.click()
await page.getByRole('alert').waitFor()
expect(selections).toBe(3)
await compareOrRefreshGolden(POINTER_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('ArrowUp')
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await menu.waitFor({ state: 'detached' })
await trigger.click()
await page.getByRole('menuitem', { name: /^模型/ }).click()
await page.locator('[data-composer-input][contenteditable="true"]').focus()
await menu.waitFor({ state: 'detached' })
await trigger.click()
await page.mouse.click(0, 0)
await menu.waitFor({ state: 'detached' })
expect(tripwire.pageErrors).toEqual([])
})
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'pointer-menu.expected.md'])
})
})
+5 -5
View File
@@ -14,7 +14,7 @@ import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documen
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
@@ -396,13 +396,13 @@ else process.exit(1);
const markdownImages: string[] = []
for (const alt of ['relative image', 'absolute image', 'reference image']) {
const image = preview.getByRole('img', { name: alt, exact: true })
await image.scrollIntoViewIfNeeded()
await scrollIntoView(image)
await expect.poll(() => image.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true)
const source = new URL(await image.getAttribute('src') ?? '')
expect(source.pathname).toBe('/api/file')
markdownImages.push(alt)
}
await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded()
await scrollIntoView(preview.getByRole('heading', { name: heading, exact: true }))
await successShot(page, 'markdown')
const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) })
const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab')
@@ -567,7 +567,7 @@ else process.exit(1);
expect(firstColor).toBe('red')
const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
expect(workerNames).toContain('dsh-pdf')
await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
await scrollIntoView(preview.locator('[data-pdf-page="2"]'))
const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true })
await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
@@ -580,7 +580,7 @@ else process.exit(1);
await filesTab.click()
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
await pdfTab.click()
await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
await scrollIntoView(preview.locator('[data-pdf-page="2"]'))
await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
const restoredColor = await canvasColor(secondPage)
@@ -0,0 +1,9 @@
- menu "模型与推理等级":
- group "DeepSeek":
- text: DeepSeek
- menuitemradio "DeepSeek-V4-Flash"
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
- group "Acme Gateway":
- text: Acme Gateway
- menuitemradio "Acme Think"
- menuitemradio "Acme Swift" [checked]
@@ -0,0 +1,4 @@
- alert:
- paragraph: "Cannot download model.int8.onnx: the download service returned HTTP 502."
- paragraph: Confirm that the download address is available and the proxy can reach the download service, or retry later.
- text: "Download source: https://fallback-model-source.example"
@@ -0,0 +1,19 @@
- strong: SenseVoiceSmall (INT8)
- paragraph: Local models will be downloaded to the machine running DSH. No Python or compiler is required.
- term: Disk space
- definition: Allow about 1 GB for models, runtime, and download caches
- term: Memory
- definition: About 1 GB with the model loaded; recognition may use more
- term: First setup
- definition: Allow roughly 1–10 minutes, depending on the network and machine
- text: These are estimates; slow networks may take longer. Downloads are reused; idle models release memory by default.
- button "First use downloads the local recognition models.":
- text: First use downloads the local recognition models.
- status
- text: Model download source
- combobox "Model download source":
- option "Automatic (recommended)" [selected]
- option "Hugging Face"
- option "HF-Mirror (China mirror)"
- text: Prefer the first responding source and try another if a download fails.
- button "Download and prepare"
@@ -0,0 +1,19 @@
- strong: SenseVoiceSmall (INT8)
- paragraph: Local models will be downloaded to the machine running DSH. No Python or compiler is required.
- term: Disk space
- definition: Allow about 1 GB for models, runtime, and download caches
- term: Memory
- definition: About 1 GB with the model loaded; recognition may use more
- term: First setup
- definition: Allow roughly 1–10 minutes, depending on the network and machine
- text: These are estimates; slow networks may take longer. Downloads are reused; idle models release memory by default.
- button "First use downloads the local recognition models.":
- text: First use downloads the local recognition models.
- status
- text: Model download source
- combobox "Model download source":
- option "Automatic (recommended)"
- option "Hugging Face"
- option "HF-Mirror (China mirror)" [selected]
- text: Download only from the selected source. Verified downloaded files are reused.
- button "Download and prepare"
+7
View File
@@ -67,11 +67,18 @@ describe('web e2e: goal bar clear convergence', () => {
declaredTop: Number.parseFloat(element.style.top),
tooltipCenter: tooltip.left + tooltip.width / 2,
tooltipTop: tooltip.top,
left: tooltip.left,
right: tooltip.right,
viewportWidth: window.innerWidth,
visibility: element.style.visibility,
}
})
expect(tooltipGeometry).not.toBeNull()
expect(Math.abs(tooltipGeometry!.tooltipCenter - tooltipGeometry!.declaredLeft)).toBeLessThan(2)
expect(Math.abs(tooltipGeometry!.tooltipTop - tooltipGeometry!.declaredTop)).toBeLessThan(2)
expect(tooltipGeometry!.visibility).toBe('visible')
expect(tooltipGeometry!.left).toBeGreaterThanOrEqual(12)
expect(tooltipGeometry!.right).toBeLessThanOrEqual(tooltipGeometry!.viewportWidth - 12)
await page.mouse.move(0, 0)
await pauseTooltip.waitFor({ state: 'hidden' })
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
@@ -0,0 +1,275 @@
/** Keep idle submissions in Chat across independently delivered Inbox and transcript frames. */
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Page } from 'playwright'
import { expect, it } from 'vitest'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import {
parseRemoteStreamClientMessage, parseRemoteStreamServerMessage,
} from '@deepseek-ai/dsh-api-gateway/stream-protocol'
import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
import { newEnglishPage } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
const SESSION_ID = 'idle-submission-handoff'
const TEXT = 'IDLE_SUBMISSION_HANDOFF Keep this message in the conversation.'
interface Delivery {
readonly stream: string
readonly inbox?: InboxState
readonly event?: SessionEvent
readonly send: () => void
}
/** Delay real Host frames while preserving FIFO within each logical stream. */
class HistoryDeliveryGate {
private readonly streams = new Map<string, string>()
private buffered: Delivery[] = []
holding = false
async install(page: Page): Promise<void> {
await page.routeWebSocket('**/api/remote.mux', (socket) => {
const server = socket.connectToServer()
socket.onMessage((message) => {
const frame = parseRemoteStreamClientMessage(String(message))
if (frame.type === 'open') this.streams.set(frame.streamId, frame.endpoint)
server.send(message)
})
server.onMessage((message) => {
const frame = parseRemoteStreamServerMessage(String(message))
const endpoint = this.streams.get(frame.streamId)
if (!this.holding || (endpoint !== 'session/control' && endpoint !== 'session/follow')) {
socket.send(message)
return
}
const control = endpoint === 'session/control' && frame.type === 'item'
? frame.value as { type: string; sessionId?: string; key?: string; value?: InboxState } : undefined
const inbox = control?.type === 'projection' && control.sessionId === SESSION_ID && control.key === 'inbox'
? control.value : undefined
const follow = endpoint === 'session/follow' && frame.type === 'item'
? frame.value as { type: string; event?: SessionEvent } : undefined
this.buffered.push({ stream: frame.streamId, send: () => { socket.send(message) },
...follow?.event === undefined ? {} : { event: follow.event }, ...inbox === undefined ? {} : { inbox } })
})
})
}
inboxDelivery(queued: boolean, text = TEXT): Delivery | undefined {
return this.buffered.find(item => item.inbox !== undefined && item.inbox['next-turn'].some(message =>
message.content.some(block => block.type === 'text' && block.text === text)) === queued)
}
admissionDelivery(text = TEXT): Delivery | undefined {
return this.buffered.find(item => item.event?.type === 'user/message'
&& item.event.data.content.some(block => block.type === 'text' && block.text === text))
}
turnStartDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/start') }
turnEndDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/end') }
releaseThrough(target: Delivery): void {
let reached = false
this.buffered = this.buffered.filter((item) => {
if (reached || item.stream !== target.stream) return true
item.send()
reached = item === target
return false
})
}
releaseAll(): void {
this.holding = false
for (const item of this.buffered) item.send()
this.buffered = []
}
}
async function placement(page: Page, input = TEXT) {
return page.evaluate((text) => {
const matching = (selector: string) => [...document.querySelectorAll<HTMLElement>(selector)]
.filter(element => element.textContent?.includes(text))
const echo = matching('[data-submission-echo]')
const dock = matching('[data-queue-dock] li')
const durable = matching('[data-chat-flow-kind="user"]')
return {
echo: echo.length,
dock: dock.length,
durable: durable.length,
messageTop: (echo[0] ?? dock[0] ?? durable[0])?.getBoundingClientRect().top ?? null,
composerTop: document.querySelector('[data-composer-card]')?.getBoundingClientRect().top ?? null,
}
}, input)
}
it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first'] as const)(
'keeps an idle submission in Chat under CPU throttling (%s)', async (order) => {
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
const releasePrompt = Promise.withResolvers<undefined>()
const promptBlocked = Promise.withResolvers<undefined>()
const gate = new HistoryDeliveryGate()
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
try {
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SESSION_ID)
browser = await chromium.launch()
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await gate.install(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.locator('[data-chat-flow-kind="user"]').first().waitFor()
await page.getByRole('button', { name: 'Send message', exact: true }).waitFor()
const cdp = await page.context().newCDPSession(page)
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 6 })
await page.route('**/api/session/prompt', async (route) => {
promptBlocked.resolve(undefined)
await releasePrompt.promise
await route.continue()
}, { times: 1 })
const trace: Array<{ phase: string } & Awaited<ReturnType<typeof placement>>> = []
const settled = scaffold.whenTurnSettled()
gate.holding = true
const input = page.locator('[data-composer-input]').first()
await input.fill(TEXT)
await input.press('Enter')
await promptBlocked.promise
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'optimistic', ...await placement(page) })
releasePrompt.resolve(undefined)
if (order === 'transcript-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
await expect.poll(() => gate.admissionDelivery()).toBeDefined()
gate.releaseThrough(gate.admissionDelivery()!)
await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
trace.push({ phase: 'durable-before-inbox', ...await placement(page) })
}
await expect.poll(() => gate.inboxDelivery(true)).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(true)!)
await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
trace.push({ phase: 'inbox', ...await placement(page) })
await expect.poll(() => gate.inboxDelivery(false)).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(false)!)
await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
trace.push({ phase: 'claim-projection', ...await placement(page) })
if (order === 'inbox-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
}
gate.releaseAll()
await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
trace.push({ phase: 'durable', ...await placement(page) })
for (const entry of trace) {
expect(entry.messageTop, entry.phase).toBe(trace[0]?.messageTop)
expect(entry.composerTop, entry.phase).toBe(trace[0]?.composerTop)
}
await settled
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 })
await cdp.detach()
expect(tripwire.pageErrors).toEqual([])
} finally {
releasePrompt.resolve(undefined)
gate.releaseAll()
try { await browser?.close() } finally { await scaffold.close() }
}
},
)
it.skipIf(webSnapshotMode() === 'record').each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])(
'hands off three rapid submissions through all Turns in Host order %s', async (hostOrder) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-input-handoff-'))
const releases = new Map(['A', 'B', 'C'].map(id => [id, Promise.withResolvers<undefined>()]))
const blocked = new Set<string>()
const gate = new HistoryDeliveryGate()
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
let scaffold: Awaited<ReturnType<typeof launchWebScaffold>> | undefined
try {
const fixture = await readFile(FIXTURE, 'utf8')
const recorded = deriveReplayScript(parseSessionLog(fixture))
expect(recorded).toHaveLength(1)
const override = join(root, 'replay.override.json')
await writeFile(override, JSON.stringify([recorded[0], recorded[0], recorded[0]]))
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: override, compareReplaySession: false })
await seedSession(scaffold, fixture, SESSION_ID)
browser = await chromium.launch()
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await gate.install(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.locator('[data-chat-flow-kind="user"]').first().waitFor()
await page.getByRole('button', { name: 'Send message', exact: true }).waitFor()
const text = (id: string) => `RAPID_INPUT_${id}`
await page.route('**/api/session/prompt', async (route) => {
const request = route.request().postData() ?? ''
const id = [...releases.keys()].find(id => request.includes(text(id)))
if (id === undefined) throw new Error('unexpected prompt in rapid input fixture')
blocked.add(id)
await releases.get(id)!.promise
await route.continue()
})
gate.holding = true
const input = page.locator('[data-composer-input]').first()
for (const id of releases.keys()) {
await input.fill(text(id))
await input.press('Enter')
await expect.poll(() => blocked.has(id)).toBe(true)
await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 1, dock: 0, durable: 0 })
}
const entered: string[] = []
for (const id of hostOrder) {
const settled = scaffold.whenTurnSettled()
releases.get(id)!.resolve(undefined)
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
await expect.poll(() => gate.admissionDelivery(text(id))).toBeDefined()
gate.releaseThrough(gate.admissionDelivery(text(id))!)
entered.push(id)
for (const candidate of releases.keys()) {
const admitted = entered.includes(candidate)
await expect.poll(() => placement(page, text(candidate))).toMatchObject({
echo: admitted ? 0 : 1, dock: 0, durable: admitted ? 1 : 0,
})
}
await expect.poll(() => gate.inboxDelivery(true, text(id))).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(true, text(id))!)
await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 0, dock: 0, durable: 1 })
await expect.poll(() => gate.inboxDelivery(false, text(id))).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(false, text(id))!)
await expect.poll(() => gate.turnEndDelivery()).toBeDefined()
gate.releaseThrough(gate.turnEndDelivery()!)
await settled
}
gate.releaseAll()
const actual = await page.locator('[data-chat-flow-kind="user"]').allTextContents()
expect(actual.filter(value => value.includes('RAPID_INPUT_')).map(value =>
[...releases.keys()].find(id => value.includes(text(id))))).toEqual(hostOrder.split(''))
expect(await page.locator('[data-submission-echo]').count()).toBe(0)
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
} finally {
for (const release of releases.values()) release.resolve(undefined)
gate.releaseAll()
try { await browser?.close() } finally {
try { await scaffold?.close() } finally { await rm(root, { recursive: true, force: true }) }
}
}
},
)
+4 -4
View File
@@ -11,7 +11,7 @@ import {
acknowledgeReloadConnectionLoss, launchWebScaffold,
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
import { newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
// Borrowed read-only: this scenario needs any settled assistant message to
// address, not a new recording (message-actions / sidebar-scrollbar pattern).
@@ -67,7 +67,7 @@ describe('web e2e: durable per-message feedback', () => {
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
const like = page.getByRole('button', { name: 'Good response' }).first()
await like.waitFor({ timeout: 30_000 })
await like.scrollIntoViewIfNeeded()
await scrollIntoView(like)
await like.hover()
await like.click()
const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
@@ -111,12 +111,12 @@ describe('web e2e: durable per-message feedback', () => {
// the unrated control is what triggers the authoritative re-read.
const cold = page.getByRole('button', { name: 'Good response' }).first()
await cold.waitFor({ timeout: 30_000 })
await cold.scrollIntoViewIfNeeded()
await scrollIntoView(cold)
await cold.hover()
const restored = page.getByRole('button', { name: 'Remove rating' }).first()
await restored.waitFor({ timeout: 30_000 })
await restored.scrollIntoViewIfNeeded()
await scrollIntoView(restored)
await restored.hover()
await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true')
// The retract label sits on the Dislike side: the Like stays unpressed.
+2 -2
View File
@@ -14,7 +14,7 @@ import {
compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture,
watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage } from './support.ts'
import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage, scrollIntoView } from './support.ts'
const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
const FIXTURE = join(DIR, 'session.v3.jsonl')
@@ -272,7 +272,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
expect(geometry.openFontSize).toBe('11px')
await page.setViewportSize({ width: 480, height: 900 })
const row = page.locator('[data-presented-files-row]')
await row.scrollIntoViewIfNeeded()
await scrollIntoView(row)
for (const card of await row.getByRole('button').all()) {
const bounds = await card.boundingBox()
expect(bounds).not.toBeNull()
+32
View File
@@ -227,6 +227,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let seededThroughSeq = -1
let openingWindow: unknown
beforeAll(async () => {
// The POSIX terminal fixture stays off Windows; the pinned desktop applies
@@ -266,6 +267,27 @@ describe('web e2e: seeded history renders through cold resume', () => {
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
if (MODE !== 'record') {
// A one-message tail makes this short recording exercise the real Load earlier path.
let pagedOpening = false
await page.routeWebSocket('**/api/remote.mux', (socket) => {
const server = socket.connectToServer()
socket.onMessage((message) => {
const frame = JSON.parse(String(message)) as {
type: string
endpoint?: string
payload: { args: { request: { maxMessages: number; turnWindow?: { minMessages: number; minTurns: number } } } }
}
if (!pagedOpening && frame.type === 'open' && frame.endpoint === 'session/follow') {
pagedOpening = true
openingWindow = { ...frame.payload.args.request }
frame.payload.args.request.maxMessages = 1
delete frame.payload.args.request.turnWindow
server.send(JSON.stringify(frame))
} else server.send(message)
})
})
}
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
@@ -330,6 +352,16 @@ describe('web e2e: seeded history renders through cold resume', () => {
await sessionRow.click()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(openingWindow).toMatchObject({ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } })
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(0)
const [paging] = await Promise.all([
page.waitForRequest('**/api/session/page'),
page.getByRole('button', { name: 'Load earlier', exact: true }).click(),
])
expect(paging.postDataJSON()).toMatchObject({
payload: { args: { request: { maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } } } },
})
await expect.poll(() => page.getByText(PROMPT, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
timeout: 10_000,
+39 -10
View File
@@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
import { parseRemoteStreamServerMessage } from '@deepseek-ai/dsh-api-gateway/stream-protocol'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden, fixtureUserPrompts,
@@ -191,13 +192,21 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
})
})
describe('web e2e: composer shortcut steers directly', () => {
describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortcut steers directly (%s)', (handoff) => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let remoteSocket: WebSocketRoute | undefined
const sessionEvents: SessionEvent[] = []
let holdInbox = false
let heldStream: string | undefined
let heldControl: (() => void)[] = []
const releaseInbox = (): void => {
holdInbox = false
for (const send of heldControl) send()
heldControl = []
}
beforeAll(async () => {
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: false })
@@ -206,7 +215,17 @@ describe('web e2e: composer shortcut steers directly', () => {
page = await newEnglishPage(browser)
await page.routeWebSocket('**/api/remote.mux', (route) => {
remoteSocket = route
route.connectToServer()
const server = route.connectToServer()
server.onMessage((message) => {
const frame = parseRemoteStreamServerMessage(String(message))
if (holdInbox && frame.type === 'item') {
const value = frame.value as { type?: string; key?: string }
if (value.type === 'projection' && value.key === 'inbox') heldStream = frame.streamId
}
// Hold the remaining control stream in FIFO order, while history keeps flowing.
if (holdInbox && frame.streamId === heldStream) heldControl.push(() => { route.send(message) })
else route.send(message)
})
})
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -251,6 +270,7 @@ describe('web e2e: composer shortcut steers directly', () => {
return next()
})
try {
holdInbox = handoff === 'delayed-inbox'
await composer.getByRole('radio', { name: 'Yes' }).click()
await composer.getByRole('radio', { name: 'Yes' }).press('Enter')
await expect.poll(() => claimed, { timeout: 10_000 }).toBe(true)
@@ -259,16 +279,25 @@ describe('web e2e: composer shortcut steers directly', () => {
}))
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
expect(await page.locator('[data-submission-echo]').filter({ hasText: STEER }).count()).toBe(1)
const socket = remoteSocket
if (socket === undefined) throw new Error('steering page has no Remote connection')
const warningStart = tripwire.warnings.length
await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' })
await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true)
await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0)
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
acknowledgeReloadConnectionLoss(tripwire, warningStart)
if (handoff === 'reconnect') {
const socket = remoteSocket
if (socket === undefined) throw new Error('steering page has no Remote connection')
const warningStart = tripwire.warnings.length
await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' })
await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true)
await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0)
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
acknowledgeReloadConnectionLoss(tripwire, warningStart)
} else {
await expect.poll(() => heldControl.length, { timeout: 10_000 }).toBeGreaterThan(0)
admission.resolve(undefined)
await page.locator('[data-chat-flow-kind="steering"]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
expect(await page.getByText(STEER, { exact: true }).count()).toBe(1)
expect(await pendingSteering.count()).toBe(0)
}
} finally {
admission.resolve(undefined)
releaseInbox()
stopHolding()
await settled
}
+13
View File
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
import { expect } from 'vitest'
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
@@ -55,6 +56,18 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
}
/**
* Scroll a locator whose rendered element can be replaced during layout.
* @param target - locator resolved again when its previous element detached.
*/
export async function scrollIntoView(target: Locator): Promise<void> {
await expect.poll(() => target.evaluate((element) => {
if (!element.isConnected) return false
element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' })
return true
}), { timeout: 10_000 }).toBe(true)
}
/**
* Expand every eligible Turn process and secondary group so a Tool-focused
* scenario can exercise the original row contract beneath product-default
+3 -3
View File
@@ -16,7 +16,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
import { expandTurnProcesses, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url))
@@ -159,7 +159,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
expect(await markdown.locator('hr').count()).toBe(1)
expect(await markdown.locator('table').count()).toBe(1)
expect(await markdown.locator('.katex').count()).toBe(7)
await markdown.locator('pre').scrollIntoViewIfNeeded()
await scrollIntoView(markdown.locator('pre'))
await expect.poll(() => markdown.locator('pre.shiki').count(), { timeout: 15_000 }).toBe(1)
const snapshot = (await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd))
.split(LONG_TOKEN).join('{{longToken}}')
@@ -233,7 +233,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize))
await page.setViewportSize({ width: 1680, height: 1000 })
const groupScroll = thinking.locator('xpath=ancestor::*[@data-step-process-body][1]')
await groupScroll.scrollIntoViewIfNeeded()
await scrollIntoView(groupScroll)
await groupScroll.evaluate((host) => {
const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)')
if (row === null) throw new Error('tall Thinking table row missing')
+2 -2
View File
@@ -8,7 +8,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url))
@@ -73,7 +73,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
const card = page.locator('[data-tool="schedule_create"]')
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
const reminders = page.locator('[data-tool="schedule_list"]')
await reminders.scrollIntoViewIfNeeded()
await scrollIntoView(reminders)
const list = reminders.getByRole('list').first()
await list.evaluate((element) => {
const second = element.children[1]!
+65 -2
View File
@@ -1,7 +1,7 @@
/** The real local provider publishes actionable download failures through the speech Remote to bundle details. */
import { once } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { createServer, type ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -45,7 +45,7 @@ it('shows the failed asset, actual download source and recovery advice, then ret
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click()
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
const alert = page.getByRole('alert').filter({ hasText: 'Cannot download model.int8.onnx' })
const alert = page.getByRole('alert')
await alert.waitFor()
expect(await alert.textContent()).toContain('HTTP 503')
expect(await alert.textContent()).toContain(`Download source: ${origin}`)
@@ -59,3 +59,66 @@ it('shows the failed asset, actual download source and recovery advice, then ret
expect(requests).toHaveLength(2)
expect(tripwire.pageErrors).toEqual([])
})
it.each([{ winner: 0, manual: false }, { winner: 1, manual: false }, { winner: 0, manual: true }, { winner: 1, manual: true }])(
'uses source $winner with manual selection $manual through the real preparation UI', async ({ winner, manual }) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-voice-mirrors-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
const probes = new Map<number, ServerResponse>(), downloads: number[] = [], requestedPaths: string[] = []
const servers = [0, 1].map(index => createServer((request, response) => {
requestedPaths.push(request.url!)
if (request.method === 'HEAD') {
probes.set(index, response)
// Release the winner only after both real Host requests overlap.
if (probes.size === 2) { const selected = probes.get(winner)!; selected.writeHead(200); selected.end() }
} else {
downloads.push(index)
response.writeHead(index === winner ? 503 : 502); response.end()
}
}))
onTestFinished(async () => {
try { await resources.browser?.close() } finally {
try { await resources.scaffold?.close() } finally {
await Promise.all(servers.map(async (server) => {
const closed = new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
server.closeAllConnections(); await closed
}))
}
}
})
const origins = await Promise.all(servers.map(async (server) => {
server.listen(0, '127.0.0.1'); await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Expected a loopback listener')
return `http://127.0.0.1:${address.port}`
}))
const overlay = join(root, 'voice.patch.yml')
await writeFile(overlay, `- id: speech-to-text-sensevoice\n config: ${JSON.stringify({
dataRoot: join(root, 'models'), modelOrigins: origins, modelProbeTimeoutMs: 10_000,
})}\n`)
const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled: true }] }, extraOverlayPath: overlay })
resources.scaffold = scaffold
const browser = await chromium.launch(); resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl)
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click()
const picker = page.getByLabel('Model download source', { exact: true })
await picker.waitFor()
if (manual) await picker.selectOption(origins[winner]!)
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
const alert = page.getByRole('alert')
await alert.waitFor()
expect(downloads).toEqual(manual ? [winner] : [winner, 1 - winner])
expect(requestedPaths).toHaveLength(manual ? 1 : 4)
expect(requestedPaths.every(path => path === '/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/model.int8.onnx')).toBe(true)
const failedOrigin = origins[manual ? winner : 1 - winner]!
expect(await picker.inputValue()).toBe(manual ? origins[winner] : '')
expect(await alert.textContent()).toContain(manual ? 'HTTP 503' : 'HTTP 502')
expect(await alert.textContent()).toContain(`Download source: ${failedOrigin}`)
await compareOrRefreshGolden(manual ? expected : fileURLToPath(new URL('./expected/voice-download-fallback.expected.md', import.meta.url)),
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(failedOrigin, manual ? 'https://model-mirror.example' : 'https://fallback-model-source.example'),
webSnapshotMode())
expect(tripwire.pageErrors).toEqual([])
})
+9
View File
@@ -40,5 +40,14 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo
await page.getByRole('button', { name: 'Download and prepare', exact: true }).waitFor()
expect(await dialog.count()).toBe(0)
expect(await page.getByText('Local models will be downloaded to the machine running DSH. No Python or compiler is required.', { exact: true }).count()).toBe(1)
expect(await page.getByText('Audio is recognized on the machine running DSH. If models need downloading, that machine must be able to reach the selected source and its file services. Configure a proxy on that machine if needed.', { exact: true }).count()).toBe(1)
const source = page.getByLabel('Model download source', { exact: true })
expect(await source.inputValue()).toBe('')
expect(await source.getByRole('option').allTextContents()).toEqual(['Automatic (recommended)', 'Hugging Face', 'HF-Mirror (China mirror)'])
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/voice-source-auto.expected.md', import.meta.url)),
await captureStableAria(page, '[data-speech-provider]', scaffold.workspaceCwd), webSnapshotMode())
await source.selectOption('https://hf-mirror.com')
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/voice-source-manual.expected.md', import.meta.url)),
await captureStableAria(page, '[data-speech-provider]', scaffold.workspaceCwd), webSnapshotMode())
expect(tripwire.pageErrors).toEqual([])
})
+1
View File
@@ -122,6 +122,7 @@
"tests/markdown-inline-code-links.e2e.ts",
"tests/clickable-links-gallery.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/idle-submission-handoff.e2e.ts",
"tests/cold-blank-session.e2e.ts",
"tests/blank-writer-reuse.e2e.ts",
"tests/queue-image.e2e.ts",
+29 -29
View File
@@ -7,36 +7,36 @@
"devDependencies": {
"playwright": "^1.49.0",
"@xterm/headless": "^6.0.0",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-llm-replay": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-agent-preset-registry": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
"@deepseek-ai/dsh-deque": "workspace:^",
"@deepseek-ai/dsh-lazy-require": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sdk-client": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-stats": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-turn-outline": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-util-values": "workspace:^"
"@deepseek-ai/dsh-terminal": "workspace:*",
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-llm-replay": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
"@deepseek-ai/dsh-deque": "workspace:*",
"@deepseek-ai/dsh-lazy-require": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-sdk-client": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-session-stats": "workspace:*",
"@deepseek-ai/dsh-session-title": "workspace:*",
"@deepseek-ai/dsh-session-turn-outline": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:~"
}
}
+2 -2
View File
@@ -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: 2668f1f933654526c274f26eb4543a7d7085e808
config-catalog.zh.md: 97509f6162b6804562390525a5e1031150692f5e
config-catalog.md: eda021971f5bb77c5767e9f3a2c3a3c995fae434
config-catalog.zh.md: 02d01b17133c93d9f28626677de88af5497237d9
+6 -2
View File
@@ -1033,8 +1033,12 @@ export interface Config {
vadModelPath?: string | undefined
/** Weight precision; INT8 minimizes first-use download and model storage. */
precision: 'int8' | 'fp32'
/** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */
modelOrigin: string
/** Explicit Hugging Face-compatible origin; bypasses automatic selection and public fallback. */
modelOrigin?: string | undefined
/** Hugging Face-compatible origins compared before downloading each missing asset. */
modelOrigins: string[]
/** Deadline for concurrent HEAD probes, including redirects to the actual asset. */
modelProbeTimeoutMs: number
/** CPU intra-operation thread count. */
threads: number
/** Maximum speech segment length passed to the recognizer. */
+6 -2
View File
@@ -1035,8 +1035,12 @@ export interface Config {
vadModelPath?: string | undefined
/** Weight precision; INT8 minimizes first-use download and model storage. */
precision: 'int8' | 'fp32'
/** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */
modelOrigin: string
/** Explicit Hugging Face-compatible origin; bypasses automatic selection and public fallback. */
modelOrigin?: string | undefined
/** Hugging Face-compatible origins compared before downloading each missing asset. */
modelOrigins: string[]
/** Deadline for concurrent HEAD probes, including redirects to the actual asset. */
modelProbeTimeoutMs: number
/** CPU intra-operation thread count. */
threads: number
/** Maximum speech segment length passed to the recognizer. */
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-remote-api.md
adding-a-remote-api.md: 0f89101e9bc3ff961d465c109f033150e93e04f6
adding-a-remote-api.zh.md: 0c5a57b539ecd98cb88790260a3fb8e7b41afff6
adding-a-remote-api.md: a36fe1aecd1e134686d9f1b583c56e9648d21f23
adding-a-remote-api.zh.md: c1f56a865bc49512a50eb67d53e4190d98f5a176
+2 -2
View File
@@ -97,8 +97,8 @@ export async function rename(noteId: string, title: string): Promise<void> {
"./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" },
"./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" }
},
"peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" },
"devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }
"peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" },
"devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" }
}
```
+2 -2
View File
@@ -97,8 +97,8 @@ export async function rename(noteId: string, title: string): Promise<void> {
"./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" },
"./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" }
},
"peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" },
"devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }
"peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" },
"devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" }
}
```
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 13ac82b2c9fb0b2aeeaa2bec9c736db5b5b63992
event-producer-consumer.zh.md: fce8463041181139e45fb5f3a9ab14c87117135f
event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce
event-producer-consumer.zh.md: 73438c3f19ef6850674a27bf8f490fe4cf52b420
+5 -5
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
+5 -5
View File
@@ -23,11 +23,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:43`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
+2 -2
View File
@@ -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/rescope.md
rescope.md: 80a0b1dc67c8b3585f2c18cad3afff9dde5859f9
rescope.zh.md: aaf65024520308c5d0013d85a128a42839a84573
rescope.md: 0fcbdd476afb45c2fd9efe927c6b5643b9426683
rescope.zh.md: bd6e3f233b46cb7c0ed5c1f624d2941f41665a6f
+1 -1
View File
@@ -23,7 +23,7 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@
## What the rename does not touch
- **Directory names and upstream source versions.** `vendor/hmr/` stays `vendor/hmr/`, and the table records the upstream version of the pinned source snapshot, so the manifest reads as an upstream snapshot; the vendored `package.json`'s own `version` field is the harness's released manifest version, which `pnpm run release:vendor` bumps and a re-sync restores to the upstream version.
- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use `workspace:^` for repository-owned runtime dependencies, so pnpm resolves the pinned local packages and substitutes release ranges when publishing.
- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use the `workspace:` protocol; [repository rules](../AGENTS.md#conventions) distinguish exact DSH references from vendor/native tilde ranges.
- **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name.
- **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`.
- **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`.
+1 -1
View File
@@ -23,7 +23,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R
## 改名不碰什么
- **目录名与上游源码版本。** `vendor/hmr/` 仍是 `vendor/hmr/`,清单表记录的是所钉住源码快照的上游版本,因此清单读作一份上游快照;而每个 vendored 包 `package.json` 自身的 `version` 字段是 harness 发布的清单版本,`pnpm run release:vendor` 会提升它,重新 sync 时会恢复成上游版本。
- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单对仓库内的运行时依赖使用 `workspace:^`,因此 pnpm 会解析到固定的本地包,并在发布时替换为版本范围。
- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单使用 `workspace:` 协议;[仓库规则](../AGENTS.md#conventions)区分精确的 DSH 引用与 vendor/native tilde 范围。
- **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。
- **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。
- **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/voice-input.md
voice-input.md: 1c128b268c8fbbcabc13f4ca52daaa62534a6366
voice-input.zh.md: 5ce0626f4dc769cb12a842b7ad296eb915cb94ea
voice-input.md: 1d85e8226afddad187533cfe5b14b6750d46c158
voice-input.zh.md: 78a70ab9c265df86c1d99aa863a4be0ae1abbd9e
+6 -2
View File
@@ -18,6 +18,8 @@ The input facade captures a revision-bearing selection before recording. `InputA
## Preparation and settings
`SpeechProviderInfo.downloadSources` advertises preparation origins. `SpeechPreparationOptions.downloadSource` selects one for a task; omission preserves provider policy. SenseVoice validates the advertised choice, pins a manual source without fallback, and rejects source changes during active preparation. The UI keeps a choice for retries in the current card; it is not a persisted recognition preference.
The Host provider owns one preparation task across page and Session changes. The Client shares one `follow()` subscription across the composer, setup prompt and bundle details. Optional `SpeechSetupEstimate` metadata supplies provider-specific planning hints, separate from measured progress. `SpeechPreparationStepKind` identifies ordered resource operations; `SpeechPreparationStep` records each status and start time. The complete `SpeechPreparationState` retains these steps after cancellation or failure. The collapsed UI shows the current operation; expanding lists all steps, with byte progress or elapsed time only for the running step. Closing an observer never cancels preparation. Verified files survive retries and idle worker reclamation.
Failed preparation can include `SpeechDownloadFailure` with the asset, source origin, classified reason and optional diagnostic code or HTTP status. The Client localizes recovery advice; raw download causes remain on the Host.
@@ -66,8 +68,9 @@ Speech calls never activate or submit to an Agent.
/**
* Start or join one Host-owned preparation task.
* @param providerId - selected recognizer.
* @param options - task-local source selection validated by the provider.
*/
@Remote prepare(providerId: SpeechProviderId): void
@Remote prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel resource preparation.
@@ -130,8 +133,9 @@ async configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join provider-owned preparation.
* @param id - exact registered provider identity.
* @param options - task-local source selection validated by the provider.
*/
prepare(id: SpeechProviderId): void
prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel provider preparation without tying it to a browser connection.
+6 -2
View File
@@ -18,6 +18,8 @@
## 准备与设置
`SpeechProviderInfo.downloadSources` 公布准备阶段的源。`SpeechPreparationOptions.downloadSource` 为单次任务选择一个源;省略时保留提供方策略。SenseVoice 校验公布的选项,固定使用手动指定的源而不回退,并拒绝在准备期间改源。UI 为当前卡片中的重试保留选择,但不会将其保存为识别偏好。
Host Provider 在页面和 Session 变化期间拥有同一个准备任务。Client 在输入框、安装引导弹窗、Bundle 详情之间共享一份 `follow()` 订阅。可选 `SpeechSetupEstimate` 元数据提供各 Provider 的资源预期,与实测进度分开。`SpeechPreparationStepKind` 标识有序资源操作;`SpeechPreparationStep` 记录各步骤状态与开始时间。完整 `SpeechPreparationState` 在取消或失败后保留这些步骤。折叠 UI 显示当前操作,展开后列出所有步骤,仅进行中步骤显示字节进度或等待时间。关闭观察者不会取消准备。已校验文件在重试及空闲进程回收后继续复用。
准备失败时可包含 `SpeechDownloadFailure`,提供文件、下载源、原因分类以及可选错误码或 HTTP 状态。Client 将恢复建议本地化;原始下载错误留在 Host。
@@ -66,8 +68,9 @@ Speech calls never activate or submit to an Agent.
/**
* Start or join one Host-owned preparation task.
* @param providerId - selected recognizer.
* @param options - task-local source selection validated by the provider.
*/
@Remote prepare(providerId: SpeechProviderId): void
@Remote prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel resource preparation.
@@ -130,8 +133,9 @@ async configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join provider-owned preparation.
* @param id - exact registered provider identity.
* @param options - task-local source selection validated by the provider.
*/
prepare(id: SpeechProviderId): void
prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel provider preparation without tying it to a browser connection.
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/testing.md
testing.md: 95badfd66ba49151a64dad3f5959a12265449f97
testing.zh.md: a715fb0a6f285a908857a90e848213f42cc040cf
testing.md: c726270b796f6de125a762436ccd5bc7c74ba3bc
testing.zh.md: 977960c76d6455389a4058ebac305e6690342278
+1 -1
View File
@@ -12,7 +12,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
- **Owner-local expected output** (`pnpm run test:expected`): keyless assembled CLI/process expectations without a recorded-session round trip. Drivers use `*.expected.e2e.ts` beside `tests/expected/`; CI runs built exports. Package/script expectations use `test`, while browser expectations use `test:web`.
- **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): `benchmarks/` groups user-path gates. It builds libraries and workers; timed code runs under plain Node, never TSX. Synthetic inputs enforce time, heap, and scaling budgets; package-local `.perf.ts` stays diagnostic ([rules](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)).
- **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff.
- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS.
- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): The command builds plugin CSS, then compares all session-driven `snapshots/web/` and UI-only `apps/web/tests/expected/` output in Chromium; the model/reasoning picker also runs in WebKit. CI enforces read-only `DSH_SNAPSHOT=replay`; record/refresh stay local, with every diff reviewed ([web lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)).
Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current fixtures use the [writer format](session-format-status.md) in their filenames and headers, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors.
+1 -1
View File
@@ -12,7 +12,7 @@
- **所属位置的预期输出**(`pnpm run test:expected`):无录制会话往返的无密钥组装 CLI/进程预期。驱动使用 `*.expected.e2e.ts`,并与 `tests/expected/` 同属一处;CI 针对构建产物运行。包/脚本预期使用 `test`,浏览器预期使用 `test:web`。
- **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):`benchmarks/` 按用户路径组织门禁。它先构建 library 和 worker;被计时代码在纯 Node 下运行,不使用 TSX。合成输入执行耗时、堆和缩放预算;包内 `.perf.ts` 保留为诊断([规则](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。
- **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.<ordinal>[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。
- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。
- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):命令先构建插件 CSS,再用 Chromium 比较 `snapshots/web/` 下全部会话驱动输出和 `apps/web/tests/expected/` 下全部纯 UI 输出;模型与推理强度选择场景还在 WebKit 中运行。CI 强制只读的 `DSH_SNAPSHOT=replay`;record/refresh 留在本地,每处 diff 都须评审([web 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。
Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 fixture 在文件名与 header 中使用[写入格式](session-format-status.zh.md),每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。
+1 -1
View File
@@ -26,7 +26,7 @@
"release:verify-packed-install": "node ./scripts/verify-packed-install.mjs"
},
"devDependencies": {
"@deepseek-ai/node-addon-system": "workspace:*",
"@deepseek-ai/node-addon-system": "workspace:~",
"@types/node": "^26.0.1",
"tsx": "^4.20.6",
"typescript": "^6.0.3"
@@ -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 native/system/packages/entry/README.md
README.md: 8f6a4d341c8f9e4c104e5706e08d9a7d45c065e1
README.zh.md: e401fc2ec71b1afb664d8e73bf3e9372054957a7
README.md: 0ffd2b38a78c562dad7c6760e8dbd79fbf8151c2
README.zh.md: fd4f9acc56ed59d69309c5b9e8f9f6d6ce0ca926
+1 -1
View File
@@ -10,6 +10,6 @@ The `./landlock-run` entry exports the Landlock launcher path, enforcement probe
The lock operation attempts `LOCK_EX | LOCK_NB` asynchronously. Keep the caller-owned descriptor open until completion; contention rejects with `EAGAIN`/`EWOULDBLOCK`, other syscall failures also reject, and errors carry their code, positive errno, and `syscall: 'flock'`. Native setup errors reject the same promise. Closing the last descriptor for the open file description releases the lock. The binding does not open, duplicate, close, or explicitly unlock descriptors.
Optional OS/CPU platform packages carry the binaries. Linux has `bin/landlock-run` and separate `bin/glibc/system.node` / `bin/musl/system.node`; macOS has `bin/system.node`. Missing or unloadable flock bindings reject acquisition, without installation-time compilation. Landlock remains a separate executable with its existing fail-closed protocol; unsupported kernels/platforms probe unusable.
Optional OS/CPU platform packages carry the binaries. Their `~x.y.z` dependency ranges allow later patches within the same minor version. Linux has `bin/landlock-run` and separate `bin/glibc/system.node` / `bin/musl/system.node`; macOS has `bin/system.node`. Missing or unloadable flock bindings reject acquisition, without installation-time compilation. Landlock remains a separate executable with its existing fail-closed protocol; unsupported kernels/platforms probe unusable.
The two C sources ship for auditability. See the workspace [architecture](../../docs/architecture.md), [support matrix](../../docs/support-matrix.md), and [CLI contract](../../docs/cli-contract.md).
+1 -1
View File
@@ -10,6 +10,6 @@ kind: "package-library"
锁操作异步尝试 `LOCK_EX | LOCK_NB`。在完成前保持调用方拥有的描述符打开;竞争以 `EAGAIN`/`EWOULDBLOCK` 拒绝,其他系统调用失败也会拒绝,错误携带 code、值为正数的 errno 和 `syscall: 'flock'`。原生调用准备阶段的错误也会拒绝同一个 promise。关闭指向该打开文件描述的最后一个描述符即释放锁。绑定不打开、复制、关闭或显式解锁描述符。
可选操作系统/CPU 平台包携带二进制。Linux 包含 `bin/landlock-run` 和分别用于两种 libc 的 `bin/glibc/system.node` / `bin/musl/system.node`;macOS 包含 `bin/system.node`。flock 绑定缺失或无法加载时,锁获取请求会被拒绝,不在安装时编译。Landlock 仍是遵循既有失败关闭协议的独立可执行文件;不支持的内核或平台探测结果为不可用。
可选操作系统/CPU 平台包携带二进制。它们的 `~x.y.z` 依赖范围允许同一次版本内的后续补丁版本。Linux 包含 `bin/landlock-run` 和分别用于两种 libc 的 `bin/glibc/system.node` / `bin/musl/system.node`;macOS 包含 `bin/system.node`。flock 绑定缺失或无法加载时,锁获取请求会被拒绝,不在安装时编译。Landlock 仍是遵循既有失败关闭协议的独立可执行文件;不支持的内核或平台探测结果为不可用。
两个 C 源文件随包分发以供审计。参见工作区[架构](../../docs/architecture.md)、[支持矩阵](../../docs/support-matrix.md)和 [CLI 约定](../../docs/cli-contract.md)。
+4 -4
View File
@@ -38,9 +38,9 @@
"access": "public"
},
"optionalDependencies": {
"@deepseek-ai/node-addon-system-darwin-arm64": "workspace:*",
"@deepseek-ai/node-addon-system-darwin-x64": "workspace:*",
"@deepseek-ai/node-addon-system-linux-arm64": "workspace:*",
"@deepseek-ai/node-addon-system-linux-x64": "workspace:*"
"@deepseek-ai/node-addon-system-darwin-arm64": "workspace:~",
"@deepseek-ai/node-addon-system-darwin-x64": "workspace:~",
"@deepseek-ai/node-addon-system-linux-arm64": "workspace:~",
"@deepseek-ai/node-addon-system-linux-x64": "workspace:~"
}
}
+2 -2
View File
@@ -46,7 +46,7 @@ function fixture(t) {
writeJson('packages/entry/package.json', {
name: '@fixture/native', version: '1.2.3', type: 'module', repository: repository('entry'),
exports: { '.': './lib/index.js' }, files: ['lib/'],
optionalDependencies: { '@fixture/native-linux-x64': 'workspace:*' },
optionalDependencies: { '@fixture/native-linux-x64': 'workspace:~' },
scripts: { prepack: 'node ../../scripts/verify-entry-lib.mjs' },
});
fs.mkdirSync(path.join(dir, 'packages/entry/lib'));
@@ -102,7 +102,7 @@ for (const workflow of [false, true]) {
}
assert.equal(manifests[0].repository.directory, 'native/system/packages/linux-x64');
assert.equal(manifests[1].repository.directory, 'native/system/packages/entry');
assert.equal(manifests[1].optionalDependencies['@fixture/native-linux-x64'], '1.2.3');
assert.equal(manifests[1].optionalDependencies['@fixture/native-linux-x64'], '~1.2.3');
assert.equal(tarFile(f.dir, files[1], 'LICENSE').toString(), workspaceLicense);
assert.deepEqual(tarFile(f.dir, files[0], 'bin/landlock-run'), f.binary);
const extracted = path.join(f.dir, 'extracted');
+16 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-root",
"version": "0.1.7-alpha.1",
"version": "0.1.7-alpha.2",
"license": "MIT",
"private": true,
"type": "module",
@@ -204,19 +204,19 @@
"postinstall": "node scripts/install-lefthook.mjs"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-browser-use": "workspace:^",
"@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:^",
"@deepseek-ai/dsh-experimental-browser-use-playwright-mcp": "workspace:^",
"@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp": "workspace:^",
"@deepseek-ai/dsh-experimental-browser-use-stagehand-native": "workspace:^",
"@deepseek-ai/dsh-computer-use": "workspace:^",
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": "workspace:^",
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-native": "workspace:^",
"@deepseek-ai/dsh-llm-replay": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
"@deepseek-ai/dsh-web-fetch-http": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-browser-use": "workspace:*",
"@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:*",
"@deepseek-ai/dsh-experimental-browser-use-playwright-mcp": "workspace:*",
"@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp": "workspace:*",
"@deepseek-ai/dsh-experimental-browser-use-stagehand-native": "workspace:*",
"@deepseek-ai/dsh-computer-use": "workspace:*",
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": "workspace:*",
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-native": "workspace:*",
"@deepseek-ai/dsh-llm-replay": "workspace:*",
"@deepseek-ai/dsh-mcp-client": "workspace:*",
"@deepseek-ai/dsh-tool-session-query": "workspace:*",
"@deepseek-ai/dsh-web-fetch-http": "workspace:*",
"@stylistic/eslint-plugin": "^5.10.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
@@ -255,7 +255,7 @@
"typescript": "^6.0.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.8",
"@deepseek-ai/dsh-skill-office": "workspace:^",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^"
"@deepseek-ai/dsh-skill-office": "workspace:*",
"@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*"
}
}
+26 -26
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-acp",
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
"version": "0.1.7-alpha.1",
"version": "0.1.7-alpha.2",
"publishConfig": {
"access": "public"
},
@@ -28,19 +28,19 @@
"license": "MIT",
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^"
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-attachment": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-mcp-client": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-token-meter": {
@@ -48,19 +48,19 @@
}
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^"
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
"@deepseek-ai/dsh-attachment": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-mcp-client": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*"
}
}
Loaded 100 of 532 files, more files were not shown because too many files have changed in this diff. Show more