Merge remote-tracking branch 'origin/master' into worktree/voice-model-download-mirrors
This commit is contained in:
commit
3f0022a8fa
474 files changed
+12049
-9530
No files matched your search
+2
-2
@@ -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
-2
@@ -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 行的排除,不替换这里的恢复模型。
|
||||
+6
@@ -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
|
||||
+54
@@ -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.
|
||||
+54
@@ -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/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
-2
@@ -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 链接开关与专用锁文件检查不再必要。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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: 2744c40c7ff6d90e2d1d6cefadfcf6b55acb7e1b
|
||||
README.zh.md: e7dd1a23b96e5b3eff055e49deec8c86d8e91727
|
||||
@@ -10,6 +10,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.
|
||||
|
||||
@@ -10,6 +10,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 服务访问都无法编译。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/** Hold one Host service until the restart test releases application startup. */
|
||||
export const name = 'web-restart-startup'
|
||||
|
||||
/** Keep the carrier services available while Session Controller startup is held. */
|
||||
export const inject = ['typertGateway', 'connection', 'webServer']
|
||||
|
||||
/**
|
||||
* Provide the test-only dependency after startup has been released.
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - Host plugin context.
|
||||
* @returns {Promise<void>} Once the barrier is released or the plugin is disposed.
|
||||
*/
|
||||
export async function apply(ctx) {
|
||||
if (process.env.DSH_WEB_RESTART_HOLD_STARTUP === '1') {
|
||||
if (typeof process.send !== 'function') throw new Error('web restart startup fixture requires an IPC channel')
|
||||
const released = Promise.withResolvers()
|
||||
const resume = (message) => {
|
||||
if (message === 'resume-startup') released.resolve(true)
|
||||
}
|
||||
ctx.effect(() => {
|
||||
process.on('message', resume)
|
||||
return () => {
|
||||
process.off('message', resume)
|
||||
released.resolve(false)
|
||||
}
|
||||
}, 'web-restart: startup barrier')
|
||||
process.send('startup-blocked')
|
||||
if (!await released.promise) return
|
||||
}
|
||||
ctx.reflect.provide('webRestartReady', {})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
- id: session-controller
|
||||
inject: [webRestartReady]
|
||||
- insert:
|
||||
- id: web-restart-startup
|
||||
name: ./restart-startup.js
|
||||
@@ -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 }) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
|
||||
await picker.getByRole('button', { name: '取消全选' }).click()
|
||||
await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
|
||||
await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
|
||||
await picker.getByRole('checkbox', { name: 'GPT-6 Astra', exact: true }).check()
|
||||
await picker.getByRole('button', { name: '添加所选' }).click()
|
||||
await dialog.getByRole('button', { name: '模型选项 1' }).click()
|
||||
expect(await image.isChecked()).toBe(true)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,16 +14,18 @@ class RestartableServer {
|
||||
private child: ChildProcess | undefined
|
||||
private closed: Promise<void> | undefined
|
||||
private output = ''
|
||||
readonly startupBlocked = Promise.withResolvers<undefined>()
|
||||
|
||||
constructor(private readonly world: string, private readonly modelUrl: string) {}
|
||||
|
||||
async start(port: number): Promise<string> {
|
||||
async start(port: number, holdStartup = false): Promise<string> {
|
||||
if (this.child !== undefined) throw new Error('Server is already running')
|
||||
this.output = ''
|
||||
const ready = Promise.withResolvers<string>()
|
||||
const child = spawn(process.execPath, [
|
||||
join(REPO_ROOT, 'apps/cli/lib/bin.js'), '--profile', 'web',
|
||||
'--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
'--patch', fileURLToPath(new URL('./fixtures/restart-startup.overlay.yml', import.meta.url)),
|
||||
'--no-open', '--port', String(port),
|
||||
], {
|
||||
cwd: this.world,
|
||||
@@ -32,8 +34,9 @@ class RestartableServer {
|
||||
DSH_HOME: join(this.world, 'home'), DSH_AGENTS_HOME: join(this.world, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-server-restart-fixture',
|
||||
DEEPSEEK_BASE_URL: this.modelUrl,
|
||||
DSH_WEB_RESTART_HOLD_STARTUP: holdStartup ? '1' : '0',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
})
|
||||
this.child = child
|
||||
const receive = (data: Buffer): void => {
|
||||
@@ -43,6 +46,9 @@ class RestartableServer {
|
||||
}
|
||||
child.stdout?.on('data', receive)
|
||||
child.stderr?.on('data', receive)
|
||||
child.on('message', (message) => {
|
||||
if (message === 'startup-blocked') this.startupBlocked.resolve(undefined)
|
||||
})
|
||||
child.once('error', (error) => { ready.reject(error) })
|
||||
this.closed = new Promise((resolve) => {
|
||||
child.once('close', (code, signal) => {
|
||||
@@ -58,6 +64,7 @@ class RestartableServer {
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child
|
||||
if (child === undefined) return
|
||||
this.resumeStartup()
|
||||
let forced = false
|
||||
const timer = setTimeout(() => { forced = true; child.kill('SIGKILL') }, 15_000)
|
||||
try {
|
||||
@@ -70,10 +77,14 @@ class RestartableServer {
|
||||
if (forced) throw new Error('Server did not stop after SIGTERM\n' + this.logs())
|
||||
}
|
||||
|
||||
resumeStartup(): void {
|
||||
if (this.child?.connected) this.child.send('resume-startup')
|
||||
}
|
||||
|
||||
logs(): string { return this.output.replace(/([?&]token=)[A-Za-z0-9_-]+/gu, '$1<redacted>') }
|
||||
}
|
||||
|
||||
it('keeps an opened Session and draft when the server restarts on the same port', async () => {
|
||||
it.each([false, true])('keeps the same revision, Session and page across a server restart (delayed startup: %s)', async (holdStartup) => {
|
||||
const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-'))
|
||||
onTestFinished(() => rm(world, { recursive: true, force: true }))
|
||||
const model = createServer((request, response) => {
|
||||
@@ -126,6 +137,7 @@ it('keeps an opened Session and draft when the server restarts on the same port'
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await newEnglishPage(browser)
|
||||
const errors: string[] = []
|
||||
const streamErrors: unknown[] = []
|
||||
const graphs: { rev: string; entries: { id: string; rev: string }[] }[] = []
|
||||
let readyFrames = 0
|
||||
let sessionBaselines = 0
|
||||
@@ -140,7 +152,8 @@ it('keeps an opened Session and draft when the server restarts on the same port'
|
||||
})
|
||||
socket.on('framereceived', ({ payload }) => {
|
||||
if (typeof payload !== 'string') return
|
||||
const message = JSON.parse(payload) as { type?: string; streamId: string; value?: { type?: string } }
|
||||
const message = JSON.parse(payload) as { type?: string; streamId: string; value?: { type?: string }; error?: unknown }
|
||||
if (message.type === 'error') streamErrors.push(message.error)
|
||||
if (message.type === 'item' && message.value?.type === 'ready') readyFrames++
|
||||
if (message.type === 'item' && endpoints.get(message.streamId) === 'session/follow'
|
||||
&& message.value?.type === 'snapshot') sessionBaselines++
|
||||
@@ -154,7 +167,7 @@ it('keeps an opened Session and draft when the server restarts on the same port'
|
||||
})
|
||||
onTestFailed(async () => {
|
||||
await saveFailureShot(page, 'web-server-restart-' + scenarioId)
|
||||
await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-' + scenarioId + '.json'), JSON.stringify({ errors, graphs, server: server.logs() }, null, 2))
|
||||
await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-' + scenarioId + '.json'), JSON.stringify({ errors, streamErrors, graphs, server: server.logs() }, null, 2))
|
||||
})
|
||||
await page.goto(url, { waitUntil: 'load' })
|
||||
await page.locator('[data-slot="root"]').waitFor({ state: 'attached', timeout: 20_000 })
|
||||
@@ -177,7 +190,29 @@ it('keeps an opened Session and draft when the server restarts on the same port'
|
||||
page.on('framenavigated', () => { navigations++ })
|
||||
|
||||
await server.stop()
|
||||
const restarted = await server.start(port)
|
||||
let restarted: string
|
||||
const restarting = server.start(port, holdStartup)
|
||||
try {
|
||||
if (holdStartup) {
|
||||
await Promise.race([
|
||||
server.startupBlocked.promise,
|
||||
restarting.then(() => { throw new Error('the restarted Host did not hold controller startup') }),
|
||||
])
|
||||
const opened = await page.evaluate(() => new Promise<boolean>((resolve) => {
|
||||
const endpoint = new URL('api/remote.mux', location.href)
|
||||
endpoint.protocol = 'ws:'
|
||||
const socket = new WebSocket(endpoint)
|
||||
let accepted = false
|
||||
socket.addEventListener('open', () => { accepted = true; socket.close() })
|
||||
socket.addEventListener('close', () => { resolve(accepted) })
|
||||
}))
|
||||
expect(opened).toBe(false)
|
||||
expect(readyFrames).toBe(beforeReady)
|
||||
}
|
||||
} finally {
|
||||
server.resumeStartup()
|
||||
restarted = await restarting
|
||||
}
|
||||
expect(new URL(restarted).origin).toBe(new URL(url).origin)
|
||||
await expect.poll(() => readyFrames, { timeout: 30_000 }).toBeGreaterThan(beforeReady)
|
||||
await expect.poll(() => sessionBaselines, { timeout: 30_000 }).toBeGreaterThan(beforeBaseline)
|
||||
@@ -187,7 +222,8 @@ it('keeps an opened Session and draft when the server restarts on the same port'
|
||||
}))
|
||||
|
||||
expect(errors).toEqual([])
|
||||
expect(graphs.at(-1)).toEqual(graphs[beforeGraphs - 1])
|
||||
expect(streamErrors).toEqual([])
|
||||
for (const graph of graphs.slice(beforeGraphs)) expect(graph).toEqual(graphs[beforeGraphs - 1])
|
||||
expect(await originalRoot!.evaluate(element => element.isConnected)).toBe(true)
|
||||
expect(await composer.textContent()).toBe(draft)
|
||||
expect(await response.isVisible()).toBe(true)
|
||||
@@ -195,5 +231,6 @@ it('keeps an opened Session and draft when the server restarts on the same port'
|
||||
await composer.press('Enter')
|
||||
await expect.poll(() => response.count(), { timeout: 20_000 }).toBe(2)
|
||||
expect(errors).toEqual([])
|
||||
expect(streamErrors).toEqual([])
|
||||
expect(navigations).toBe(0)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]!
|
||||
|
||||
@@ -121,6 +121,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
@@ -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,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: 05ee813fed33a1b781da898838d67b6d1a23dc89
|
||||
config-catalog.zh.md: 6a40a7dd204fc9bfeb1b23fff1913838c30c54ba
|
||||
config-catalog.md: 655de8ff1ba633f775bdab7b5ff3546a9c243305
|
||||
config-catalog.zh.md: b46a1d855a68a1179e73b50b5f8b862e7847c32a
|
||||
@@ -194,7 +194,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts)
|
||||
Source: [`packages/api/gateway/src/index.ts:145`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-job-controller"></a>
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts)
|
||||
来源:[`packages/api/gateway/src/index.ts:145`](../packages/api/gateway/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-job-controller"></a>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:*" }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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)。
|
||||
@@ -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:~"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-account-controller",
|
||||
"description": "Expose safe account operations over authenticated Remote",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -47,13 +47,13 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "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 packages/api/gateway/README.md
|
||||
README.md: b5b64d3fa073c4f992529b63bc2e02117a28cecf
|
||||
README.zh.md: 87919703fdb2a1f9e32603bf6a1d152d4cc5404e
|
||||
README.md: 1a746a83eb966fba6ae40b61061d42bf478e1c84
|
||||
README.zh.md: 7b37548f6ad894b761457df80278eeed7aa6ca7b
|
||||
@@ -34,6 +34,8 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H
|
||||
|
||||
A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it.
|
||||
|
||||
When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged.
|
||||
|
||||
Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream<Out, In>` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream<Out>` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly.
|
||||
|
||||
Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready.
|
||||
|
||||
@@ -34,6 +34,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。
|
||||
|
||||
启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。
|
||||
|
||||
每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream<Out, In>`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream<Out>` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。
|
||||
|
||||
Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "Typert Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -55,25 +55,26 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-deque": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"@deepseek-ai/dsh-deque": "workspace:*",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"ws": "^8.21.0",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-cmdline": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:*",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:*",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type {} from '@deepseek-ai/dsh-cmdline'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
export type { TypertGatewayFaultDetails } from './remote-error-codes.ts'
|
||||
import {
|
||||
@@ -217,6 +218,8 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
|
||||
/**
|
||||
* Register the Gateway against the active Typert registry.
|
||||
* WebSocket admission waits for launcher-owned application readiness when supplied;
|
||||
* direct invocation and in-process streams remain available independently.
|
||||
* @param ctx - owning Host Context with Typert registry access.
|
||||
* @param config - validated Gateway transport configuration.
|
||||
*/
|
||||
@@ -234,31 +237,42 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
)
|
||||
})
|
||||
ctx.inject(['connection', 'webServer'], (webCtx) => {
|
||||
const mux = new RemoteStreamMuxServer(
|
||||
(endpoint, payload, uplink, peer, control) =>
|
||||
this.openWireStream(endpoint, payload, uplink, peer, control.signal, control),
|
||||
this.wireStream.failure,
|
||||
resolved.websocketHeartbeatIntervalMs,
|
||||
resolved.streamInboxBytes,
|
||||
)
|
||||
webCtx.effect(() => {
|
||||
const route: WebUpgradeRoute = {
|
||||
path: REMOTE_STREAM_MUX_PATH,
|
||||
handler: (req, socket, head) => {
|
||||
const admission = webCtx.connection.admit(req)
|
||||
if ('rejection' in admission) {
|
||||
rejectRemoteStreamUpgrade(socket, admission.rejection)
|
||||
return
|
||||
}
|
||||
mux.handleUpgrade(req, socket, head, admission.peer)
|
||||
},
|
||||
const listen = (): void => {
|
||||
const mux = new RemoteStreamMuxServer(
|
||||
(endpoint, payload, uplink, peer, control) =>
|
||||
this.openWireStream(endpoint, payload, uplink, peer, control.signal, control),
|
||||
this.wireStream.failure,
|
||||
resolved.websocketHeartbeatIntervalMs,
|
||||
resolved.streamInboxBytes,
|
||||
)
|
||||
webCtx.effect(function* () {
|
||||
yield () => mux.close()
|
||||
const route: WebUpgradeRoute = {
|
||||
path: REMOTE_STREAM_MUX_PATH,
|
||||
handler: (req, socket, head) => {
|
||||
const admission = webCtx.connection.admit(req)
|
||||
if ('rejection' in admission) {
|
||||
rejectRemoteStreamUpgrade(socket, admission.rejection)
|
||||
return
|
||||
}
|
||||
mux.handleUpgrade(req, socket, head, admission.peer)
|
||||
},
|
||||
}
|
||||
yield webCtx.webServer.registerUpgrade(route)
|
||||
}, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`)
|
||||
}
|
||||
// Existing pages reconnect before the new Host prints its URL. No stream
|
||||
// may enter until the launcher has activated and audited its controllers.
|
||||
const ready = webCtx.get('appReady')
|
||||
if (ready === undefined) listen()
|
||||
else webCtx.effect(() => {
|
||||
let closed = false
|
||||
const cancel = ready.onReady(() => { if (!closed) listen() })
|
||||
return () => {
|
||||
closed = true
|
||||
cancel()
|
||||
}
|
||||
const unregister = webCtx.webServer.registerUpgrade(route)
|
||||
return async () => {
|
||||
unregister()
|
||||
await mux.close()
|
||||
}
|
||||
}, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`)
|
||||
}, 'api-gateway: application readiness')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Context, symbols } from '@deepseek-ai/cordis'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { AppReady } from '@deepseek-ai/dsh-cmdline'
|
||||
import {
|
||||
Remote,
|
||||
remoteErrorOf,
|
||||
@@ -229,6 +230,30 @@ class FeedService extends TypertRemoteService {
|
||||
|
||||
const roots: Context[] = []
|
||||
|
||||
class StartupProbe implements AppReady {
|
||||
private ready = false
|
||||
private readonly listeners = new Set<() => void>()
|
||||
lastListener: (() => void) | undefined
|
||||
|
||||
get pending(): number { return this.listeners.size }
|
||||
|
||||
onReady(listener: () => void): () => void {
|
||||
this.lastListener = listener
|
||||
if (this.ready) {
|
||||
listener()
|
||||
return () => {}
|
||||
}
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
commit(): void {
|
||||
this.ready = true
|
||||
for (const listener of this.listeners) listener()
|
||||
this.listeners.clear()
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteEventSourceProbe {
|
||||
readonly source = (signal: AbortSignal): AsyncIterable<TypertRemoteEventDispatch> => {
|
||||
this.signal = signal
|
||||
@@ -307,6 +332,30 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Typert Remote streams', () => {
|
||||
it.each([false, true])('accepts WebSockets only after application readiness (already ready: %s)', async (alreadyReady) => {
|
||||
const startup = new StartupProbe()
|
||||
if (alreadyReady) startup.commit()
|
||||
const { ctx } = await setup(true, {}, startup)
|
||||
if (!alreadyReady) {
|
||||
expect(startup.pending).toBe(1)
|
||||
expect(await acceptsSocket(ctx)).toBe(false)
|
||||
startup.commit()
|
||||
}
|
||||
expect(await acceptsSocket(ctx)).toBe(true)
|
||||
expect(startup.pending).toBe(0)
|
||||
})
|
||||
|
||||
it('withdraws a pending WebSocket startup subscription when the Gateway unloads', async () => {
|
||||
const startup = new StartupProbe()
|
||||
const { ctx } = await setup(true, {}, startup)
|
||||
expect(startup.pending).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(startup.pending).toBe(0)
|
||||
// A launcher commit can already hold a copy of the cancelled listener.
|
||||
expect(() => { startup.lastListener?.() }).not.toThrow()
|
||||
expect(() => { startup.commit() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => {
|
||||
expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 })
|
||||
expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }))
|
||||
@@ -1341,9 +1390,11 @@ describe('Typert Remote streams', () => {
|
||||
async function setup(
|
||||
transport: boolean,
|
||||
gatewayConfig: GatewayConfig = {},
|
||||
ready?: AppReady,
|
||||
): Promise<{ readonly ctx: Context; readonly service: FeedService }> {
|
||||
const ctx = new Context()
|
||||
roots.push(ctx)
|
||||
if (ready !== undefined) ctx.provide('appReady', ready)
|
||||
if (transport) {
|
||||
await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 })
|
||||
provideBrowserCredentials(ctx)
|
||||
@@ -1365,6 +1416,17 @@ async function setup(
|
||||
return { ctx, service: receiver[symbols.original] ?? receiver }
|
||||
}
|
||||
|
||||
async function acceptsSocket(ctx: Context): Promise<boolean> {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, {
|
||||
headers: { cookie: browserCookie(ctx) },
|
||||
})
|
||||
const closed = new Promise<void>((resolve) => { socket.once('close', () => { resolve() }) })
|
||||
const opened = await once(socket, 'open').then(() => true, () => false)
|
||||
if (opened) socket.close()
|
||||
await closed
|
||||
return opened
|
||||
}
|
||||
|
||||
function descriptors(): InvocationDescriptor[] {
|
||||
const label = {
|
||||
name: 'label',
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../boot/cmdline"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-job-controller",
|
||||
"description": "Job Remote observation stream and the reference-counted client job-output service",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -65,22 +65,22 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:*",
|
||||
"@deepseek-ai/dsh-jobs-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly for application-selected Host capabilities",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -50,53 +50,53 @@
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-deque": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^"
|
||||
"@deepseek-ai/dsh-deque": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-preset-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-job-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-files": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-job-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-settings-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:*",
|
||||
"@deepseek-ai/dsh-commands": "workspace:*",
|
||||
"@deepseek-ai/dsh-cordis-host-runner": "workspace:*",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:*",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:*",
|
||||
"@deepseek-ai/dsh-goal": "workspace:*",
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:*",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-workspace-files": "workspace:*",
|
||||
"zod": "^4.4.3",
|
||||
"@deepseek-ai/dsh-command-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-account-controller": "workspace:^"
|
||||
"@deepseek-ai/dsh-command-feedback": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-terminal-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-account-controller": "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 packages/api/session-controller/README.md
|
||||
README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a
|
||||
README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec
|
||||
README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752
|
||||
README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272
|
||||
@@ -43,12 +43,12 @@ Projection reads retain loading and failure state independently of their values.
|
||||
|
||||
Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling.
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
|
||||
During uninterrupted following, steering echoes stay mounted through Inbox acceptance and claim until their durable messages arrive. A replacement follow baseline withdraws earlier receipt-confirmed steering echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
|
||||
During uninterrupted following, transcript and steering echoes stay in Chat through Inbox acceptance and claim until their durable messages arrive. After a local Chat message is admitted, its identity remains until the Inbox watermark reaches its next-turn or next-step claim seq: Chat already renders the durable Node, while Chat and QueueDock exclude matching stale Inbox rows. Other queued messages remain visible. A replacement follow baseline withdraws receipt-confirmed echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure.
|
||||
|
||||
The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
|
||||
|
||||
@@ -56,7 +56,9 @@ Fork copies the exact inclusive event prefix selected by `atSeq`, including a cu
|
||||
|
||||
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
|
||||
|
||||
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` still publishes its single page directly.
|
||||
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` publishes one Host-selected page directly.
|
||||
|
||||
The Client's initial `follow`, reconnect openings, and `loadOlder()` request at least 50 append-origin `user/message` and `assistant/message` events and cross at least two `turn/start` events, including the partial Turn at the loaded window's beginning. The Host stops at the first Turn start satisfying both minima, or at 500 counted messages or history exhaustion, whichever comes first. Steering does not add a Turn boundary. Intervening events accompany the page without counting toward the message budget. The optional `turnWindow` on page and follow requests supplies these minima under `maxMessages`. `loadThrough()` uses the same rules with a 200-message minimum per page; the 500-message cap does not limit the whole jump. Requests without `turnWindow` retain message-aligned paging.
|
||||
|
||||
Queue edits replace pending content with non-empty text only.
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
|
||||
|
||||
已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。
|
||||
|
||||
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
|
||||
|
||||
|
||||
正常在线时,steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达。新的 follow 基线会将此前已有接收回执的 steering 回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
|
||||
正常在线时,transcript 与 steering 回显在 Inbox 接受与领取期间留在 Chat,直到持久消息到达。本地 Chat 消息入档后,其身份保留到 Inbox 水位覆盖 next-turn 或 next-step 领取序号:Chat 已显示真实 Node,而 Chat 与 QueueDock 排除匹配的旧 Inbox 行。其他排队消息照常显示。新的 follow 基线会将已有接收回执的回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。
|
||||
|
||||
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
|
||||
|
||||
@@ -56,7 +56,9 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
|
||||
|
||||
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
|
||||
|
||||
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。
|
||||
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 直接发布 Host 选取的一页结果。
|
||||
|
||||
Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 append 方式追加的 `user/message` 和 `assistant/message` 事件,并向前跨过至少两个 `turn/start`,其中包含已加载窗口开头尚未补齐的轮次。Host 在首次同时满足两个下限的轮次开头停止;若先达到 500 条计数消息或历史已耗尽,则立即返回。Steering 不增加轮次分界。区间内的其他事件随页返回,但不计入消息预算。分页和 follow 请求可选的 `turnWindow` 在 `maxMessages` 上限内指定这两个下限。`loadThrough()` 复用相同规则,仅将每页消息数下限设为 200;500 条上限不限制整次跳转。不带 `turnWindow` 的请求保留按消息对齐的分页方式。
|
||||
|
||||
队列编辑仅允许用非空文本替换待处理内容。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-session-controller",
|
||||
"description": "Session Remote commands, cold reads, and live control transport",
|
||||
"version": "0.1.7-alpha.1",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -69,41 +69,41 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-deque": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-deque": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"mime-types": "^3.0.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-preset-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:*",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:*",
|
||||
"@deepseek-ai/dsh-commands": "workspace:*",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:*",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
@@ -114,45 +114,45 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-default-model": "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-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-default-model": "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-gateway": "workspace:*",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-commands": "workspace:*",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:*",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:*",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:*",
|
||||
"@deepseek-ai/dsh-scope": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:*",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:*",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:*",
|
||||
"@types/mime-types": "^3.0.1"
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,10 @@ export interface ISession {
|
||||
/**
|
||||
* Register one local submission echo in `snapshot.pendingSubmissions`,
|
||||
* synchronously, before the caller serializes and sends the prompt. The
|
||||
* echo retires when a durable `user/message` event or queue occurrence
|
||||
* carrying the returned identity arrives, or when the identified prompt
|
||||
* call fails.
|
||||
* Chat echoes persist until durable admission; transcript identities also
|
||||
* wait for the Inbox claim watermark so stale queue rows stay suppressed.
|
||||
* Queued echoes retire on queue acceptance. Identified failures retire
|
||||
* submissions that have not already reached durable admission.
|
||||
* @param input - echo content and the optional settlement callback.
|
||||
* @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
|
||||
*/
|
||||
@@ -118,7 +119,9 @@ export interface ISession {
|
||||
*/
|
||||
rename(title: string): Promise<RemoteResult<{ title: string; seq: SessionSeq }>>
|
||||
/**
|
||||
* Extend the history window backwards (older messages pagination).
|
||||
* Extend history by at least 50 messages and two Turn starts, including a
|
||||
* partial Turn at the window's beginning. Stop at 500 messages or history
|
||||
* exhaustion even when those minima cannot be met. Publish one prepend.
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
@@ -127,6 +130,8 @@ export interface ISession {
|
||||
* turn-jump loader. Repeated calls while a jump is paging lower its shared
|
||||
* target and return the in-flight completion; `snapshot.loadingOlder` is
|
||||
* the busy signal for the whole jump.
|
||||
* Each page uses the same Turn alignment as loadOlder, with a 200-message
|
||||
* minimum. The 500-message limit applies per page, not to the whole jump.
|
||||
* Older pages publish together at completion, including successful pages
|
||||
* before a later failure; live events remain independently visible.
|
||||
* @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
|
||||
|
||||
@@ -46,7 +46,7 @@ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
|
||||
export interface PendingSubmission {
|
||||
/** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */
|
||||
readonly requestId: SessionRequestId
|
||||
/** Expected surface until the Host reports the admitted queue or durable occurrence. */
|
||||
/** Surface fixed at submission time; Inbox acceptance does not move a Chat echo into the dock. */
|
||||
readonly placement: PendingSubmissionPlacement
|
||||
/** Client wall-clock ms when the submission began. */
|
||||
readonly time: number
|
||||
|
||||
@@ -117,6 +117,16 @@ export class ProjectionValueStore {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the accepted Host watermark without subscribing or copying a value.
|
||||
* @param key - projection key.
|
||||
* @returns the current sequence, or undefined for absent and cached values.
|
||||
*/
|
||||
seqOf(key: string): SessionSeqCursor | undefined {
|
||||
const row = this.rows.get(key)
|
||||
return row?.kind === 'sequenced' ? row.seq : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every current projection value as one reference-stable snapshot.
|
||||
* @returns The same frozen value map until a row changes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Sessions remain resident after creation so their open Remote sources keep running off-screen.
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import type { InboxState, InboxTarget } from '@deepseek-ai/dsh-agent/types'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
@@ -48,12 +48,19 @@ function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBasel
|
||||
}
|
||||
}
|
||||
|
||||
/** Messages requested per history page. */
|
||||
/** Minimum message count for ordinary history windows. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
|
||||
const HISTORY_PAGE_OPTIONS = { maxMessages: 500, turnWindow: { minMessages: PAGE_MESSAGES, minTurns: 2 } }
|
||||
|
||||
/** Minimum messages per page while a turn jump loops backwards. */
|
||||
export const JUMP_PAGE_MESSAGES = 200
|
||||
|
||||
const JUMP_PAGE_OPTIONS = {
|
||||
...HISTORY_PAGE_OPTIONS,
|
||||
turnWindow: { ...HISTORY_PAGE_OPTIONS.turnWindow, minMessages: JUMP_PAGE_MESSAGES },
|
||||
}
|
||||
|
||||
interface PendingHistory {
|
||||
beforeSeq: SessionLogOffset
|
||||
hasMore: boolean
|
||||
@@ -126,12 +133,15 @@ export class Session implements SessionFace {
|
||||
* Inbox projection and its durable event cannot both retire one echo. */
|
||||
private readonly submissionSettlements = new Map<SessionRequestId, {
|
||||
readonly placement: PendingSubmission['placement']
|
||||
/** Latest received next-step position; null means claimed but not yet admitted. */
|
||||
/** Latest received Inbox position; null means removed from that queue. */
|
||||
receipt?: {
|
||||
readonly target: InboxTarget
|
||||
readonly seq: number
|
||||
readonly index: number | null
|
||||
readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]
|
||||
}
|
||||
/** Durable admission may precede the Inbox projection acknowledging its claim. */
|
||||
admitted?: readonly (ImageAttachmentRef | FileAttachmentRef)[]
|
||||
readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined
|
||||
retiring: boolean
|
||||
}>()
|
||||
@@ -392,7 +402,7 @@ export class Session implements SessionFace {
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
|
||||
/** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
const events = this.events
|
||||
@@ -400,7 +410,10 @@ export class Session implements SessionFace {
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
|
||||
await events.prepend({
|
||||
beforeSeq: this.baseSeq,
|
||||
...HISTORY_PAGE_OPTIONS,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isRemoteFailure(error)) {
|
||||
console.error('[session-controller] loadOlder failed:', error)
|
||||
@@ -444,7 +457,7 @@ export class Session implements SessionFace {
|
||||
while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
|
||||
if (generation !== this.openGeneration) return
|
||||
const before = pending.beforeSeq
|
||||
await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES })
|
||||
await events.prepend({ beforeSeq: before, ...JUMP_PAGE_OPTIONS })
|
||||
// No-progress guard: an empty or dropped page that still claims more
|
||||
// history must end the loop, not spin it.
|
||||
if (pending.beforeSeq >= before) return
|
||||
@@ -586,10 +599,10 @@ export class Session implements SessionFace {
|
||||
async dispose(): Promise<void> {
|
||||
this.stopObservingInbox()
|
||||
// Unsettled echoes retire as failed so their owners can restore or
|
||||
// release browser resources; echoes already scheduled as observed keep
|
||||
// that settlement.
|
||||
for (const requestId of [...this.submissionSettlements.keys()]) {
|
||||
this.retireFailedSubmission(requestId)
|
||||
// release browser resources; admitted echoes keep their observed outcome.
|
||||
for (const [requestId, settlement] of [...this.submissionSettlements]) {
|
||||
if (settlement.admitted !== undefined) this.scheduleObservedRetirement(requestId, settlement.admitted)
|
||||
else this.retireFailedSubmission(requestId)
|
||||
}
|
||||
this.openGeneration++
|
||||
const events = this.events
|
||||
@@ -615,7 +628,7 @@ export class Session implements SessionFace {
|
||||
})
|
||||
this.events = events
|
||||
try {
|
||||
await events.open({ maxMessages: PAGE_MESSAGES })
|
||||
await events.open(HISTORY_PAGE_OPTIONS)
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -672,7 +685,7 @@ export class Session implements SessionFace {
|
||||
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(visible, hasMore)
|
||||
// A new follow baseline replaces optimistic steering with Host-owned rows.
|
||||
// A new follow baseline replaces confirmed optimistic inputs with Host-owned rows.
|
||||
// Receipt-backed inputs are accepted, not failed, even if their history is outside this window.
|
||||
if (projections !== undefined) {
|
||||
for (const [requestId, { receipt }] of this.submissionSettlements) {
|
||||
@@ -684,7 +697,9 @@ export class Session implements SessionFace {
|
||||
for (const entry of visible) this.observeSubmissionEvent(entry.event)
|
||||
if (projections !== undefined) {
|
||||
const inbox = projections.values.inbox as InboxState | undefined
|
||||
this.observeSteeringInsertions(inbox?.['next-step'] ?? [], 0, projections.asOfSeq)
|
||||
for (const target of ['next-turn', 'next-step'] as const) {
|
||||
this.observeSubmissionInsertions(target, inbox?.[target] ?? [], 0, projections.asOfSeq)
|
||||
}
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -748,55 +763,76 @@ export class Session implements SessionFace {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
if (event.type === 'agent/inbox/spliced') {
|
||||
const { target, start, removedCount = 0, inserted, outcome } = event.data
|
||||
if (target === 'next-step') {
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
const receipt = settlement.receipt
|
||||
if (receipt === undefined || receipt.index === null || receipt.seq >= event.seq) continue
|
||||
const removed = receipt.index >= start && receipt.index < start + removedCount
|
||||
if (removed && outcome === 'canceled') this.retireFailedSubmission(requestId)
|
||||
else settlement.receipt = {
|
||||
...receipt,
|
||||
seq: event.seq,
|
||||
index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount,
|
||||
}
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
const receipt = settlement.receipt
|
||||
if (receipt?.target !== target || receipt.index === null || receipt.seq >= event.seq) continue
|
||||
const removed = receipt.index >= start && receipt.index < start + removedCount
|
||||
if (removed && outcome === 'canceled') this.retireFailedSubmission(requestId)
|
||||
else settlement.receipt = {
|
||||
...receipt,
|
||||
seq: event.seq,
|
||||
index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount,
|
||||
}
|
||||
this.observeSteeringInsertions(inserted, start, event.seq)
|
||||
}
|
||||
this.observeSubmissionInsertions(target, inserted, start, event.seq)
|
||||
for (const message of inserted) this.observeSubmissionMessage(message, false)
|
||||
return
|
||||
}
|
||||
if (event.type === 'request/context' || event.type === 'turn/end') {
|
||||
for (const [requestId, settlement] of this.submissionSettlements) {
|
||||
if (settlement.receipt?.index === null && settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId)
|
||||
if (settlement.admitted === undefined && settlement.receipt?.index === null
|
||||
&& settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'user/message') this.observeSubmissionMessage(event.data, true)
|
||||
}
|
||||
|
||||
private observeSteeringInsertions(messages: readonly UserMessage[], start: number, seq: number): void {
|
||||
private observeSubmissionInsertions(target: InboxTarget, messages: readonly UserMessage[], start: number, seq: number): void {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
const source = message.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) continue
|
||||
const settlement = this.submissionSettlements.get(source.rpcId)
|
||||
if (settlement?.placement !== 'steering' || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue
|
||||
settlement.receipt = { seq, index: start + index, attachments: attachmentRefsIn(message.content) }
|
||||
if (settlement === undefined || settlement.placement === 'queued'
|
||||
|| settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue
|
||||
settlement.receipt = { target, seq, index: start + index, attachments: attachmentRefsIn(message.content) }
|
||||
}
|
||||
}
|
||||
|
||||
private observeSubmissionMessage(message: UserMessage, admitted: boolean): void {
|
||||
const source = message.source
|
||||
if (source.kind !== 'user' || !('rpcId' in source)) return
|
||||
if (!admitted && this.submissionSettlements.get(source.rpcId)?.placement === 'steering') return
|
||||
this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
|
||||
const settlement = this.submissionSettlements.get(source.rpcId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
if (!admitted) {
|
||||
if (settlement.placement === 'queued') this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
|
||||
return
|
||||
}
|
||||
settlement.admitted = attachmentRefsIn(message.content)
|
||||
this.retireAdmittedSubmission(source.rpcId)
|
||||
}
|
||||
|
||||
/** Retire non-steering echoes when the Inbox accepts their queue occurrences. */
|
||||
/** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */
|
||||
private retireAdmittedSubmission(requestId: SessionRequestId): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement?.admitted === undefined) return
|
||||
const receipt = settlement.receipt
|
||||
if (receipt?.index === null
|
||||
&& (this.projections.seqOf('inbox') ?? -1) < receipt.seq) return
|
||||
this.scheduleObservedRetirement(requestId, settlement.admitted)
|
||||
}
|
||||
|
||||
/** Inbox acceptance retires queued echoes; its watermark completes admitted Chat handoffs. */
|
||||
private observeSubmissionInbox(): void {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
const inbox = this.projections.get('inbox') as InboxState | undefined
|
||||
if (inbox === undefined) return
|
||||
for (const message of [...inbox['next-turn'], ...inbox['next-step']]) this.observeSubmissionMessage(message, false)
|
||||
const seq = this.projections.seqOf('inbox')
|
||||
for (const target of ['next-turn', 'next-step'] as const) {
|
||||
if (seq !== undefined) this.observeSubmissionInsertions(target, inbox[target], 0, seq)
|
||||
for (const message of inbox[target]) this.observeSubmissionMessage(message, false)
|
||||
}
|
||||
for (const requestId of this.submissionSettlements.keys()) this.retireAdmittedSubmission(requestId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -818,7 +854,7 @@ export class Session implements SessionFace {
|
||||
/** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */
|
||||
private retireFailedSubmission(requestId: SessionRequestId): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
if (settlement === undefined || settlement.retiring || settlement.admitted !== undefined) return
|
||||
settlement.retiring = true
|
||||
this.finishSubmission(requestId, { reason: 'failed' })
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
for await (const frame of this.remote.session.follow({
|
||||
address: this.address,
|
||||
assistantStream: true,
|
||||
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
|
||||
...this.repairRequest(request),
|
||||
}, signal)) {
|
||||
if (frame.type === 'snapshot') {
|
||||
for (const record of frame.records) assertSessionWireEvent(record.event)
|
||||
@@ -238,6 +238,9 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
protected override repairRequest(
|
||||
request: ClientSessionPageRequest,
|
||||
): ClientSessionPageRequest {
|
||||
return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }
|
||||
return {
|
||||
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
|
||||
...(request.turnWindow === undefined ? {} : { turnWindow: request.turnWindow }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export class SessionHistoryController {
|
||||
beforeSeq,
|
||||
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
|
||||
throughSeq,
|
||||
request.turnWindow,
|
||||
)
|
||||
const records = pageRecords(page.events)
|
||||
return {
|
||||
@@ -117,7 +118,7 @@ export class SessionHistoryController {
|
||||
* @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames.
|
||||
*/
|
||||
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
validateFollowRequest(request)
|
||||
validateHistoryWindow(request)
|
||||
const { address } = request
|
||||
const target = addressId(address)
|
||||
const buffered = new Deque<
|
||||
@@ -180,7 +181,7 @@ export class SessionHistoryController {
|
||||
signal.throwIfAborted()
|
||||
const cursor = source.cursor
|
||||
snapshotCursor = cursor
|
||||
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES, cursor, request.turnWindow)
|
||||
const assistantStream = request.assistantStream === true
|
||||
? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 }
|
||||
: undefined
|
||||
@@ -313,16 +314,23 @@ function validatePageRequest(request: SessionPageRequest): void {
|
||||
|| Object.is(request.beforeSeq, -0))) {
|
||||
throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
|
||||
}
|
||||
validateHistoryWindow(request)
|
||||
}
|
||||
|
||||
function validateHistoryWindow(request: Pick<SessionPageRequest, 'maxMessages' | 'turnWindow'>): void {
|
||||
if (request.maxMessages !== undefined
|
||||
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
||||
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
|
||||
}
|
||||
}
|
||||
|
||||
function validateFollowRequest(request: SessionFollowRequest): void {
|
||||
if (request.maxMessages !== undefined
|
||||
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
||||
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
|
||||
const window = request.turnWindow
|
||||
if (window !== undefined) {
|
||||
if (!Number.isSafeInteger(window.minMessages) || window.minMessages <= 0
|
||||
|| window.minMessages > (request.maxMessages ?? DEFAULT_MAX_MESSAGES)) {
|
||||
throw new RemoteError('gateway/bad-request', 'turnWindow.minMessages must be a positive safe integer no greater than maxMessages', {})
|
||||
}
|
||||
if (!Number.isSafeInteger(window.minTurns) || window.minTurns <= 0) {
|
||||
throw new RemoteError('gateway/bad-request', 'turnWindow.minTurns must be a positive safe integer', {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,13 +393,22 @@ function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: SessionLogOffsetType | undefined,
|
||||
maxMessages: number,
|
||||
throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1,
|
||||
throughSeq: SessionSeqCursor,
|
||||
turnWindow?: SessionPageRequest['turnWindow'],
|
||||
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
|
||||
const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1))
|
||||
let count = 0
|
||||
let turns = 0
|
||||
let cut = SessionLogOffset(0)
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (turnWindow !== undefined && event.type === 'turn/start') {
|
||||
turns++
|
||||
if (count >= turnWindow.minMessages && turns >= turnWindow.minTurns) {
|
||||
cut = SessionLogOffset(index)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
||||
count++
|
||||
const sources = event.sourceEventSeqs
|
||||
|
||||
@@ -472,12 +472,18 @@ export interface SessionPageRequest {
|
||||
readonly throughSeq: number
|
||||
readonly beforeSeq?: number
|
||||
readonly maxMessages?: number
|
||||
/** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */
|
||||
readonly turnWindow?: {
|
||||
/** Minimum append-origin user/assistant messages; must not exceed maxMessages. */
|
||||
readonly minMessages: number
|
||||
/** Minimum Turn starts crossed, including the partial Turn at beforeSeq. */
|
||||
readonly minTurns: number
|
||||
}
|
||||
}
|
||||
|
||||
/** One live event request for a durable Session address. */
|
||||
export interface SessionFollowRequest {
|
||||
export interface SessionFollowRequest extends Pick<SessionPageRequest, 'maxMessages' | 'turnWindow'> {
|
||||
readonly address: SessionAddress
|
||||
readonly maxMessages?: number
|
||||
/** Include process-local assistant presentation frames for the Web client. */
|
||||
readonly assistantStream?: true
|
||||
}
|
||||
|
||||
@@ -777,7 +777,8 @@ describe('subagent catalogs', () => {
|
||||
mode: 'continuable',
|
||||
},
|
||||
assistantStream: true,
|
||||
maxMessages: 50,
|
||||
maxMessages: 500,
|
||||
turnWindow: { minMessages: 50, minTurns: 2 },
|
||||
},
|
||||
])
|
||||
expect(mock.log.requests('session/page')).toEqual([])
|
||||
|
||||
@@ -44,6 +44,18 @@ function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): Se
|
||||
}
|
||||
|
||||
describe('Session projection value semantics', () => {
|
||||
it('exposes a watermark only for current Host-sequenced values', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.seqOf('inbox')).toBeUndefined()
|
||||
store.applyCached({ inbox: { 'next-turn': [], 'next-step': [] } })
|
||||
expect(store.seqOf('inbox')).toBeUndefined()
|
||||
store.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(4))
|
||||
store.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(2))
|
||||
expect(store.seqOf('inbox')).toBe(4)
|
||||
store.seed({ asOfSeq: SessionSeq(6), values: {} })
|
||||
expect(store.seqOf('inbox')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads undefined until a value lands (capability absence)', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.get('test/marks')).toBeUndefined()
|
||||
|
||||
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user